fix: eliminate voice ducking caused by rogue LiveKit audio elements
Add MutationObserver to neutralize LiveKit's re-attached <audio> elements, mark our keep-alive elements with data-opencord, detach tracks on unsubscribe, remove dangerous blanket .play(), and soften compressor to transparent limiter.
This commit is contained in:
@@ -1,101 +1,88 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
/**
|
||||
* Shared hybrid audio pipeline hook.
|
||||
* Hybrid audio pipeline for a single remote audio track.
|
||||
*
|
||||
* Manages an <audio> element ref with two modes:
|
||||
* - Standard mode (volume <= 1.0): uses native HTMLAudioElement volume
|
||||
* - Boost mode (volume > 1.0): routes through Web Audio GainNode for amplification
|
||||
* Architecture:
|
||||
* 1. A MUTED <audio> element keeps Chrome's WebRTC audio pipeline alive
|
||||
* for the track. Chrome requires an HTML media element consuming a
|
||||
* WebRTC MediaStreamTrack or it stops processing it. The element is
|
||||
* always muted (volume=0, muted=true) — it never produces audible output.
|
||||
*
|
||||
* The caller must render: <audio ref={audioRef} autoPlay playsInline />
|
||||
* 2. A Web Audio pipeline handles ALL actual audio output:
|
||||
* MediaStreamTrack -> MediaStream -> MediaStreamAudioSourceNode -> GainNode -> ctx.destination
|
||||
*
|
||||
* This gives us:
|
||||
* - Chrome compatibility (muted <audio> keep-alive)
|
||||
* - No ducking (all elements are muted, only Web Audio produces sound)
|
||||
* - Clean mixing (single ctx.destination for all tracks)
|
||||
* - Full volume range (0.0 – 4.0+) via GainNode
|
||||
* - Smooth transitions via setTargetAtTime (no clicks/pops)
|
||||
*/
|
||||
export function useAudioTrackPlayer(opts) {
|
||||
const audioRef = useRef(null);
|
||||
const boostGainRef = useRef(null);
|
||||
const boostSourceRef = useRef(null);
|
||||
const { track, volume, muted } = opts;
|
||||
// Effect 1: Track attachment
|
||||
const audioRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const gainRef = useRef(null);
|
||||
// Keep current volume/muted in refs so Effect 1 can read them
|
||||
// for the initial ramp without depending on them
|
||||
const volumeRef = useRef(volume);
|
||||
const mutedRef = useRef(muted);
|
||||
volumeRef.current = volume;
|
||||
mutedRef.current = muted;
|
||||
// Effect 1: Track attachment (keep-alive) + Web Audio pipeline build
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !track) {
|
||||
// Tear down previous Web Audio graph
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
if (gainRef.current) {
|
||||
gainRef.current.disconnect();
|
||||
gainRef.current = null;
|
||||
}
|
||||
if (!track) {
|
||||
if (audioEl)
|
||||
audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
// --- Keep-alive: attach track to <audio> element (always muted) ---
|
||||
// Chrome needs an HTML element consuming the WebRTC track or it
|
||||
// stops the audio pipeline for that track entirely.
|
||||
if (audioEl) {
|
||||
audioEl.srcObject = new MediaStream([track]);
|
||||
audioEl.muted = true;
|
||||
audioEl.volume = 0;
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
// --- Web Audio pipeline for actual output ---
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const stream = new MediaStream([track]);
|
||||
// Only update if the stream actually changed to prevent interruptions
|
||||
if (audioEl.srcObject?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
await audioEl.play();
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[Audio] Autoplay blocked, retrying...', err);
|
||||
}
|
||||
};
|
||||
tryPlay();
|
||||
}
|
||||
}, [track]);
|
||||
// Effect 2: Volume management (hybrid native/Web Audio)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !track)
|
||||
return;
|
||||
if (muted) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = volume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
if (isBoosting && isContextReady) {
|
||||
// --- BOOST MODE (>100%) ---
|
||||
// Setup pipeline if missing
|
||||
if (!boostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([track]));
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
boostGainRef.current = gain;
|
||||
boostSourceRef.current = source;
|
||||
}
|
||||
// Apply boosted gain
|
||||
if (boostGainRef.current && ctx) {
|
||||
boostGainRef.current.gain.setTargetAtTime(volume, ctx.currentTime, 0.01);
|
||||
}
|
||||
// MUTE the element so we don't double audio
|
||||
audioEl.muted = true;
|
||||
}
|
||||
else {
|
||||
// --- STANDARD MODE (0% - 100%) ---
|
||||
// Clean up boost pipeline if it exists
|
||||
if (boostGainRef.current) {
|
||||
boostGainRef.current.disconnect();
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
}
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(volume, 1.0);
|
||||
// Ensure it's playing (in case it was paused/blocked earlier)
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gain = ctx.createGain();
|
||||
// Start gain at 0 to prevent pop, then ramp to target
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime);
|
||||
const targetGain = mutedRef.current ? 0 : volumeRef.current;
|
||||
gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
source.connect(gain);
|
||||
gain.connect(AudioManager.getInstance().getMasterOutput());
|
||||
sourceRef.current = source;
|
||||
gainRef.current = gain;
|
||||
return () => {
|
||||
if (boostGainRef.current) {
|
||||
boostGainRef.current.disconnect();
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
}
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
sourceRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
}, [volume, muted, track]);
|
||||
}, [track]);
|
||||
// Effect 2: Update gain when volume or muted changes (no graph rebuild)
|
||||
useEffect(() => {
|
||||
if (!gainRef.current)
|
||||
return;
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const targetGain = muted ? 0 : volume;
|
||||
gainRef.current.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
}, [volume, muted]);
|
||||
return audioRef;
|
||||
}
|
||||
|
||||
@@ -3,119 +3,108 @@ import { AudioManager } from '../audio/AudioManager';
|
||||
|
||||
interface UseAudioTrackPlayerOptions {
|
||||
track: MediaStreamTrack | null;
|
||||
volume: number; // final computed value, 0.0 – 2.0+
|
||||
volume: number; // final computed value, 0.0 – 4.0+
|
||||
muted: boolean; // deafen, stream mute, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hybrid audio pipeline hook.
|
||||
* Hybrid audio pipeline for a single remote audio track.
|
||||
*
|
||||
* Manages an <audio> element ref with two modes:
|
||||
* - Standard mode (volume <= 1.0): uses native HTMLAudioElement volume
|
||||
* - Boost mode (volume > 1.0): routes through Web Audio GainNode for amplification
|
||||
* Architecture:
|
||||
* 1. A MUTED <audio> element keeps Chrome's WebRTC audio pipeline alive
|
||||
* for the track. Chrome requires an HTML media element consuming a
|
||||
* WebRTC MediaStreamTrack or it stops processing it. The element is
|
||||
* always muted (volume=0, muted=true) — it never produces audible output.
|
||||
*
|
||||
* The caller must render: <audio ref={audioRef} autoPlay playsInline />
|
||||
* 2. A Web Audio pipeline handles ALL actual audio output:
|
||||
* MediaStreamTrack -> MediaStream -> MediaStreamAudioSourceNode -> GainNode -> ctx.destination
|
||||
*
|
||||
* This gives us:
|
||||
* - Chrome compatibility (muted <audio> keep-alive)
|
||||
* - No ducking (all elements are muted, only Web Audio produces sound)
|
||||
* - Clean mixing (single ctx.destination for all tracks)
|
||||
* - Full volume range (0.0 – 4.0+) via GainNode
|
||||
* - Smooth transitions via setTargetAtTime (no clicks/pops)
|
||||
*/
|
||||
export function useAudioTrackPlayer(
|
||||
opts: UseAudioTrackPlayerOptions,
|
||||
): React.RefObject<HTMLAudioElement> {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const boostGainRef = useRef<GainNode | null>(null);
|
||||
const boostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
const { track, volume, muted } = opts;
|
||||
|
||||
// Effect 1: Track attachment
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const gainRef = useRef<GainNode | null>(null);
|
||||
|
||||
// Keep current volume/muted in refs so Effect 1 can read them
|
||||
// for the initial ramp without depending on them
|
||||
const volumeRef = useRef(volume);
|
||||
const mutedRef = useRef(muted);
|
||||
volumeRef.current = volume;
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Effect 1: Track attachment (keep-alive) + Web Audio pipeline build
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !track) {
|
||||
|
||||
// Tear down previous Web Audio graph
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
if (gainRef.current) {
|
||||
gainRef.current.disconnect();
|
||||
gainRef.current = null;
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
if (audioEl) audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Keep-alive: attach track to <audio> element (always muted) ---
|
||||
// Chrome needs an HTML element consuming the WebRTC track or it
|
||||
// stops the audio pipeline for that track entirely.
|
||||
if (audioEl) {
|
||||
audioEl.srcObject = new MediaStream([track]);
|
||||
audioEl.muted = true;
|
||||
audioEl.volume = 0;
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
|
||||
// --- Web Audio pipeline for actual output ---
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
|
||||
const stream = new MediaStream([track]);
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gain = ctx.createGain();
|
||||
|
||||
// Only update if the stream actually changed to prevent interruptions
|
||||
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
// Start gain at 0 to prevent pop, then ramp to target
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime);
|
||||
const targetGain = mutedRef.current ? 0 : volumeRef.current;
|
||||
gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
await audioEl.play();
|
||||
} catch (err) {
|
||||
console.warn('[Audio] Autoplay blocked, retrying...', err);
|
||||
}
|
||||
};
|
||||
tryPlay();
|
||||
}
|
||||
}, [track]);
|
||||
source.connect(gain);
|
||||
gain.connect(AudioManager.getInstance().getMasterOutput());
|
||||
|
||||
// Effect 2: Volume management (hybrid native/Web Audio)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !track) return;
|
||||
|
||||
if (muted) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = volume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
|
||||
if (isBoosting && isContextReady) {
|
||||
// --- BOOST MODE (>100%) ---
|
||||
// Setup pipeline if missing
|
||||
if (!boostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([track]));
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
boostGainRef.current = gain;
|
||||
boostSourceRef.current = source;
|
||||
}
|
||||
|
||||
// Apply boosted gain
|
||||
if (boostGainRef.current && ctx) {
|
||||
boostGainRef.current.gain.setTargetAtTime(volume, ctx.currentTime, 0.01);
|
||||
}
|
||||
|
||||
// MUTE the element so we don't double audio
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
// --- STANDARD MODE (0% - 100%) ---
|
||||
// Clean up boost pipeline if it exists
|
||||
if (boostGainRef.current) {
|
||||
boostGainRef.current.disconnect();
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
}
|
||||
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(volume, 1.0);
|
||||
|
||||
// Ensure it's playing (in case it was paused/blocked earlier)
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
sourceRef.current = source;
|
||||
gainRef.current = gain;
|
||||
|
||||
return () => {
|
||||
if (boostGainRef.current) {
|
||||
boostGainRef.current.disconnect();
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
}
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
sourceRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
}, [volume, muted, track]);
|
||||
}, [track]);
|
||||
|
||||
// Effect 2: Update gain when volume or muted changes (no graph rebuild)
|
||||
useEffect(() => {
|
||||
if (!gainRef.current) return;
|
||||
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const targetGain = muted ? 0 : volume;
|
||||
gainRef.current.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
}, [volume, muted]);
|
||||
|
||||
return audioRef;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,8 @@ export function useLiveKit() {
|
||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
|
||||
return;
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 1. Reset state immediately to reflect "Loading/Switching" in UI
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
@@ -289,7 +291,12 @@ export function useLiveKit() {
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
@@ -309,9 +316,9 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
if (publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
@@ -363,6 +370,15 @@ export function useLiveKit() {
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Initial mute state check
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
@@ -382,6 +398,8 @@ export function useLiveKit() {
|
||||
}, [updateParticipants, handleDataReceived]);
|
||||
const connectDm = useCallback(async (dmChannelId) => {
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 1. Reset state immediately
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
@@ -419,7 +437,12 @@ export function useLiveKit() {
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
@@ -438,9 +461,9 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
if (publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
@@ -480,6 +503,15 @@ export function useLiveKit() {
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
if (wasDeafened) {
|
||||
|
||||
@@ -299,7 +299,10 @@ export function useLiveKit() {
|
||||
const connect = useCallback(async (channelId: string) => {
|
||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return;
|
||||
const gen = ++_connectGeneration;
|
||||
|
||||
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
|
||||
// 1. Reset state immediately to reflect "Loading/Switching" in UI
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
@@ -354,7 +357,12 @@ export function useLiveKit() {
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
(track as RemoteAudioTrack).detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
@@ -374,9 +382,11 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
if (
|
||||
publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio
|
||||
) {
|
||||
(publication as RemoteTrackPublication).setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
@@ -425,7 +435,19 @@ export function useLiveKit() {
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
|
||||
updateParticipants();
|
||||
|
||||
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if (
|
||||
(pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed
|
||||
) {
|
||||
(pub as RemoteTrackPublication).setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initial mute state check
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
@@ -441,7 +463,10 @@ export function useLiveKit() {
|
||||
|
||||
const connectDm = useCallback(async (dmChannelId: string) => {
|
||||
const gen = ++_connectGeneration;
|
||||
|
||||
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
|
||||
// 1. Reset state immediately
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
@@ -479,7 +504,12 @@ export function useLiveKit() {
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
(track as RemoteAudioTrack).detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
@@ -498,9 +528,11 @@ export function useLiveKit() {
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
if (
|
||||
publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio
|
||||
) {
|
||||
(publication as RemoteTrackPublication).setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
@@ -533,13 +565,26 @@ export function useLiveKit() {
|
||||
await newRoom.connect(url, token);
|
||||
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
|
||||
const fullId = `dm-${dmChannelId}`;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = fullId;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = fullId;
|
||||
setConnectedChannelId(fullId);
|
||||
setRoom(newRoom);
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if (
|
||||
(pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed
|
||||
) {
|
||||
(pub as RemoteTrackPublication).setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user