2026-03-16 20:38:05 +00:00
|
|
|
/**
|
|
|
|
|
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
|
|
|
|
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
|
|
|
|
*/
|
2026-03-27 19:02:56 +00:00
|
|
|
import {
|
|
|
|
|
DisconnectReason,
|
2026-04-08 17:10:20 +00:00
|
|
|
LocalParticipant,
|
|
|
|
|
LocalTrackPublication,
|
2026-05-21 20:58:53 +00:00
|
|
|
Participant,
|
2026-03-27 19:02:56 +00:00
|
|
|
Room as LiveKitRoom,
|
|
|
|
|
RoomEvent,
|
|
|
|
|
Track,
|
2026-05-21 20:58:53 +00:00
|
|
|
TrackPublication,
|
2026-04-02 19:33:48 +00:00
|
|
|
supportsAudioOutputSelection,
|
2026-03-27 19:02:56 +00:00
|
|
|
type AudioCaptureOptions,
|
|
|
|
|
} from "livekit-client"
|
2026-08-10 18:32:11 +00:00
|
|
|
import {App} from "@capacitor/app"
|
2026-06-26 22:56:49 +00:00
|
|
|
import {derived, get, writable} from "svelte/store"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
|
|
|
|
|
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
|
|
|
|
|
import {deriveDeduplicated} from "@welshman/store"
|
|
|
|
|
import {makeRoomKey} from "@welshman/app"
|
|
|
|
|
import type {Room} from "@welshman/app"
|
2026-03-16 20:38:05 +00:00
|
|
|
import {getLivekitEndpoint} from "$lib/livekit"
|
2026-05-28 19:17:17 +00:00
|
|
|
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {network, user} from "@app/core"
|
|
|
|
|
import {deriveEventsForUrl} from "@app/repository"
|
2026-06-08 21:14:40 +00:00
|
|
|
import {pushToast} from "@app/toast"
|
2026-03-16 20:38:05 +00:00
|
|
|
|
|
|
|
|
export const LIVEKIT_PARTICIPANTS = 39004
|
|
|
|
|
|
2026-04-02 19:33:48 +00:00
|
|
|
export {supportsAudioOutputSelection}
|
|
|
|
|
|
2026-08-14 17:43:19 +00:00
|
|
|
/**
|
|
|
|
|
* Aspect ratio constraints for tiles. The lower bound is dynamic
|
|
|
|
|
* (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9.
|
|
|
|
|
*/
|
|
|
|
|
const TILE_ASPECT_PORTRAIT = 3 / 4
|
|
|
|
|
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
|
|
|
|
const TILE_GAP = 8
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Minimum pixel height for a tile before we allow the grid to
|
|
|
|
|
* overflow (scroll) instead of forcing tiles into portrait mode.
|
|
|
|
|
* Calibrated so ~6-8 tiles on a standard portrait phone fit
|
|
|
|
|
* without scrolling; beyond that, scroll is acceptable.
|
|
|
|
|
*/
|
|
|
|
|
const MIN_TILE_HEIGHT = 120
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A single row in an adaptive tile grid.
|
|
|
|
|
*/
|
|
|
|
|
export type TileRow = {
|
|
|
|
|
columnCount: number
|
|
|
|
|
tileWidth: number
|
|
|
|
|
tileHeight: number
|
|
|
|
|
/** Total width of this row including gaps — for the template's max-width */
|
|
|
|
|
rowWidth: number
|
|
|
|
|
aspectRatio: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Adaptive tile grid: all tiles share the same dimensions. Full rows
|
|
|
|
|
* fill the container width; partial rows are centered.
|
|
|
|
|
*
|
|
|
|
|
* Example (3 tiles, 2 columns):
|
|
|
|
|
* row 0: [ square ] [ square ]
|
|
|
|
|
* row 1: [ square ] (centered, same size)
|
|
|
|
|
*/
|
|
|
|
|
export type AdaptiveTileGrid = {
|
|
|
|
|
rows: TileRow[]
|
|
|
|
|
totalWidth: number
|
|
|
|
|
totalHeight: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Score for comparing candidate layouts. Lower is better.
|
|
|
|
|
* Uses named fields instead of opaque array "keys" (per review feedback).
|
|
|
|
|
*/
|
|
|
|
|
type LayoutScore = {
|
|
|
|
|
/** Penalty for vertical overflow: 0 if none, else huge */
|
|
|
|
|
verticalOverflowPenalty: number
|
|
|
|
|
/** Penalty for horizontal overflow: 0 if none, else huge */
|
|
|
|
|
horizontalOverflowPenalty: number
|
|
|
|
|
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
|
|
|
|
|
minTileHeightPenalty: number
|
|
|
|
|
/** Negative tile area (we want to maximize area, so negate) */
|
|
|
|
|
negativeArea: number
|
|
|
|
|
/** Leftover whitespace (underflow) in px */
|
|
|
|
|
whitespace: number
|
|
|
|
|
/** Average absolute deviation from 16:9 across all tiles */
|
|
|
|
|
aspectDeviation: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
|
|
|
|
|
const fields: (keyof LayoutScore)[] = [
|
|
|
|
|
"verticalOverflowPenalty",
|
|
|
|
|
"horizontalOverflowPenalty",
|
|
|
|
|
"minTileHeightPenalty",
|
|
|
|
|
"negativeArea",
|
|
|
|
|
"whitespace",
|
|
|
|
|
"aspectDeviation",
|
|
|
|
|
]
|
|
|
|
|
for (const field of fields) {
|
|
|
|
|
if (a[field] !== b[field]) return a[field] - b[field]
|
|
|
|
|
}
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute the largest tile size that fits within a given width and height,
|
|
|
|
|
* bounded by [minAspect, TILE_ASPECT_LANDSCAPE]. The tile is shrunk to fit
|
|
|
|
|
* whichever dimension is more constraining, so it never overflows.
|
|
|
|
|
*/
|
|
|
|
|
const fitTile = (availWidth: number, availHeight: number, minAspect: number) => {
|
|
|
|
|
const fillAspect = availWidth / availHeight
|
|
|
|
|
const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
|
|
|
|
|
if (fillAspect >= aspectRatio) {
|
|
|
|
|
const tileWidth = availHeight * aspectRatio
|
|
|
|
|
return {tileWidth, tileHeight: availHeight, aspectRatio}
|
|
|
|
|
}
|
|
|
|
|
const tileHeight = availWidth / aspectRatio
|
|
|
|
|
return {tileWidth: availWidth, tileHeight, aspectRatio}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a candidate grid for a given column count. All tiles share the
|
|
|
|
|
* same dimensions; the partial last row (if any) is centered with the
|
|
|
|
|
* same tile size as full rows.
|
|
|
|
|
*/
|
|
|
|
|
const buildCandidate = (
|
|
|
|
|
tileCount: number,
|
|
|
|
|
columnCount: number,
|
|
|
|
|
containerWidth: number,
|
|
|
|
|
containerHeight: number,
|
|
|
|
|
minAspect: number,
|
|
|
|
|
): AdaptiveTileGrid | undefined => {
|
|
|
|
|
const gap = TILE_GAP
|
|
|
|
|
const fullRowCount = Math.floor(tileCount / columnCount)
|
|
|
|
|
const remainder = tileCount % columnCount
|
|
|
|
|
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
|
|
|
|
|
|
|
|
|
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
|
|
|
|
|
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
|
|
|
|
if (availWidth <= 0 || availHeight <= 0) return undefined
|
|
|
|
|
|
|
|
|
|
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
|
|
|
|
|
|
|
|
|
const rows: TileRow[] = []
|
|
|
|
|
for (let r = 0; r < fullRowCount; r++) {
|
|
|
|
|
rows.push({
|
|
|
|
|
columnCount,
|
|
|
|
|
tileWidth,
|
|
|
|
|
tileHeight,
|
|
|
|
|
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (remainder > 0) {
|
|
|
|
|
rows.push({
|
|
|
|
|
columnCount: remainder,
|
|
|
|
|
tileWidth,
|
|
|
|
|
tileHeight,
|
|
|
|
|
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
|
|
|
|
|
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
|
|
|
|
|
|
|
|
|
return {rows, totalWidth, totalHeight}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute an adaptive tile grid. All tiles share the same dimensions;
|
|
|
|
|
* partial rows are centered. Tiles flex between a minimum aspect
|
|
|
|
|
* (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never
|
|
|
|
|
* overflow the container.
|
|
|
|
|
*
|
|
|
|
|
* Only allows overflow (scroll) when tiles would be below
|
|
|
|
|
* MIN_TILE_HEIGHT (~6-8 tiles on portrait phone).
|
|
|
|
|
*
|
|
|
|
|
* Prioritises:
|
|
|
|
|
* 1. No overflow (tiles shrink to fit within aspect bounds)
|
|
|
|
|
* 2. Minimal whitespace
|
|
|
|
|
* 3. Aspect ratios close to 16:9
|
|
|
|
|
* 4. Larger tiles
|
|
|
|
|
*/
|
|
|
|
|
export const computeAdaptiveGrid = (
|
|
|
|
|
tileCount: number,
|
|
|
|
|
containerWidth: number,
|
|
|
|
|
containerHeight: number,
|
|
|
|
|
): AdaptiveTileGrid | undefined => {
|
|
|
|
|
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined
|
|
|
|
|
|
|
|
|
|
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
|
|
|
|
|
|
|
|
|
|
let best: AdaptiveTileGrid | undefined
|
|
|
|
|
let bestScore: LayoutScore | undefined
|
|
|
|
|
|
|
|
|
|
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
|
|
|
|
const candidate = buildCandidate(
|
|
|
|
|
tileCount,
|
|
|
|
|
columnCount,
|
|
|
|
|
containerWidth,
|
|
|
|
|
containerHeight,
|
|
|
|
|
minAspect,
|
|
|
|
|
)
|
|
|
|
|
if (!candidate) continue
|
|
|
|
|
|
|
|
|
|
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
|
|
|
|
|
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
|
|
|
|
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
|
|
|
|
const whitespace =
|
|
|
|
|
Math.max(0, containerWidth - candidate.totalWidth) +
|
|
|
|
|
Math.max(0, containerHeight - candidate.totalHeight)
|
|
|
|
|
const totalArea = tileWidth * tileHeight * tileCount
|
|
|
|
|
|
|
|
|
|
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
|
|
|
|
|
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
|
|
|
|
|
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
|
|
|
|
|
const minTileHeightPenalty =
|
|
|
|
|
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
|
|
|
|
|
|
|
|
|
|
const score: LayoutScore = {
|
|
|
|
|
verticalOverflowPenalty,
|
|
|
|
|
horizontalOverflowPenalty,
|
|
|
|
|
minTileHeightPenalty,
|
|
|
|
|
negativeArea: -totalArea,
|
|
|
|
|
whitespace,
|
|
|
|
|
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!bestScore || compareScores(score, bestScore) < 0) {
|
|
|
|
|
bestScore = score
|
|
|
|
|
best = candidate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:33:48 +00:00
|
|
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
2026-06-26 22:56:49 +00:00
|
|
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
|
|
|
|
|
|
|
|
|
export type CallSession = {
|
|
|
|
|
url: string
|
|
|
|
|
h: string
|
|
|
|
|
livekit: LiveKitRoom
|
|
|
|
|
cameraOn: boolean
|
|
|
|
|
screenShareOn: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
|
|
|
|
export const callMicMuted = writable(true)
|
|
|
|
|
|
|
|
|
|
export type CallParticipant = {pubkey?: string; liveKitIdentity: string}
|
|
|
|
|
|
|
|
|
|
export type ParticipantMediaState = {
|
|
|
|
|
muted: boolean
|
|
|
|
|
cameraOn: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export enum CallState {
|
|
|
|
|
Joining = "joining",
|
|
|
|
|
Connected = "connected",
|
|
|
|
|
Disconnected = "disconnected",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export enum VideoCallLayout {
|
|
|
|
|
Chat = "chat",
|
|
|
|
|
Video = "video",
|
|
|
|
|
Split = "split",
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:33:48 +00:00
|
|
|
export enum DeviceKind {
|
|
|
|
|
AudioInput = "audioinput",
|
|
|
|
|
AudioOutput = "audiooutput",
|
2026-04-08 17:10:20 +00:00
|
|
|
VideoInput = "videoinput",
|
2026-04-02 19:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
export const currentCallSession = writable<CallSession | undefined>(undefined)
|
|
|
|
|
|
|
|
|
|
export const callState = writable<CallState>(CallState.Disconnected)
|
|
|
|
|
|
|
|
|
|
export const callTargetRoom = writable<Room | undefined>(undefined)
|
|
|
|
|
|
2026-08-27 23:06:10 +00:00
|
|
|
export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) =>
|
|
|
|
|
derived(
|
|
|
|
|
[callState, callTargetRoom],
|
|
|
|
|
([$state, $targetRoom]) =>
|
|
|
|
|
($state === CallState.Joining || $state === CallState.Connected) &&
|
|
|
|
|
$targetRoom !== undefined &&
|
|
|
|
|
!($targetRoom.url === url && $targetRoom.h === h),
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
export const speakingParticipants = writable<CallParticipant[]>([])
|
|
|
|
|
|
|
|
|
|
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
|
|
|
|
|
|
|
|
|
export const mediaStateByIdentity = derived(
|
|
|
|
|
[participantMediaState, currentCallSession, callMicMuted],
|
|
|
|
|
([$media, $session, $micMuted]) =>
|
|
|
|
|
(liveKitIdentity: string) => {
|
|
|
|
|
if ($session?.livekit.localParticipant.identity === liveKitIdentity) {
|
|
|
|
|
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
|
|
|
|
}
|
|
|
|
|
return $media.get(liveKitIdentity) ?? {muted: true, cameraOn: false}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const isParticipantSpeaking = derived(
|
|
|
|
|
speakingParticipants,
|
|
|
|
|
$participants => (p: CallParticipant) =>
|
|
|
|
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const videoTrackRevision = writable(0)
|
|
|
|
|
|
|
|
|
|
export const triggerVideoTrackRevision = () => {
|
|
|
|
|
videoTrackRevision.update(n => n + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
|
|
|
|
|
|
|
|
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
|
|
|
|
|
|
|
|
|
export const joinVoiceRoom = async (
|
|
|
|
|
url: string,
|
|
|
|
|
h: string,
|
|
|
|
|
startMuted = true,
|
|
|
|
|
preferredMicId?: string,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
abortJoinVoiceRoom()
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
callTargetRoom.set({url, h, id: makeRoomKey(url, h)})
|
2026-06-26 22:56:49 +00:00
|
|
|
callState.set(CallState.Joining)
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
joinAbortController = controller
|
|
|
|
|
const signal = controller.signal
|
|
|
|
|
const isActive = () => joinAbortController === controller
|
|
|
|
|
|
|
|
|
|
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
|
|
|
|
|
// helpers clear their timers/listeners once the races below have settled.
|
|
|
|
|
const settle = new AbortController()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Tear down any existing session before joining. Bound it so a slow leave
|
|
|
|
|
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
|
|
|
|
|
if (get(currentCallSession)) {
|
|
|
|
|
await Promise.race([
|
|
|
|
|
leaveVoiceRoom(),
|
|
|
|
|
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
|
|
|
|
whenAborted(signal),
|
|
|
|
|
]).catch(e => {
|
|
|
|
|
if (e instanceof AbortError) throw e
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// leaveVoiceRoom flips callState to Disconnected; re-assert Joining.
|
|
|
|
|
callState.set(CallState.Joining)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (signal.aborted) throw new AbortError()
|
|
|
|
|
|
|
|
|
|
const {server_url, participant_token} = await Promise.race([
|
|
|
|
|
fetchLivekitToken(url, h, signal),
|
|
|
|
|
whenTimeout(15_000, {
|
|
|
|
|
message: "Connection timed out. Please check your network and try again.",
|
|
|
|
|
signal: settle.signal,
|
|
|
|
|
}),
|
|
|
|
|
whenAborted(signal),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (signal.aborted) throw new AbortError()
|
|
|
|
|
|
|
|
|
|
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
|
|
|
|
activeRoom = liveKitRoom
|
|
|
|
|
|
|
|
|
|
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
|
|
|
|
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
|
|
|
|
|
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
|
|
|
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
|
|
|
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
|
|
|
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
|
|
|
|
|
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
|
|
|
|
|
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await Promise.race([
|
|
|
|
|
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
|
|
|
|
whenTimeout(15_000, {
|
|
|
|
|
message: "Connection timed out. Please check your network and try again.",
|
|
|
|
|
signal: settle.signal,
|
|
|
|
|
}),
|
|
|
|
|
whenAborted(signal),
|
|
|
|
|
])
|
|
|
|
|
} catch (e) {
|
2026-07-27 22:01:48 +00:00
|
|
|
teardownRoom(liveKitRoom)
|
2026-06-26 22:56:49 +00:00
|
|
|
throw e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
participantMediaState.set(new Map())
|
|
|
|
|
syncParticipantMedia(liveKitRoom.localParticipant)
|
|
|
|
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
|
|
|
|
syncParticipantMedia(p)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission
|
|
|
|
|
// prompt resolves to muted rather than hanging the join forever.
|
|
|
|
|
const muted = await setUpMicrophone(
|
|
|
|
|
startMuted,
|
|
|
|
|
preferredMicId,
|
|
|
|
|
liveKitRoom.localParticipant,
|
|
|
|
|
signal,
|
|
|
|
|
settle.signal,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// A cancel during the mic step must tear down the connected room rather
|
|
|
|
|
// than leaking it.
|
|
|
|
|
if (signal.aborted) {
|
2026-07-27 22:01:48 +00:00
|
|
|
teardownRoom(liveKitRoom)
|
2026-06-26 22:56:49 +00:00
|
|
|
throw new AbortError()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
callMicMuted.set(muted)
|
|
|
|
|
currentCallSession.set({
|
|
|
|
|
url,
|
|
|
|
|
h,
|
|
|
|
|
livekit: liveKitRoom,
|
|
|
|
|
cameraOn: false,
|
|
|
|
|
screenShareOn: false,
|
|
|
|
|
})
|
|
|
|
|
callState.set(CallState.Connected)
|
|
|
|
|
clearReconnectSchedule()
|
|
|
|
|
playJoinSound()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (isActive()) callState.set(CallState.Disconnected)
|
|
|
|
|
if (e instanceof AbortError) {
|
|
|
|
|
clearReconnectSchedule()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
throw e
|
|
|
|
|
} finally {
|
|
|
|
|
settle.abort()
|
|
|
|
|
if (isActive()) joinAbortController = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const leaveVoiceRoom = async () => {
|
|
|
|
|
clearReconnectSchedule()
|
|
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session) return
|
|
|
|
|
|
|
|
|
|
const audio = new Audio("/leave-voice-room.mp3")
|
|
|
|
|
audio.play().catch(() => {})
|
|
|
|
|
|
|
|
|
|
if (session.cameraOn) {
|
|
|
|
|
try {
|
|
|
|
|
await session.livekit.localParticipant.setCameraEnabled(false)
|
|
|
|
|
} catch {
|
|
|
|
|
pushToast({theme: "error", message: "Error turning off camera."})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session.screenShareOn) {
|
|
|
|
|
try {
|
|
|
|
|
await session.livekit.localParticipant.setScreenShareEnabled(false)
|
|
|
|
|
} catch {
|
|
|
|
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always tear down this room's connection and listeners.
|
2026-07-27 22:01:48 +00:00
|
|
|
teardownRoom(session.livekit)
|
2026-06-26 22:56:49 +00:00
|
|
|
|
|
|
|
|
// Only reset shared UI state if this session is still current. A slow leave
|
|
|
|
|
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
|
|
|
|
// must not clobber the freshly-joined session when it finally completes.
|
2026-08-14 15:46:25 +00:00
|
|
|
//
|
|
|
|
|
// Compare the LiveKit room rather than the session object: turning off the
|
|
|
|
|
// screen share above emits LocalTrackUnpublished, whose handler replaces the
|
|
|
|
|
// store value with a new object for the same call. An identity check would
|
|
|
|
|
// fail there and leave the UI stuck in a connected state.
|
|
|
|
|
if (get(currentCallSession)?.livekit === session.livekit) {
|
2026-06-26 22:56:49 +00:00
|
|
|
callState.set(CallState.Disconnected)
|
|
|
|
|
callMicMuted.set(true)
|
|
|
|
|
currentCallSession.set(undefined)
|
|
|
|
|
speakingParticipants.set([])
|
|
|
|
|
participantMediaState.set(new Map())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const cancelJoinVoiceRoom = () => {
|
|
|
|
|
clearReconnectSchedule()
|
|
|
|
|
abortJoinVoiceRoom()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const toggleMute = async () => {
|
|
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session) return
|
|
|
|
|
|
|
|
|
|
callMicMuted.update(not)
|
|
|
|
|
|
2026-07-27 22:01:48 +00:00
|
|
|
const muted = get(callMicMuted)
|
2026-06-26 22:56:49 +00:00
|
|
|
try {
|
2026-07-27 22:01:48 +00:00
|
|
|
await session.livekit.localParticipant.setMicrophoneEnabled(!muted)
|
|
|
|
|
} catch {
|
|
|
|
|
callMicMuted.set(!muted)
|
|
|
|
|
pushToast({
|
|
|
|
|
theme: "error",
|
|
|
|
|
message: muted ? "Could not mute microphone" : "Could not access microphone",
|
|
|
|
|
})
|
2026-06-26 22:56:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const toggleCamera = async () => {
|
|
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session) return
|
|
|
|
|
|
|
|
|
|
const cameraOn = !session.cameraOn
|
|
|
|
|
try {
|
|
|
|
|
await session.livekit.localParticipant.setCameraEnabled(cameraOn)
|
2026-07-27 22:01:48 +00:00
|
|
|
currentCallSession.update(s => s && {...s, cameraOn})
|
2026-06-26 22:56:49 +00:00
|
|
|
} catch {
|
|
|
|
|
pushToast({
|
|
|
|
|
theme: "error",
|
|
|
|
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const toggleScreenShare = async () => {
|
|
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session) return
|
|
|
|
|
|
|
|
|
|
const screenShareOn = !session.screenShareOn
|
|
|
|
|
try {
|
|
|
|
|
await session.livekit.localParticipant.setScreenShareEnabled(screenShareOn)
|
2026-07-27 22:01:48 +00:00
|
|
|
currentCallSession.update(s => s && {...s, screenShareOn})
|
2026-06-26 22:56:49 +00:00
|
|
|
} catch {
|
|
|
|
|
pushToast({
|
|
|
|
|
theme: "error",
|
|
|
|
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const switchCallActiveDevice = async (
|
2026-04-02 19:33:48 +00:00
|
|
|
kind: DeviceKind,
|
|
|
|
|
targetDeviceId: string,
|
|
|
|
|
): Promise<void> => {
|
2026-06-26 22:56:49 +00:00
|
|
|
const session = get(currentCallSession)
|
2026-04-02 19:33:48 +00:00
|
|
|
if (!session) return
|
|
|
|
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
|
|
|
|
try {
|
2026-06-26 22:56:49 +00:00
|
|
|
await session.livekit.switchActiveDevice(kind, id)
|
2026-04-02 19:33:48 +00:00
|
|
|
} catch {
|
|
|
|
|
let label: string
|
|
|
|
|
switch (kind) {
|
|
|
|
|
case DeviceKind.AudioInput:
|
|
|
|
|
label = "microphone"
|
|
|
|
|
break
|
|
|
|
|
case DeviceKind.AudioOutput:
|
|
|
|
|
label = "speaker"
|
|
|
|
|
break
|
2026-04-08 17:10:20 +00:00
|
|
|
case DeviceKind.VideoInput:
|
|
|
|
|
label = "camera"
|
|
|
|
|
break
|
2026-04-02 19:33:48 +00:00
|
|
|
}
|
|
|
|
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:32:11 +00:00
|
|
|
/**
|
|
|
|
|
* On mobile, locking the screen can suspend microphone capture without ever
|
|
|
|
|
* ending the underlying MediaStreamTrack: the local participant still looks
|
|
|
|
|
* connected and unmuted, but publishes silence until the track is manually
|
|
|
|
|
* reacquired. LiveKit only guards against this for tracks attached to a DOM
|
|
|
|
|
* element (i.e. video), so the mic needs the same treatment on foreground
|
|
|
|
|
* return. `App.addListener("appStateChange", ...)` fires from Capacitor's web
|
|
|
|
|
* fallback too, so this covers both native and browser tabs.
|
|
|
|
|
*/
|
|
|
|
|
export const syncCallAudioResume = () => {
|
|
|
|
|
const listener = App.addListener("appStateChange", ({isActive}) => {
|
|
|
|
|
if (isActive) void reacquireMicrophoneIfNeeded()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
listener.then(l => l.remove())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
export const resetVideoCallLayout = () => {
|
|
|
|
|
videoCallLayout.set(VideoCallLayout.Chat)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const toggleVideoPrimaryTile = (key: string) => {
|
|
|
|
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const loadCallParticipants = (url: string, h: string) =>
|
2026-07-28 16:16:26 +00:00
|
|
|
network.get().load({
|
2026-06-26 22:56:49 +00:00
|
|
|
relays: [url],
|
|
|
|
|
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
export const deriveCallParticipants = (url: string, h: string) =>
|
|
|
|
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
|
|
|
|
derived(
|
|
|
|
|
[
|
|
|
|
|
participantMediaState,
|
|
|
|
|
callTargetRoom,
|
2026-07-28 16:16:26 +00:00
|
|
|
deriveDeduplicated(
|
|
|
|
|
deriveEventsForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
|
|
|
|
events => first(sortEventsDesc(events)),
|
|
|
|
|
),
|
2026-06-26 22:56:49 +00:00
|
|
|
],
|
|
|
|
|
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
|
2026-07-28 16:16:26 +00:00
|
|
|
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomKey(url, h)
|
|
|
|
|
|
|
|
|
|
const identities = inCall
|
|
|
|
|
? [...$participantMediaState.keys()]
|
|
|
|
|
: tagValues(tagSpec("participant"), $publishedParticipantList?.tags ?? [])
|
|
|
|
|
|
|
|
|
|
return uniqBy(
|
|
|
|
|
(p: CallParticipant) => participantKey(p),
|
|
|
|
|
identities.map(participantFromLiveKitIdentity),
|
|
|
|
|
)
|
2026-06-26 22:56:49 +00:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const pubkeyFromLiveKitIdentity = (liveKitIdentity: string): string | undefined =>
|
|
|
|
|
/^[a-f0-9]{64}$/.test(liveKitIdentity.slice(0, 64)) ? liveKitIdentity.slice(0, 64) : undefined
|
|
|
|
|
|
|
|
|
|
export const participantFromLiveKitIdentity = (liveKitIdentity: string): CallParticipant => {
|
|
|
|
|
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
|
|
|
|
return pk ? {pubkey: pk, liveKitIdentity} : {liveKitIdentity}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdentity
|
|
|
|
|
|
|
|
|
|
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
|
|
|
|
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
|
|
|
|
let activeRoom: LiveKitRoom | undefined
|
|
|
|
|
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
let reconnectAttempt = 0
|
2026-08-17 17:19:13 +00:00
|
|
|
// Captured from the dropped session so a full rejoin (as opposed to LiveKit's
|
|
|
|
|
// own internal reconnect, which reuses the existing tracks) restores the
|
|
|
|
|
// user's mic state instead of always rejoining muted.
|
|
|
|
|
let reconnectMicMuted = true
|
|
|
|
|
let reconnectMicDeviceId: string | undefined
|
2026-06-26 22:56:49 +00:00
|
|
|
let joinAbortController: AbortController | undefined
|
|
|
|
|
let hadCallSession = false
|
|
|
|
|
|
|
|
|
|
currentCallSession.subscribe(session => {
|
|
|
|
|
if (session) {
|
|
|
|
|
hadCallSession = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!hadCallSession) return
|
|
|
|
|
hadCallSession = false
|
|
|
|
|
videoPrimaryTileKey.set(undefined)
|
|
|
|
|
resetVideoCallLayout()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-27 22:01:48 +00:00
|
|
|
const teardownRoom = (livekit: LiveKitRoom) => {
|
|
|
|
|
if (activeRoom === livekit) activeRoom = undefined
|
|
|
|
|
|
|
|
|
|
// Dropping the listeners keeps TrackUnsubscribed from removing the hidden
|
|
|
|
|
// audio elements onTrackSubscribed appended, so detach them here instead.
|
|
|
|
|
for (const participant of livekit.remoteParticipants.values()) {
|
|
|
|
|
for (const publication of participant.audioTrackPublications.values()) {
|
|
|
|
|
publication.track?.detach().forEach(el => el.remove())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
livekit.removeAllListeners()
|
|
|
|
|
livekit.disconnect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:00:11 +00:00
|
|
|
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
|
|
|
|
muted: !participant.isMicrophoneEnabled,
|
|
|
|
|
cameraOn: participant.isCameraEnabled,
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const deleteParticipant = (liveKitIdentity: string) => {
|
|
|
|
|
participantMediaState.update(m => new Map(reject(nthEq(0, liveKitIdentity), [...m])))
|
2026-03-16 20:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 20:58:53 +00:00
|
|
|
const syncParticipantMedia = (participant: Participant) => {
|
2026-06-02 16:00:11 +00:00
|
|
|
const state = participantMediaFrom(participant)
|
2026-05-21 20:58:53 +00:00
|
|
|
participantMediaState.update(m => {
|
|
|
|
|
const prev = m.get(participant.identity)
|
|
|
|
|
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
2026-03-16 20:38:05 +00:00
|
|
|
const next = new Map(m)
|
2026-05-21 20:58:53 +00:00
|
|
|
next.set(participant.identity, state)
|
2026-03-16 20:38:05 +00:00
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:00:11 +00:00
|
|
|
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
2026-06-26 22:56:49 +00:00
|
|
|
const resyncAfterReconnect = (livekit: LiveKitRoom) => {
|
|
|
|
|
if (livekit !== activeRoom) return
|
2026-06-02 16:00:11 +00:00
|
|
|
|
|
|
|
|
const next = new Map<string, ParticipantMediaState>()
|
2026-06-26 22:56:49 +00:00
|
|
|
for (const p of [livekit.localParticipant, ...livekit.remoteParticipants.values()]) {
|
2026-06-02 16:00:11 +00:00
|
|
|
next.set(p.identity, participantMediaFrom(p))
|
|
|
|
|
}
|
|
|
|
|
participantMediaState.set(next)
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const session = get(currentCallSession)
|
2026-06-02 16:00:11 +00:00
|
|
|
if (!session) return
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const {localParticipant} = livekit
|
|
|
|
|
callMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
|
|
|
|
currentCallSession.set({
|
2026-06-02 16:00:11 +00:00
|
|
|
...session,
|
|
|
|
|
cameraOn: localParticipant.isCameraEnabled,
|
|
|
|
|
screenShareOn: localParticipant.isScreenShareEnabled,
|
|
|
|
|
})
|
2026-06-26 22:56:49 +00:00
|
|
|
triggerVideoTrackRevision()
|
2026-05-21 20:58:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 20:38:05 +00:00
|
|
|
const fetchLivekitToken = async (
|
|
|
|
|
url: string,
|
2026-07-28 16:16:26 +00:00
|
|
|
roomId: string,
|
2026-03-16 20:38:05 +00:00
|
|
|
signal?: AbortSignal,
|
|
|
|
|
): Promise<{server_url: string; participant_token: string}> => {
|
2026-07-28 16:16:26 +00:00
|
|
|
const endpoint = getLivekitEndpoint(url, roomId)
|
2026-03-16 20:38:05 +00:00
|
|
|
|
|
|
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
|
|
|
|
|
|
|
|
const template = await makeHttpAuth(endpoint, "GET")
|
2026-07-28 16:16:26 +00:00
|
|
|
const signedEvent = await user.get().signer.sign(template)
|
2026-03-16 20:38:05 +00:00
|
|
|
const authHeader = makeHttpAuthHeader(signedEvent)
|
|
|
|
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
|
headers: {Authorization: authHeader},
|
|
|
|
|
signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
throw new Error(`Token request failed (${response.status}): ${text}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.json()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:02:56 +00:00
|
|
|
const setUpMicrophone = async (
|
|
|
|
|
startMuted: boolean,
|
|
|
|
|
preferredMicId: string | undefined,
|
|
|
|
|
participant: LocalParticipant,
|
2026-05-28 19:17:17 +00:00
|
|
|
signal?: AbortSignal,
|
|
|
|
|
settleSignal?: AbortSignal,
|
2026-03-27 19:02:56 +00:00
|
|
|
): Promise<boolean> => {
|
|
|
|
|
if (startMuted) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let muted = true
|
|
|
|
|
let capture: AudioCaptureOptions | undefined = undefined
|
|
|
|
|
if (preferredMicId) {
|
|
|
|
|
capture = {deviceId: preferredMicId}
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-05-28 19:17:17 +00:00
|
|
|
await Promise.race([
|
|
|
|
|
participant.setMicrophoneEnabled(true, capture),
|
|
|
|
|
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
|
|
|
|
whenAborted(signal),
|
|
|
|
|
])
|
2026-03-27 19:02:56 +00:00
|
|
|
muted = false
|
|
|
|
|
} catch (e) {
|
2026-05-28 19:17:17 +00:00
|
|
|
// Timeout or microphone rejection: join muted, the call is still usable. A
|
|
|
|
|
// genuine abort is surfaced to the caller so it can tear down the room.
|
|
|
|
|
if (e instanceof AbortError) throw e
|
|
|
|
|
if (!(e instanceof TimeoutError)) {
|
|
|
|
|
pushToast({theme: "error", message: "Could not access microphone"})
|
|
|
|
|
}
|
2026-03-27 19:02:56 +00:00
|
|
|
}
|
|
|
|
|
return muted
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:32:11 +00:00
|
|
|
const reacquireMicrophoneIfNeeded = async () => {
|
|
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session || get(callMicMuted)) return
|
|
|
|
|
|
|
|
|
|
const track = session.livekit.localParticipant.getTrackPublication(
|
|
|
|
|
Track.Source.Microphone,
|
|
|
|
|
)?.audioTrack
|
|
|
|
|
if (!track || track.isMuted || track.isUserProvided) return
|
|
|
|
|
|
|
|
|
|
// Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition
|
|
|
|
|
// check: a capture device that died silently still reports readyState
|
|
|
|
|
// "live", but the browser flips `muted`/`enabled` on the underlying
|
|
|
|
|
// MediaStreamTrack. Checking for actual silence instead would false-
|
|
|
|
|
// positive any time the user simply isn't talking.
|
|
|
|
|
const {mediaStreamTrack} = track
|
|
|
|
|
const needsReacquisition =
|
|
|
|
|
mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled
|
|
|
|
|
if (!needsReacquisition) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await track.restartTrack()
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort: the user can still recover via mute/unmute or by
|
|
|
|
|
// rejoining if reacquiring the mic fails here.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:00:11 +00:00
|
|
|
const clearReconnectSchedule = () => {
|
|
|
|
|
if (reconnectTimeout !== undefined) {
|
|
|
|
|
clearTimeout(reconnectTimeout)
|
|
|
|
|
reconnectTimeout = undefined
|
|
|
|
|
}
|
|
|
|
|
reconnectAttempt = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const attemptReconnect = async () => {
|
2026-06-26 22:56:49 +00:00
|
|
|
const target = get(callTargetRoom)
|
2026-06-02 16:00:11 +00:00
|
|
|
if (!target) return
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-17 17:19:13 +00:00
|
|
|
await joinVoiceRoom(target.url, target.h, reconnectMicMuted, reconnectMicDeviceId)
|
2026-06-02 16:00:11 +00:00
|
|
|
} catch {
|
|
|
|
|
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
|
|
|
|
pushToast({theme: "error", message: "Voice connection lost."})
|
|
|
|
|
clearReconnectSchedule()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
scheduleReconnect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scheduleReconnect = () => {
|
|
|
|
|
if (reconnectTimeout !== undefined) return
|
2026-06-26 22:56:49 +00:00
|
|
|
if (!get(callTargetRoom)) return
|
2026-06-02 16:00:11 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const makeOnRoomReconnected = (livekit: LiveKitRoom) => () => {
|
|
|
|
|
if (livekit !== activeRoom) return
|
|
|
|
|
resyncAfterReconnect(livekit)
|
2026-06-02 16:00:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectReason) => {
|
2026-05-28 19:17:17 +00:00
|
|
|
// Ignore disconnects from rooms that are no longer the active session.
|
2026-06-26 22:56:49 +00:00
|
|
|
if (livekit !== activeRoom) return
|
2026-05-28 19:17:17 +00:00
|
|
|
|
2026-07-27 22:01:48 +00:00
|
|
|
// Livekit unsubscribes remote tracks before emitting Disconnected, so
|
|
|
|
|
// onTrackUnsubscribed has already removed their audio elements by now.
|
2026-05-28 19:17:17 +00:00
|
|
|
activeRoom = undefined
|
2026-06-26 22:56:49 +00:00
|
|
|
livekit.removeAllListeners()
|
2026-05-28 19:17:17 +00:00
|
|
|
|
2026-08-17 17:19:13 +00:00
|
|
|
// Capture mic state before resetting it, so a subsequent full rejoin (see
|
|
|
|
|
// scheduleReconnect/attemptReconnect below) can restore it instead of
|
|
|
|
|
// silently coming back muted regardless of what the user had set.
|
|
|
|
|
reconnectMicMuted = get(callMicMuted)
|
|
|
|
|
reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput)
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
callMicMuted.set(true)
|
|
|
|
|
currentCallSession.set(undefined)
|
2026-03-16 20:38:05 +00:00
|
|
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
2026-06-26 22:56:49 +00:00
|
|
|
callState.set(CallState.Disconnected)
|
2026-06-02 16:00:11 +00:00
|
|
|
if (reason === DisconnectReason.JOIN_FAILURE) {
|
|
|
|
|
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
2026-06-26 22:56:49 +00:00
|
|
|
} else if (get(callTargetRoom)) {
|
2026-06-02 16:00:11 +00:00
|
|
|
clearReconnectSchedule()
|
|
|
|
|
scheduleReconnect()
|
|
|
|
|
} else {
|
|
|
|
|
pushToast({theme: "error", message: "Voice connection lost."})
|
|
|
|
|
}
|
2026-03-16 20:38:05 +00:00
|
|
|
}
|
2026-03-17 19:38:10 +00:00
|
|
|
speakingParticipants.set([])
|
2026-05-21 20:58:53 +00:00
|
|
|
participantMediaState.set(new Map())
|
2026-03-16 20:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
|
|
|
|
syncParticipantMedia(participant)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 20:38:05 +00:00
|
|
|
const onTrackSubscribed = (track: Track) => {
|
|
|
|
|
if (track.kind === Track.Kind.Audio) {
|
|
|
|
|
const element = track.attach()
|
|
|
|
|
element.style.display = "none"
|
|
|
|
|
document.body.appendChild(element)
|
|
|
|
|
element.play().catch(() => {})
|
2026-04-08 17:10:20 +00:00
|
|
|
} else if (track.kind === Track.Kind.Video) {
|
2026-06-26 22:56:49 +00:00
|
|
|
triggerVideoTrackRevision()
|
2026-03-16 20:38:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onTrackUnsubscribed = (track: Track) => {
|
|
|
|
|
track.detach().forEach(el => el.remove())
|
2026-04-08 17:10:20 +00:00
|
|
|
if (track.kind === Track.Kind.Video) {
|
2026-06-26 22:56:49 +00:00
|
|
|
triggerVideoTrackRevision()
|
2026-04-08 17:10:20 +00:00
|
|
|
}
|
2026-03-16 20:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
|
|
|
|
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const playJoinSound = () => {
|
|
|
|
|
const audio = new Audio("/join-voice-room.mp3")
|
|
|
|
|
audio.play().catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 20:58:53 +00:00
|
|
|
const onParticipantConnected = (participant: Participant) => {
|
|
|
|
|
syncParticipantMedia(participant)
|
2026-03-16 20:38:05 +00:00
|
|
|
playJoinSound()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onParticipantDisconnected = (participant: {identity: string}) => {
|
|
|
|
|
deleteParticipant(participant.identity)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 17:10:20 +00:00
|
|
|
const onLocalTrackUnpublished = (
|
|
|
|
|
publication: LocalTrackPublication,
|
|
|
|
|
participant: LocalParticipant,
|
|
|
|
|
) => {
|
|
|
|
|
if (publication.source !== Track.Source.ScreenShare) return
|
2026-06-26 22:56:49 +00:00
|
|
|
const session = get(currentCallSession)
|
|
|
|
|
if (!session || participant.identity !== session.livekit.localParticipant.identity) return
|
2026-04-08 17:10:20 +00:00
|
|
|
if (!session.screenShareOn) return
|
2026-06-26 22:56:49 +00:00
|
|
|
currentCallSession.set({...session, screenShareOn: false})
|
2026-04-08 17:10:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:00:11 +00:00
|
|
|
const abortJoinVoiceRoom = () => {
|
2026-03-16 20:38:05 +00:00
|
|
|
joinAbortController?.abort()
|
|
|
|
|
}
|