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 was merged in pull request #289.
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user