From 786823946a2ae396529dc9ee8fefa4af94d7a7f3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:25:26 +0200 Subject: [PATCH] feat(camera): handle camera-track end with branched toast (unplug vs permission revoke) LocalTrackPublished registers a one-shot onended on the camera track's MediaStreamTrack. The handler: - skips when consumeIntentionalCameraOff() flag is set (user-initiated) - re-probes getUserMedia to distinguish NotAllowedError (permission revoked) from NotFoundError (disconnected) from other errors - tears down camera state via the unified path Also reset _intentionalCameraOff in voiceActions if setCameraEnabled(false) rejects, so a failed disable doesn't poison the next genuine unplug. --- packages/web/src/hooks/useLiveKit.ts | 54 ++++++++++++++++++++++++-- packages/web/src/utils/voiceActions.ts | 9 ++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 1e0ce310..3e83d30f 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/authStore'; import { useUIStore } from '../stores/uiStore'; import type { User } from '@backspace/shared'; import { broadcastVoiceStatus } from '../utils/voice'; -import { markIntentionalCameraOff } from '../utils/voiceActions'; +import { consumeIntentionalCameraOff, markIntentionalCameraOff } from '../utils/voiceActions'; import { AudioManager } from '../audio/AudioManager'; import { SpeakingDetector } from '../audio/SpeakingDetector'; import { @@ -430,9 +430,12 @@ export function useLiveKit() { } 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). + // Track is dead — disable the camera entirely. Mark the flag now to + // suppress the already-queued `ended` event on the dead track. + markIntentionalCameraOff(); + // Re-mark immediately before the disable: LiveKit may synthesize a + // second `ended` event during teardown, and the flag is consumed once. + // markIntentionalCameraOff is idempotent. markIntentionalCameraOff(); await r.localParticipant.setCameraEnabled(false).catch(() => {}); useVoiceStore.setState({ isCameraOn: false }); @@ -554,6 +557,49 @@ export function useLiveKit() { const { userId } = parseIdentity(newRoom.localParticipant.identity); useVoiceStore.getState().watchStream(userId); } + if (publication.source === Track.Source.Camera) { + const mst = publication.track?.mediaStreamTrack; + if (mst) { + // Replace any prior listener — on a re-publish the underlying track is new. + mst.onended = async () => { + // User-initiated camera-off → skip the probe + toast entirely. + if (consumeIntentionalCameraOff()) return; + // Room is tearing down or has been replaced → skip. + if (roomRef.current !== newRoom) return; + + // Re-probe getUserMedia to distinguish hardware unplug from + // OS-level permission revoke. Keep the probe short and tolerant. + const deviceId = useVoiceStore.getState().cameraDeviceId; + let copy = 'Camera unavailable'; + try { + const probe = await navigator.mediaDevices.getUserMedia({ + video: deviceId ? { deviceId: { exact: deviceId } } : true, + }); + probe.getTracks().forEach(t => t.stop()); + // Probe succeeded — reason is unknown; keep generic copy. + } catch (err: any) { + if (err?.name === 'NotAllowedError') copy = 'Camera permission was revoked'; + else if (err?.name === 'NotFoundError') copy = 'Camera disconnected'; + // Any other error → keep generic copy. + } + + // The probe is async and may have taken time (especially if the + // browser surfaced a permission prompt). If the user disconnected + // while the probe was in flight, `roomRef.current` is no longer + // `newRoom` — silently no-op so we don't mutate state or surface + // a toast for a session the user has already left. + if (roomRef.current !== newRoom) return; + + // Tear down camera state via the unified path. Mark intentional so + // the disable's own track-end doesn't recurse into this handler. + markIntentionalCameraOff(); + await roomRef.current.localParticipant.setCameraEnabled(false).catch(() => {}); + useVoiceStore.setState({ isCameraOn: false }); + broadcastVoiceStatus(); + useUIStore.getState().addToast(copy, 'warning'); + }; + } + } guardedUpdate(); }); newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => { diff --git a/packages/web/src/utils/voiceActions.ts b/packages/web/src/utils/voiceActions.ts index 1f6074da..1f52bbb7 100644 --- a/packages/web/src/utils/voiceActions.ts +++ b/packages/web/src/utils/voiceActions.ts @@ -80,7 +80,14 @@ export async function handleCameraAction(): Promise { // Mark this disable as intentional so the track-`ended` handler skips // its unplug/permission-revoke probe + toast. markIntentionalCameraOff(); - await room.localParticipant.setCameraEnabled(false); + try { + await room.localParticipant.setCameraEnabled(false); + } catch (err) { + // Disable rejected — consume the flag so it doesn't poison the + // next genuine unplug. Re-throw to the outer catch for logging. + consumeIntentionalCameraOff(); + throw err; + } } useVoiceStore.getState().toggleCamera(); broadcastVoiceStatus();