fix: resync voice state after LiveKit reconnect (#289)

Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
This commit is contained in:
2026-06-02 16:00:11 +00:00
committed by hodlbod
parent 91145c38fb
commit ee3da3893c
2 changed files with 107 additions and 18 deletions
+6 -3
View File
@@ -17,6 +17,11 @@ export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string} export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState { export enum VoiceState {
Joining = "joining", Joining = "joining",
Connected = "connected", Connected = "connected",
@@ -41,9 +46,7 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([]) export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable( export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
new Map<string, {muted: boolean; cameraOn: boolean}>(),
)
export const mediaStateByIdentity = derived( export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted], [participantMediaState, currentVoiceSession, voiceMicMuted],
+101 -15
View File
@@ -30,6 +30,7 @@ import {
participantMediaState, participantMediaState,
speakingParticipants, speakingParticipants,
VoiceState, VoiceState,
type ParticipantMediaState,
type VoiceParticipant, type VoiceParticipant,
voiceState, voiceState,
} from "@app/call/stores" } from "@app/call/stores"
@@ -77,12 +78,17 @@ export const switchVoiceActiveDevice = async (
} }
} }
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => { const deleteParticipant = (identity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m]))) participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
} }
const syncParticipantMedia = (participant: Participant) => { const syncParticipantMedia = (participant: Participant) => {
const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled} const state = participantMediaFrom(participant)
participantMediaState.update(m => { participantMediaState.update(m => {
const prev = m.get(participant.identity) const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
@@ -92,6 +98,29 @@ const syncParticipantMedia = (participant: Participant) => {
}) })
} }
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentVoiceSession)
if (!session) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => { const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant) syncParticipantMedia(participant)
} }
@@ -191,6 +220,55 @@ const setUpMicrophone = async (
// (after switching calls or an engine reconnect give-up) must not clobber it. // (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(currentVoiceRoom)
if (!target) return
try {
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimeout !== undefined) return
if (!get(currentVoiceRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
return
}
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => { const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session. // Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return if (room !== activeRoom) return
@@ -204,11 +282,14 @@ const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason
resetVideoCallLayout() resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected) voiceState.set(VoiceState.Disconnected)
const message = if (reason === DisconnectReason.JOIN_FAILURE) {
reason === DisconnectReason.JOIN_FAILURE pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
? "Could not connect to voice room. Please try again." } else if (get(currentVoiceRoom)) {
: "Voice connection lost." clearReconnectSchedule()
pushToast({theme: "error", message}) scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
} }
speakingParticipants.set([]) speakingParticipants.set([])
participantMediaState.set(new Map()) participantMediaState.set(new Map())
@@ -263,17 +344,22 @@ const onLocalTrackUnpublished = (
let joinAbortController: AbortController | undefined let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => { const abortJoinVoiceRoom = () => {
joinAbortController?.abort() joinAbortController?.abort()
} }
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
url: string, url: string,
h: string, h: string,
startMuted = true, startMuted = true,
preferredMicId?: string, preferredMicId?: string,
): Promise<void> => { ): Promise<void> => {
cancelJoinVoiceRoom() abortJoinVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining) voiceState.set(VoiceState.Joining)
@@ -320,6 +406,7 @@ export const joinVoiceRoom = async (
activeRoom = liveKitRoom activeRoom = liveKitRoom
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom)) liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
@@ -382,10 +469,14 @@ export const joinVoiceRoom = async (
screenShareOn: false, screenShareOn: false,
}) })
voiceState.set(VoiceState.Connected) voiceState.set(VoiceState.Connected)
clearReconnectSchedule()
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected) if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) return if (e instanceof AbortError) {
clearReconnectSchedule()
return
}
throw e throw e
} finally { } finally {
settle.abort() settle.abort()
@@ -394,6 +485,7 @@ export const joinVoiceRoom = async (
} }
export const leaveVoiceRoom = async () => { export const leaveVoiceRoom = async () => {
clearReconnectSchedule()
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
@@ -435,12 +527,6 @@ export const leaveVoiceRoom = async () => {
} }
} }
export const rejoinVoiceRoom = async (): Promise<void> => {
const target = get(currentVoiceRoom)
if (!target) return
return joinVoiceRoom(target.url, target.h)
}
export const toggleMute = async () => { export const toggleMute = async () => {
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return