main
/**
* 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<VoiceCredentials> {
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<void> {
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<void> {
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,
})
}