/** * The app-wide voice session: exactly one LiveKit Room for the whole tab, * mirroring the realtime hub's "module singleton, not a React provider" * decision. It outlives every route, so switching to a repository or board * keeps the call up and the global call bar can drive it from anywhere. * * Design notes: * - This file owns *connection* only. Rendering and remote-audio playback live * in the vendored `@livekit/components-react` tree (see `host.tsx`), which is * fed the same Room through `RoomContext`. * - A refresh restores the room from sessionStorage, but always muted: the * browser will not autoplay audio without a gesture, and silently rejoining a * call with an open microphone is worse than one extra click. * - Track/participant changes are published as a single immutable snapshot so * `useSyncExternalStore` consumers re-render only on real state changes. * Per-participant state (speaking, mute, quality, volume) is deliberately NOT * mirrored here: it is read from the Room by the official hooks instead, so * there is exactly one source of truth for media state. * - Failures are values (notice + detail), never throws: the room view stays * usable when LiveKit is down, exactly like the chat socket's status banner. * - `livekit-client@2.22` cannot swap the token of a live connection, so an * expired credential is recovered by rejoining with a freshly issued one. */ import { t } from '@app/i18n' import { ConnectionError, ConnectionState, DeviceUnsupportedError, DisconnectReason, LivekitError, type LocalParticipant, MediaDeviceFailure, type RemoteParticipant, Room, RoomEvent, type ScreenShareCaptureOptions, Track, } from 'livekit-client' import { rememberedVolume } from './volumes' /** Persisted so a refresh can rejoin the same channel. */ const STORAGE_KEY = 'gitdata.voice.session' /** Persisted panel toggles: they outlive the call, like the call-bar position. */ const DEAFENED_KEY = 'gitdata.voice.deafened' const MIC_MUTED_KEY = 'gitdata.voice.micMuted' function readFlag(key: string): boolean { try { return localStorage.getItem(key) === '1' } catch { return false } } function writeFlag(key: string, value: boolean): void { try { if (value) localStorage.setItem(key, '1') else localStorage.removeItem(key) } catch { // A toggle that cannot persist still works for this session. } } /** * Backoff for the automatic rejoin. Every attempt pulls a *new* token, which is * what makes long calls survive the token TTL. */ const REJOIN_DELAYS_MS = [1_000, 3_000] export type VoiceStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed' export interface VoiceSessionSnapshot { status: VoiceStatus roomId: string | null /** Channel name and owning space, so the global bar can label and link back. */ roomLabel: string | null spaceName: string | null /** Set when a moderator removed this participant: the bar explains it. */ notice: string | null /** Raw SDK/browser message, shown behind the "connection details" disclosure. */ noticeDetail: string | null micEnabled: boolean cameraEnabled: boolean screenShareEnabled: boolean /** * Deafened: every remote track is silenced locally, and the microphone follows * Discord's rule of muting for as long as it lasts. */ deafened: boolean /** Muted-by-choice while not in a call: the state the next join starts from. */ micMuted: boolean /** False until the browser has been allowed to play remote audio. */ audioPlaybackAllowed: boolean /** Local speak indicator only; remote tiles read the Room directly. */ speaking: boolean /** True once a publish permission flip landed (Stage approval). */ canPublish: boolean isStage: boolean hasRaisedHand: boolean /** Host revoked our microphone during this call (distinct from Stage queue). */ mutedByHost: boolean /** An automatic rejoin is scheduled/in flight. */ rejoining: boolean /** Auto-rejoin attempts are exhausted: only a manual action can recover. */ needsManualRejoin: boolean } export interface StoredSession { roomId: string roomLabel?: string spaceName?: string } /** Credentials for one join. Supplied by the app layer to keep `shared` free of API imports. */ export interface VoiceCredentials { url: string token: string canPublish: boolean isStage: boolean } export type VoiceCredentialSource = (roomId: string) => Promise export interface JoinOptions extends VoiceCredentials { roomId: string /** Channel name for the global call bar. */ roomLabel?: string /** Owning space slug, so "回到语音频道" can rebuild the route. */ spaceName?: string /** Voice channels join with the microphone on; Stage audiences stay muted. */ startWithMic: boolean /** Chosen in the device sheet; omitted means "whatever the browser prefers". */ audioDeviceId?: string | null videoDeviceId?: string | null audioOutputDeviceId?: string | null } const INITIAL: VoiceSessionSnapshot = { status: 'idle', roomId: null, roomLabel: null, spaceName: null, notice: null, noticeDetail: null, micEnabled: false, cameraEnabled: false, screenShareEnabled: false, deafened: false, micMuted: false, audioPlaybackAllowed: true, speaking: false, canPublish: true, isStage: false, hasRaisedHand: false, mutedByHost: false, rejoining: false, needsManualRejoin: false, } type Listener = (snapshot: VoiceSessionSnapshot) => void class VoiceSession { private room: Room | null = null private snapshot: VoiceSessionSnapshot = { ...INITIAL, deafened: readFlag(DEAFENED_KEY), micMuted: readFlag(MIC_MUTED_KEY), } /** Microphone state to restore when the deafen toggle is switched back off. */ private micBeforeDeafen = false private readonly listeners = new Set() private readonly participantNames = new Map() /** Distinguishes a user-initiated teardown from an ICE/signal drop. */ private leaving = false private credentialSource: VoiceCredentialSource | null = null private rejoinTimer = 0 private rejoinAttempt = 0 /** Last join parameters, replayed verbatim on an automatic rejoin. */ private lastJoin: JoinOptions | null = null /** * The SDK disconnects with a client-initiated reason when the page is being * torn down, which looks exactly like a hang-up. A reload must still restore * the call, so the teardown is observed separately. */ private unloading = false getSnapshot(): VoiceSessionSnapshot { return this.snapshot } subscribe(listener: Listener): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } /** Display name for a participant identity (falls back to the raw identity). */ nameOf(identity: string): string { return this.participantNames.get(identity) ?? identity } /** Lets the app layer hand over a token fetcher without `shared` importing `entities`. */ setCredentialSource(source: VoiceCredentialSource): void { this.credentialSource = source if (typeof window === 'undefined' || (window as Window & { lkUnloadBound?: boolean }).lkUnloadBound) return ;(window as Window & { lkUnloadBound?: boolean }).lkUnloadBound = true const onHide = () => { this.unloading = true } const onShow = () => { this.unloading = false } window.addEventListener('pagehide', onHide) window.addEventListener('pageshow', onShow) } private patch(updates: Partial): void { const next = { ...this.snapshot, ...updates } let changed = false for (const key of Object.keys(updates) as (keyof VoiceSessionSnapshot)[]) { if (this.snapshot[key] !== next[key]) { changed = true break } } if (!changed) return this.snapshot = next for (const listener of [...this.listeners]) listener(next) } /** True when this tab is currently inside roomId. */ isIn(roomId: string): boolean { return this.snapshot.roomId === roomId && this.snapshot.status !== 'idle' } /** The room a page load should offer to restore, if any. */ static stored(): StoredSession | null { try { const raw = sessionStorage.getItem(STORAGE_KEY) if (!raw) return null const parsed = JSON.parse(raw) as StoredSession return parsed.roomId ? parsed : null } catch { return null } } private persist(roomId: string | null): void { try { if (!roomId) { sessionStorage.removeItem(STORAGE_KEY) return } const payload: StoredSession = { roomId, ...(this.snapshot.roomLabel ? { roomLabel: this.snapshot.roomLabel } : {}), ...(this.snapshot.spaceName ? { spaceName: this.snapshot.spaceName } : {}), } sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) } catch { // Private mode / disabled storage: the call still works, only the // refresh restore is lost. } } async join(options: JoinOptions): Promise { if (this.room && this.snapshot.roomId === options.roomId) return if (this.room) await this.leave() this.cancelRejoin() const room = new Room({ adaptiveStream: true, dynacast: true }) this.room = room this.leaving = false this.lastJoin = options this.patch({ status: this.rejoinAttempt > 0 ? 'reconnecting' : 'connecting', roomId: options.roomId, roomLabel: options.roomLabel ?? null, spaceName: options.spaceName ?? null, notice: null, noticeDetail: null, canPublish: options.canPublish, isStage: options.isStage, needsManualRejoin: false, micEnabled: false, cameraEnabled: false, screenShareEnabled: false, speaking: false, }) this.bind(room) try { await room.connect(options.url, options.token) this.patch({ status: 'connected' }) this.persist(options.roomId) if (options.audioOutputDeviceId) { await this.applyOutputDevice(room, options.audioOutputDeviceId) } // Deafened survives a reload, so a restored call comes back silent; the // microphone stays off until the toggle is switched back. if (this.snapshot.deafened) this.applyDeafened() if (options.canPublish && options.startWithMic && !this.snapshot.deafened) { await this.setMicrophone(true, options.audioDeviceId) } if (options.canPublish && options.videoDeviceId && options.startWithMic) { await this.setCamera(true, options.videoDeviceId) } this.rejoinAttempt = 0 } catch (error) { const described = describeVoiceFailure(error) this.releaseRoom(room) this.patch({ status: 'failed', roomId: null, micEnabled: false, cameraEnabled: false, screenShareEnabled: false, speaking: false, rejoining: false, notice: this.snapshot.notice ?? described.notice, noticeDetail: this.snapshot.noticeDetail ?? described.detail, }) throw error } } /** Switch the speaker/headset without a reconnect. */ private async applyOutputDevice(room: Room, deviceId: string): Promise { try { await room.switchActiveDevice('audiooutput', deviceId) } catch { // Unsupported browser (Safari): the default route keeps working. } } /** Route an output device change requested from the settings menu. */ async setAudioOutputDevice(deviceId: string): Promise { const room = this.room if (!room) return try { await room.switchActiveDevice('audiooutput', deviceId) if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioOutputDeviceId: deviceId } } catch (error) { const described = describeVoiceFailure(error) this.patch({ notice: described.notice, noticeDetail: described.detail }) } } private releaseRoom(room: Room): void { if (this.room === room) this.room = null } private bind(room: Room): void { let couldPublish = this.snapshot.canPublish const refreshSelf = () => { if (this.room !== room) return const local = room.localParticipant this.captureNames([...room.remoteParticipants.values()], local) const canPublish = local.permissions?.canPublish ?? couldPublish if (couldPublish && !canPublish) { // A mid-call flip is a moderation action, so say who did what. this.patch({ mutedByHost: !this.snapshot.isStage, notice: t('chat.voice.mutedByHost') }) } else if (!couldPublish && canPublish) { this.patch({ mutedByHost: false, notice: t('chat.voice.unmutedByHost') }) } couldPublish = canPublish this.patch({ micEnabled: local.isMicrophoneEnabled, cameraEnabled: local.isCameraEnabled, screenShareEnabled: local.isScreenShareEnabled, canPublish, speaking: local.isSpeaking, }) } room .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => { if (state === ConnectionState.Connected) this.patch({ status: 'connected' }) else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' }) }) .on(RoomEvent.Reconnected, () => { this.cancelRejoin() this.rejoinAttempt = 0 this.patch({ status: 'connected', notice: null, noticeDetail: null, rejoining: false }) }) .on(RoomEvent.MediaDevicesError, (error: Error) => { const described = describeVoiceFailure(error) this.patch({ notice: described.notice, noticeDetail: described.detail }) }) .on(RoomEvent.ParticipantConnected, (participant) => { // Somebody arriving while deafened must be silent too. Their microphone // track may not exist yet, and the participant-level volume is what the // SDK remembers until it does. if (this.snapshot.deafened) { participant.setVolume(0) participant.setVolume(0, Track.Source.ScreenShareAudio) } refreshSelf() }) .on(RoomEvent.ParticipantDisconnected, () => refreshSelf()) .on(RoomEvent.LocalTrackPublished, () => refreshSelf()) .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf()) .on(RoomEvent.TrackMuted, () => refreshSelf()) .on(RoomEvent.TrackUnmuted, () => refreshSelf()) .on(RoomEvent.ActiveSpeakersChanged, () => refreshSelf()) .on(RoomEvent.ParticipantPermissionsChanged, () => refreshSelf()) .on(RoomEvent.MediaDevicesChanged, () => refreshSelf()) .on(RoomEvent.AudioPlaybackStatusChanged, () => { this.patch({ audioPlaybackAllowed: room.canPlaybackAudio }) }) .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { // The SDK also disconnects on pagehide, and that must NOT clear the // stored session - the next page load is expected to restore the call. // Only reasons that make a return impossible drop the stored room. const kicked = reason === DisconnectReason.PARTICIPANT_REMOVED const leftForGood = kicked || reason === DisconnectReason.ROOM_DELETED || reason === DisconnectReason.DUPLICATE_IDENTITY const hungUp = this.leaving || reason === DisconnectReason.CLIENT_INITIATED const intentional = hungUp && !this.unloading this.releaseRoom(room) const retainedNotice = this.snapshot.notice const retainedDetail = this.snapshot.noticeDetail // An explicit hang-up must not come back on the next page load, and it // must not be auto-retried either - the official DisconnectButton // disconnects without going through leave(). if (leftForGood || intentional) { this.lastJoin = null this.persist(null) } this.patch({ status: intentional || leftForGood ? 'idle' : 'failed', roomId: leftForGood ? null : this.snapshot.roomId, roomLabel: leftForGood ? null : this.snapshot.roomLabel, spaceName: leftForGood ? null : this.snapshot.spaceName, micEnabled: false, cameraEnabled: false, screenShareEnabled: false, speaking: false, notice: kicked ? t('chat.voice.notice.kicked') : reason === DisconnectReason.DUPLICATE_IDENTITY ? t('chat.voice.notice.duplicate') : retainedNotice ? retainedNotice : intentional ? null : t('chat.voice.reconnecting'), noticeDetail: retainedDetail ?? (reason ? String(reason) : null), }) if (!intentional && !leftForGood) this.scheduleRejoin() }) refreshSelf() } private cancelRejoin(): void { if (this.rejoinTimer) { clearTimeout(this.rejoinTimer) this.rejoinTimer = 0 } this.patch({ rejoining: false }) } /** Rejoin with a freshly issued token; gives up after REJOIN_DELAYS_MS runs out. */ private scheduleRejoin(): void { const last = this.lastJoin if (!last || !this.credentialSource || this.rejoinTimer) return if (this.rejoinAttempt >= REJOIN_DELAYS_MS.length) { this.patch({ status: 'failed', rejoining: false, needsManualRejoin: true, notice: t('chat.voice.reconnectFailed'), }) return } const delay = REJOIN_DELAYS_MS[this.rejoinAttempt] this.rejoinAttempt += 1 this.patch({ rejoining: true, status: 'reconnecting' }) this.rejoinTimer = window.setTimeout(() => { this.rejoinTimer = 0 void this.rejoin() }, delay) } /** Automatic path (after the backoff) and manual "rejoin" button share this. */ async rejoin(): Promise { const last = this.lastJoin const source = this.credentialSource if (!last || !source) return false this.patch({ notice: t('chat.voice.reconnecting'), rejoining: true }) try { const credentials = await source(last.roomId) await this.join({ ...last, ...credentials }) this.cancelRejoin() this.patch({ rejoining: false, needsManualRejoin: false }) return true } catch (error) { const described = describeVoiceFailure(error) this.patch({ notice: this.snapshot.notice ?? described.notice, noticeDetail: this.snapshot.noticeDetail ?? described.detail, }) this.scheduleRejoin() return false } } private captureNames(remotes: RemoteParticipant[], local: LocalParticipant | null): void { for (const participant of remotes) { if (participant.name) this.participantNames.set(participant.identity, participant.name) } if (local?.name) this.participantNames.set(local.identity, local.name) } async leave(): Promise { const room = this.room this.leaving = true this.cancelRejoin() this.rejoinAttempt = 0 this.lastJoin = null this.room = null this.persist(null) this.patch({ status: 'idle', roomId: null, roomLabel: null, spaceName: null, micEnabled: false, cameraEnabled: false, screenShareEnabled: false, speaking: false, mutedByHost: false, needsManualRejoin: false, rejoining: false, notice: null, noticeDetail: null, }) if (!room) return try { await room.disconnect() } catch { // Already gone; nothing useful to report. } } async setMicrophone(enabled: boolean, deviceId?: string | null): Promise { const room = this.room if (!room) return try { await room.localParticipant.setMicrophoneEnabled( enabled, deviceId ? { deviceId: { exact: deviceId } } : undefined, ) this.patch({ micEnabled: enabled }) if (this.lastJoin) this.lastJoin = { ...this.lastJoin, audioDeviceId: deviceId ?? null } } catch (error) { const described = describeVoiceFailure(error) this.patch({ notice: described.notice, noticeDetail: described.detail }) } } async setCamera(enabled: boolean, deviceId?: string | null): Promise { const room = this.room if (!room) return try { await room.localParticipant.setCameraEnabled(enabled, deviceId ? { deviceId: { exact: deviceId } } : undefined) this.patch({ cameraEnabled: enabled }) if (this.lastJoin) this.lastJoin = { ...this.lastJoin, videoDeviceId: deviceId ?? null } } catch (error) { const described = describeVoiceFailure(error) this.patch({ notice: described.notice, noticeDetail: described.detail }) } } /** * `audio` decides whether the tab/window/system sound rides along; cancelling * the browser picker is not a failure and is reported as such. */ async setScreenShare(enabled: boolean, options?: ScreenShareCaptureOptions): Promise { const room = this.room if (!room) return false try { await room.localParticipant.setScreenShareEnabled(enabled, { audio: true, ...options }) this.patch({ screenShareEnabled: enabled }) return true } catch (error) { if (isUserCancellation(error)) { this.patch({ notice: t('chat.voice.shareCancelled'), noticeDetail: null }) } else { const described = describeVoiceFailure(error) this.patch({ notice: described.notice, noticeDetail: described.detail }) } return false } } /** * Headphones toggle. Silences every remote track - microphone and screen-share * audio alike, including tracks that subscribe later - and mutes the microphone * for as long as it lasts, which is what Discord's deafen does. Outside a call * it only records the preference the next join applies. */ async setDeafened(enabled: boolean): Promise { if (this.snapshot.deafened === enabled) return const wasMicOn = this.snapshot.micEnabled if (enabled) this.micBeforeDeafen = wasMicOn this.patch({ deafened: enabled }) writeFlag(DEAFENED_KEY, enabled) if (!this.room) return this.applyDeafened() if (enabled) { if (wasMicOn && this.snapshot.canPublish) await this.setMicrophone(false) } else if (this.micBeforeDeafen && this.snapshot.canPublish) { await this.setMicrophone(true) } } /** The microphone toggle outside a call: remembered until the next join. */ setMicMuted(muted: boolean): void { this.patch({ micMuted: muted }) writeFlag(MIC_MUTED_KEY, muted) } /** Push the deafen flag onto every remote track currently in the room. */ private applyDeafened(): void { const room = this.room if (!room) return const roomId = this.snapshot.roomId for (const participant of room.remoteParticipants.values()) { participant.setVolume(this.snapshot.deafened ? 0 : rememberedVolume(roomId, participant.identity)) participant.setVolume(this.snapshot.deafened ? 0 : 1, Track.Source.ScreenShareAudio) } } /** Browsers block remote audio until a gesture; the call bar calls this. */ async resumeAudio(): Promise { const room = this.room if (!room) return try { await room.startAudio() this.patch({ audioPlaybackAllowed: room.canPlaybackAudio }) } catch { this.patch({ notice: t('chat.voice.notice.audioBlocked') }) } } markHandRaised(raised: boolean): void { this.patch({ hasRaisedHand: raised }) } clearNotice(): void { this.patch({ notice: null, noticeDetail: null }) } /** LiveKit Room handle for the voice host (RoomContext + RoomAudioRenderer). */ getRoom(): Room | null { return this.room } } /** The user dismissed the picker; nothing went wrong. */ function isUserCancellation(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } interface VoiceFailure { notice: string detail: string | null } /** * Classify by the SDK's error types and the browser's `DOMException.name` * instead of matching English message substrings, which break whenever the SDK * rewords a message. */ function describeVoiceFailure(error: unknown): VoiceFailure { const detail = error instanceof Error ? error.message : String(error ?? '') if (error instanceof Error && error.name === 'NotAllowedError') { return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null } } const deviceFailure = error instanceof Error ? MediaDeviceFailure.getFailure(error) : undefined if (deviceFailure === MediaDeviceFailure.PermissionDenied) { return { notice: t('chat.voice.notice.permissionDenied'), detail: detail || null } } if (deviceFailure === MediaDeviceFailure.NotFound || (error instanceof Error && error.name === 'NotFoundError')) { return { notice: t('chat.voice.notice.deviceMissing'), detail: detail || null } } if (deviceFailure === MediaDeviceFailure.DeviceInUse) { return { notice: t('chat.voice.notice.deviceInUse'), detail: detail || null } } if (error instanceof DeviceUnsupportedError) { return { notice: t('chat.voice.notice.browserUnsupported'), detail: detail || null } } if (/token|unauthori[sz]ed|forbidden/i.test(detail)) { return { notice: t('chat.voice.notice.tokenInvalid'), detail: detail || null } } if (error instanceof ConnectionError || error instanceof LivekitError) { return { notice: t('chat.voice.notice.mediaUnreachable'), detail: detail || null } } return { notice: t('chat.voice.notice.connectFailed'), detail: detail || null } } export const voiceSession = new VoiceSession() /** The persisted call for this tab, if any - the refresh-restore entry point. */ export function storedVoiceSession(): StoredSession | null { return VoiceSession.stored() }