/** * The wiring seam between the tab-wide voice session (`@/shared/voice`) and the * chat API (`@/entities/chat`). * * `shared/voice` must stay free of entities imports, so the credential minting * lives here in the feature that owns both sides. Everything the session needs * to survive - a refresh restore, a rejoin with a *fresh* token after a signal * drop, the pre-join capability gate - funnels through this module. */ import { getVoiceConfig, requestVoiceToken } from '@app/domains/chat/model' import { storedVoiceSession, type VoiceCredentials, voiceSession } from '@app/services/voice' let installed = false /** Mint a LiveKit access token + endpoint for one voice channel. */ export async function voiceCredentials(roomId: string): Promise { const token = await requestVoiceToken(roomId) return { url: token.url, token: token.token, canPublish: token.can_publish, isStage: token.is_stage, } } /** * Restores an in-progress call after a page refresh. * * The connection cannot survive a reload, so the room id lives in * sessionStorage and the tab re-joins silently - **muted**, because a browser * blocks remote audio until a gesture anyway and rejoining with an open * microphone would be worse than one extra click. The call bar then offers to * restore sound. */ async function restoreStoredSession(): Promise { const stored = storedVoiceSession() if (!stored) return try { const credentials = await voiceCredentials(stored.roomId) await voiceSession.join({ roomId: stored.roomId, roomLabel: stored.roomLabel, spaceName: stored.spaceName, ...credentials, startWithMic: false, }) } catch { // A stale room (deleted channel, revoked access) simply does not restore; // leaving clears the stored id so the next load stays quiet. await voiceSession.leave() } } /** * Install the session's credential source and kick off the refresh restore. * Called once from the app layer; idempotent, so a double render (StrictMode) * cannot tear down and rebuild the connection the first pass just made. */ export function installVoiceSession(): void { if (installed) return installed = true voiceSession.setCredentialSource(voiceCredentials) void restoreStoredSession() } export interface JoinVoiceRoomOptions { roomId: string roomLabel?: string spaceName?: string /** Voice channels open the mic; Stage audiences join in listen-only mode. */ startWithMic: boolean audioDeviceId?: string | null videoDeviceId?: string | null audioOutputDeviceId?: string | null } /** Capability gate + credential minting + connect, as one awaitable action. */ export async function joinVoiceRoom(options: JoinVoiceRoomOptions): Promise { const config = await getVoiceConfig() if (!config.enabled) throw new Error('livekit is not enabled') const credentials = await voiceCredentials(options.roomId) await voiceSession.join({ roomId: options.roomId, roomLabel: options.roomLabel, spaceName: options.spaceName, ...credentials, startWithMic: options.startWithMic, audioDeviceId: options.audioDeviceId, videoDeviceId: options.videoDeviceId, audioOutputDeviceId: options.audioOutputDeviceId, }) }