feat(camera): syncCamera effect hot-swaps publication on cameraDeviceId change

Uses room.switchActiveDevice('videoinput', id) to swap the underlying
MediaStreamTrack without re-publishing. Compares against the published
track's actual getSettings().deviceId so null→explicit-same-device is a
no-op. Rolls back on failure with a 'Could not switch camera' toast.
This commit is contained in:
Jannis Braun
2026-04-27 20:23:00 +02:00
parent 2e41edbc99
commit e8534339fb
+59
View File
@@ -17,8 +17,10 @@ import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore
import { wsSend } from './useWebSocket'; import { wsSend } from './useWebSocket';
import { useVoiceStore } from '../stores/voiceStore'; import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { useUIStore } from '../stores/uiStore';
import type { User } from '@backspace/shared'; import type { User } from '@backspace/shared';
import { broadcastVoiceStatus } from '../utils/voice'; import { broadcastVoiceStatus } from '../utils/voice';
import { markIntentionalCameraOff } from '../utils/voiceActions';
import { AudioManager } from '../audio/AudioManager'; import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector'; import { SpeakingDetector } from '../audio/SpeakingDetector';
import { import {
@@ -149,6 +151,7 @@ export function useLiveKit() {
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const roomRef = useRef<Room | null>(null); const roomRef = useRef<Room | null>(null);
const connectedChannelRef = useRef<string | null>(null); const connectedChannelRef = useRef<string | null>(null);
const switchCameraGenRef = useRef(0);
const isMuted = useVoiceStore((s) => s.isMuted); const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
@@ -162,6 +165,7 @@ export function useLiveKit() {
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const inputVolume = useVoiceStore((s) => s.inputVolume); const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
const echoCancellation = useVoiceStore((s) => s.echoCancellation); const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression); const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl); const autoGainControl = useVoiceStore((s) => s.autoGainControl);
@@ -384,6 +388,61 @@ export function useLiveKit() {
}; };
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); }, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
// Hot-swap the camera source when cameraDeviceId changes mid-call.
// Compares against the published track's actual deviceId (getSettings().deviceId)
// rather than a memoised previous store value, so the null → explicit-same-device
// transition is a correct no-op.
useEffect(() => {
const r = roomRef.current;
if (!r || !isConnected || !isCameraOn) return;
const camPub = r.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.Camera);
if (!camPub?.track) return;
const currentDeviceId = camPub.track.mediaStreamTrack?.getSettings().deviceId;
const target = cameraDeviceId;
if (target === null) return; // "Auto" never force-switches a live publication
if (currentDeviceId === target) return; // already on target
const myGen = ++switchCameraGenRef.current;
// Race semantics: if the effect re-fires while this IIFE is in flight
// (rapid dropdown changes), the newer firing will increment the gen.
// This IIFE's catch then no-ops its store rollback — the newer attempt
// owns the canonical store state.
(async () => {
const prev = currentDeviceId ?? null;
try {
await r.switchActiveDevice('videoinput', target);
} catch (err) {
console.error('[LiveKit] Camera hot-swap failed:', err);
// A newer device-switch attempt has superseded ours. Don't write stale
// rollback state; let the newer attempt's outcome stand.
if (myGen !== switchCameraGenRef.current) {
useUIStore.getState().addToast('Could not switch camera', 'warning');
return;
}
const stillLive = camPub.track?.mediaStreamTrack?.readyState === 'live';
if (stillLive && prev) {
useVoiceStore.getState().setCameraDeviceId(prev);
} else if (stillLive) {
useVoiceStore.getState().setCameraDeviceId(null);
} else {
// Track is dead — disable the camera entirely. Mark the disable
// intentional so the track-`ended` handler does not also fire its
// unplug/permission-revoke toast (which would be redundant here).
markIntentionalCameraOff();
await r.localParticipant.setCameraEnabled(false).catch(() => {});
useVoiceStore.setState({ isCameraOn: false });
broadcastVoiceStatus();
}
useUIStore.getState().addToast('Could not switch camera', 'warning');
}
})();
}, [cameraDeviceId, isCameraOn, isConnected]);
const connect = useCallback(async (channelId: string, isDm?: boolean) => { const connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId; const storedId = isDm ? `dm-${channelId}` : channelId;
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return; if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;