main
/**
* 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<VoiceCredentials>
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<Listener>()
private readonly participantNames = new Map<string, string>()
/** 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<VoiceSessionSnapshot>): 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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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()
}