fix: local stream video visibility and audio double-playback

Show local user's own screen share in their stream tile by bypassing
the isSubscribed check for local participants and auto-watching/unwatching
local streams on publish/unpublish. Eliminate audio double-playback by
detaching LiveKit's auto-attached audio elements so GlobalAudioRenderer
is the sole playback path, making attenuation and volume controls effective.
Fix GainNode leak in useAudioTrackPlayer cleanup paths.
This commit is contained in:
Jannis Braun
2026-02-20 04:47:50 +01:00
parent 44018aa588
commit 6f777f97ce
4 changed files with 108 additions and 18 deletions
@@ -71,10 +71,13 @@ export function useAudioTrackPlayer(opts) {
else { else {
// --- STANDARD MODE (0% - 100%) --- // --- STANDARD MODE (0% - 100%) ---
// Clean up boost pipeline if it exists // Clean up boost pipeline if it exists
if (boostGainRef.current) {
boostGainRef.current.disconnect();
boostGainRef.current = null;
}
if (boostSourceRef.current) { if (boostSourceRef.current) {
boostSourceRef.current.disconnect(); boostSourceRef.current.disconnect();
boostSourceRef.current = null; boostSourceRef.current = null;
boostGainRef.current = null;
} }
audioEl.muted = false; audioEl.muted = false;
audioEl.volume = Math.min(volume, 1.0); audioEl.volume = Math.min(volume, 1.0);
@@ -84,10 +87,13 @@ export function useAudioTrackPlayer(opts) {
} }
} }
return () => { return () => {
if (boostGainRef.current) {
boostGainRef.current.disconnect();
boostGainRef.current = null;
}
if (boostSourceRef.current) { if (boostSourceRef.current) {
boostSourceRef.current.disconnect(); boostSourceRef.current.disconnect();
boostSourceRef.current = null; boostSourceRef.current = null;
boostGainRef.current = null;
} }
}; };
}, [volume, muted, track]); }, [volume, muted, track]);
@@ -87,10 +87,13 @@ export function useAudioTrackPlayer(
} else { } else {
// --- STANDARD MODE (0% - 100%) --- // --- STANDARD MODE (0% - 100%) ---
// Clean up boost pipeline if it exists // Clean up boost pipeline if it exists
if (boostGainRef.current) {
boostGainRef.current.disconnect();
boostGainRef.current = null;
}
if (boostSourceRef.current) { if (boostSourceRef.current) {
boostSourceRef.current.disconnect(); boostSourceRef.current.disconnect();
boostSourceRef.current = null; boostSourceRef.current = null;
boostGainRef.current = null;
} }
audioEl.muted = false; audioEl.muted = false;
@@ -103,10 +106,13 @@ export function useAudioTrackPlayer(
} }
return () => { return () => {
if (boostGainRef.current) {
boostGainRef.current.disconnect();
boostGainRef.current = null;
}
if (boostSourceRef.current) { if (boostSourceRef.current) {
boostSourceRef.current.disconnect(); boostSourceRef.current.disconnect();
boostSourceRef.current = null; boostSourceRef.current = null;
boostGainRef.current = null;
} }
}; };
}, [volume, muted, track]); }, [volume, muted, track]);
+46 -7
View File
@@ -124,7 +124,9 @@ export function useLiveKit() {
if (!track) if (!track)
return; return;
// Strict check: Track must be subscribed AND not muted to be considered "active" // Strict check: Track must be subscribed AND not muted to be considered "active"
if (pub.isMuted || !pub.isSubscribed) if (pub.isMuted)
return;
if (!isLocal && !pub.isSubscribed)
return; return;
const mt = track.mediaStreamTrack; const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live') if (!mt || mt.readyState !== 'live')
@@ -278,10 +280,30 @@ export function useLiveKit() {
} }
}); });
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
// GlobalAudioRenderer is the sole audio playback path with volume/attenuation/boost.
// Detach LiveKit's internal element to prevent double-playback.
if (track.kind === Track.Kind.Audio) {
track.detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().unwatchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
@@ -391,10 +413,27 @@ export function useLiveKit() {
updateParticipants(); }; updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Audio) {
track.detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().unwatchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
+46 -7
View File
@@ -6,6 +6,7 @@ import {
Participant, Participant,
RemoteParticipant, RemoteParticipant,
RemoteTrackPublication, RemoteTrackPublication,
RemoteAudioTrack,
ConnectionState, ConnectionState,
VideoPresets, VideoPresets,
VideoPreset, VideoPreset,
@@ -177,7 +178,8 @@ export function useLiveKit() {
const track = pub.track; const track = pub.track;
if (!track) return; if (!track) return;
// Strict check: Track must be subscribed AND not muted to be considered "active" // Strict check: Track must be subscribed AND not muted to be considered "active"
if (pub.isMuted || !pub.isSubscribed) return; if (pub.isMuted) return;
if (!isLocal && !pub.isSubscribed) return;
const mt = track.mediaStreamTrack; const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live') return; if (!mt || mt.readyState !== 'live') return;
@@ -343,10 +345,30 @@ export function useLiveKit() {
} }
}); });
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
// GlobalAudioRenderer is the sole audio playback path with volume/attenuation/boost.
// Detach LiveKit's internal element to prevent double-playback.
if (track.kind === Track.Kind.Audio) {
(track as RemoteAudioTrack).detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().unwatchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
@@ -451,10 +473,27 @@ export function useLiveKit() {
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Audio) {
(track as RemoteAudioTrack).detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.LocalTrackPublished, guardedUpdate); newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
newRoom.on(RoomEvent.LocalTrackUnpublished, guardedUpdate); if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().unwatchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);