Refactor voice and video module (#310)

Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
This commit is contained in:
Matt Lorentz 2026-06-26 22:56:49 +00:00 committed by hodlbod
parent 9955a50add
commit 90561d46ad
11 changed files with 598 additions and 884 deletions

View file

@ -2,6 +2,7 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/ */
import {MediaQuery} from "svelte/reactivity"
import { import {
DisconnectReason, DisconnectReason,
LocalParticipant, LocalParticipant,
@ -14,7 +15,7 @@ import {
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
} from "livekit-client" } from "livekit-client"
import {derived, get} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib" import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
@ -22,22 +23,8 @@ import {signer} from "@welshman/app"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import {
currentVoiceRoom,
currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity,
participantKey,
participantMediaState,
speakingParticipants,
VoiceState,
type ParticipantMediaState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl} from "@app/repository" import {deriveLatestEventForUrl} from "@app/repository"
import {deriveRoom, makeRoomId} from "@app/groups" import {deriveRoom, makeRoomId, type Room} from "@app/groups"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
@ -47,6 +34,43 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection} export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default" const LIVEKIT_DEFAULT_DEVICE_ID = "default"
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
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",
}
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export enum DeviceKind { export enum DeviceKind {
AudioInput = "audioinput", AudioInput = "audioinput",
@ -54,312 +78,65 @@ export enum DeviceKind {
VideoInput = "videoinput", VideoInput = "videoinput",
} }
export const switchVoiceActiveDevice = async ( export const currentCallSession = writable<CallSession | undefined>(undefined)
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentVoiceSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.room.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({ export const callState = writable<CallState>(CallState.Disconnected)
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => { export const callTargetRoom = writable<Room | undefined>(undefined)
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => { export const speakingParticipants = writable<CallParticipant[]>([])
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m)
next.set(participant.identity, state)
return next
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect. export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
const resyncAfterReconnect = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>() export const mediaStateByIdentity = derived(
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) { [participantMediaState, currentCallSession, callMicMuted],
next.set(p.identity, participantMediaFrom(p)) ([$media, $session, $micMuted]) =>
} (liveKitIdentity: string) => {
participantMediaState.set(next) if ($session?.livekit.localParticipant.identity === liveKitIdentity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
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) => {
syncParticipantMedia(participant)
}
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await $signer.sign(template)
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()
}
export const loadVoiceParticipants = (url: string, h: string) =>
load({
relays: [url],
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
})
export const deriveVoiceParticipants = (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,
currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
if (inCall) {
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} }
return $media.get(liveKitIdentity) ?? {muted: true, cameraOn: false}
}, },
) )
const setUpMicrophone = async ( export const isParticipantSpeaking = derived(
startMuted: boolean, speakingParticipants,
preferredMicId: string | undefined, $participants => (p: CallParticipant) =>
participant: LocalParticipant, $participants.some(sp => participantKey(sp) === participantKey(p)),
signal?: AbortSignal, )
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true export const isLocalSpeaking = derived(
let capture: AudioCaptureOptions | undefined = undefined [currentCallSession, speakingParticipants],
if (preferredMicId) { ([$session, $speaking]) => {
capture = {deviceId: preferredMicId} if (!$session?.livekit) return false
} const local = participantFromLiveKitIdentity($session.livekit.localParticipant.identity)
try { return $speaking.some(sp => participantKey(sp) === participantKey(local))
await Promise.race([ },
participant.setMicrophoneEnabled(true, capture), )
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal), export const videoTrackRevision = writable(0)
])
muted = false export const triggerVideoTrackRevision = () => {
} catch (e) { videoTrackRevision.update(n => n + 1)
// 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"})
}
}
return muted
} }
// The room whose events are allowed to mutate shared state. Abandoned rooms export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] export const videoCallViewportSync = {
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined previousLayout: undefined as ViewportSize | undefined,
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
} }
const attemptReconnect = async () => { export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
const target = get(currentVoiceRoom)
if (!target) return
try { export const videoPrimaryTileKey = writable<string | undefined>(undefined)
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => { export const videoTileCount = derived(
if (reconnectTimeout !== undefined) return [currentCallSession, callState, videoTrackRevision],
if (!get(currentVoiceRoom)) return ([$session, $state]) => {
if (reconnectAttempt >= RECONNECT_DELAYS.length) { if ($state !== CallState.Connected || !$session) return 0
pushToast({theme: "error", message: "Voice connection lost."}) return countLiveVisualFeeds($session)
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) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
activeRoom = undefined
room.removeAllListeners()
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(currentVoiceRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
}
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(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
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(() => {})
}
const onParticipantConnected = (participant: Participant) => {
syncParticipantMedia(participant)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentVoiceSession)
if (!session || participant.identity !== session.room.localParticipant.identity) return
if (!session.screenShareOn) return
currentVoiceSession.set({...session, screenShareOn: false})
}
let joinAbortController: AbortController | undefined
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
url: string, url: string,
@ -369,8 +146,8 @@ export const joinVoiceRoom = async (
): Promise<void> => { ): Promise<void> => {
abortJoinVoiceRoom() abortJoinVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) callTargetRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining) callState.set(CallState.Joining)
const controller = new AbortController() const controller = new AbortController()
joinAbortController = controller joinAbortController = controller
@ -384,7 +161,7 @@ export const joinVoiceRoom = async (
try { try {
// Tear down any existing session before joining. Bound it so a slow leave // Tear down any existing session before joining. Bound it so a slow leave
// (camera/screenshare renegotiation can take ~15s) cannot block this join. // (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentVoiceSession)) { if (get(currentCallSession)) {
await Promise.race([ await Promise.race([
leaveVoiceRoom(), leaveVoiceRoom(),
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}), whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
@ -393,8 +170,8 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) throw e if (e instanceof AbortError) throw e
}) })
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining. // leaveVoiceRoom flips callState to Disconnected; re-assert Joining.
voiceState.set(VoiceState.Joining) callState.set(CallState.Joining)
} }
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
@ -468,19 +245,19 @@ export const joinVoiceRoom = async (
throw new AbortError() throw new AbortError()
} }
voiceMicMuted.set(muted) callMicMuted.set(muted)
currentVoiceSession.set({ currentCallSession.set({
url, url,
h, h,
room: liveKitRoom, livekit: liveKitRoom,
cameraOn: false, cameraOn: false,
screenShareOn: false, screenShareOn: false,
}) })
voiceState.set(VoiceState.Connected) callState.set(CallState.Connected)
clearReconnectSchedule() clearReconnectSchedule()
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected) if (isActive()) callState.set(CallState.Disconnected)
if (e instanceof AbortError) { if (e instanceof AbortError) {
clearReconnectSchedule() clearReconnectSchedule()
return return
@ -494,7 +271,7 @@ export const joinVoiceRoom = async (
export const leaveVoiceRoom = async () => { export const leaveVoiceRoom = async () => {
clearReconnectSchedule() clearReconnectSchedule()
const session = get(currentVoiceSession) const session = get(currentCallSession)
if (!session) return if (!session) return
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
@ -502,7 +279,7 @@ export const leaveVoiceRoom = async () => {
if (session.cameraOn) { if (session.cameraOn) {
try { try {
await session.room.localParticipant.setCameraEnabled(false) await session.livekit.localParticipant.setCameraEnabled(false)
} catch { } catch {
pushToast({theme: "error", message: "Error turning off camera."}) pushToast({theme: "error", message: "Error turning off camera."})
} }
@ -510,46 +287,429 @@ export const leaveVoiceRoom = async () => {
if (session.screenShareOn) { if (session.screenShareOn) {
try { try {
await session.room.localParticipant.setScreenShareEnabled(false) await session.livekit.localParticipant.setScreenShareEnabled(false)
} catch { } catch {
pushToast({theme: "error", message: "Error turning off screen sharing."}) pushToast({theme: "error", message: "Error turning off screen sharing."})
} }
} }
// Always tear down this room's connection and listeners. // Always tear down this room's connection and listeners.
if (activeRoom === session.room) activeRoom = undefined if (activeRoom === session.livekit) activeRoom = undefined
session.room.removeAllListeners() session.livekit.removeAllListeners()
session.room.disconnect() session.livekit.disconnect()
// Only reset shared UI state if this session is still current. A slow leave // 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) // that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes. // must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) { if (get(currentCallSession) === session) {
voiceState.set(VoiceState.Disconnected) callState.set(CallState.Disconnected)
videoPrimaryTileKey.set(undefined) callMicMuted.set(true)
voiceMicMuted.set(true) currentCallSession.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([]) speakingParticipants.set([])
participantMediaState.set(new Map()) participantMediaState.set(new Map())
} }
} }
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const toggleMute = async () => { export const toggleMute = async () => {
const session = get(currentVoiceSession) const session = get(currentCallSession)
if (!session) return if (!session) return
voiceMicMuted.update(not) callMicMuted.update(not)
if (get(voiceMicMuted)) { if (get(callMicMuted)) {
// Disable and re-enable microphone to trigger permission prompt session.livekit.localParticipant.setMicrophoneEnabled(false)
session.room.localParticipant.setMicrophoneEnabled(false)
return return
} }
try { try {
await session.room.localParticipant.setMicrophoneEnabled(true) await session.livekit.localParticipant.setMicrophoneEnabled(true)
} catch (e) { } catch (e) {
voiceMicMuted.set(true) callMicMuted.set(true)
pushToast({theme: "error", message: "Could not access microphone"}) pushToast({theme: "error", message: "Could not access microphone"})
} }
} }
export const toggleCamera = async () => {
const session = get(currentCallSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.livekit.localParticipant.setCameraEnabled(cameraOn)
currentCallSession.set({...session, cameraOn})
} 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)
currentCallSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
export const switchCallActiveDevice = async (
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentCallSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.livekit.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
export const loadCallParticipants = (url: string, h: string) =>
load({
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,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomId(url, h)
if (inCall) {
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: CallParticipant) => participantKey(p), participants)
} else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
return uniqBy((p: CallParticipant) => participantKey(p), participants)
}
},
)
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
let joinAbortController: AbortController | undefined
let hadCallSession = false
currentCallSession.subscribe(session => {
if (session) {
hadCallSession = true
return
}
if (!hadCallSession) return
hadCallSession = false
videoPrimaryTileKey.set(undefined)
resetVideoCallLayout()
})
const countLiveVisualFeeds = (session: CallSession): number => {
const livekit = session.livekit
let n = 0
const lp = livekit.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of livekit.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (liveKitIdentity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, liveKitIdentity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m)
next.set(participant.identity, state)
return next
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (livekit: LiveKitRoom) => {
if (livekit !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [livekit.localParticipant, ...livekit.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentCallSession)
if (!session) return
const {localParticipant} = livekit
callMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentCallSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoTrackRevision()
}
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await $signer.sign(template)
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()
}
const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true
let capture: AudioCaptureOptions | undefined = undefined
if (preferredMicId) {
capture = {deviceId: preferredMicId}
}
try {
await Promise.race([
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false
} catch (e) {
// 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"})
}
}
return muted
}
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(callTargetRoom)
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(callTargetRoom)) 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 = (livekit: LiveKitRoom) => () => {
if (livekit !== activeRoom) return
resyncAfterReconnect(livekit)
}
const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (livekit !== activeRoom) return
activeRoom = undefined
livekit.removeAllListeners()
callMicMuted.set(true)
currentCallSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
callState.set(CallState.Disconnected)
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(callTargetRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
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(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoTrackRevision()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoTrackRevision()
}
}
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(() => {})
}
const onParticipantConnected = (participant: Participant) => {
syncParticipantMedia(participant)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentCallSession)
if (!session || participant.identity !== session.livekit.localParticipant.identity) return
if (!session.screenShareOn) return
currentCallSession.set({...session, screenShareOn: false})
}
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}

View file

@ -1,75 +0,0 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import type {Room} from "@app/groups"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
cameraOn: boolean
screenShareOn: boolean
}
/** Mic mute state is separate so toggling it does not re-render video tiles. */
export const voiceMicMuted = writable(true)
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted],
([$media, $session, $micMuted]) =>
(identity: string) => {
if ($session?.room.localParticipant.identity === identity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
}
return $media.get(identity) ?? {muted: true, cameraOn: false}
},
)
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)

View file

@ -1,99 +0,0 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}

View file

@ -18,13 +18,11 @@
videoCallViewportSync, videoCallViewportSync,
ViewportSize, ViewportSize,
videoPrimaryTileKey, videoPrimaryTileKey,
} from "@app/call/video" currentCallSession,
import { callTargetRoom,
currentVoiceSession,
currentVoiceRoom,
mediaStateByIdentity, mediaStateByIdentity,
pubkeyFromLiveKitIdentity, pubkeyFromLiveKitIdentity,
} from "@app/call/stores" } from "@app/call"
type Props = { type Props = {
layout: VideoCallLayout layout: VideoCallLayout
@ -35,7 +33,7 @@
} }
type VideoTileData = { type VideoTileData = {
identity: string liveKitIdentity: string
isLocal: boolean isLocal: boolean
trackSid: string trackSid: string
track: Track | undefined track: Track | undefined
@ -64,7 +62,7 @@
}) })
const isViewingCurrentCallRoom = $derived( const isViewingCurrentCallRoom = $derived(
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, $callTargetRoom?.url === url && $callTargetRoom?.h === h,
) )
const showVideoContent = $derived( const showVideoContent = $derived(
@ -75,19 +73,19 @@
) )
const videoTiles = $derived.by(() => { const videoTiles = $derived.by(() => {
const session = $currentVoiceSession const session = $currentCallSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) { if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) {
return [] return []
} }
const room = session.room const livekit = session.livekit
const videoTiles: VideoTileData[] = [] const videoTiles: VideoTileData[] = []
const user = room.localParticipant const user = livekit.localParticipant
if (session.cameraOn) { if (session.cameraOn) {
const localPub = user.getTrackPublication(Track.Source.Camera) const localPub = user.getTrackPublication(Track.Source.Camera)
videoTiles.push({ videoTiles.push({
identity: user.identity, liveKitIdentity: user.identity,
isLocal: true, isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera", trackSid: localPub?.trackSid ?? "local-camera",
track: localPub?.track, track: localPub?.track,
@ -98,7 +96,7 @@
if (session.screenShareOn) { if (session.screenShareOn) {
const localPub = user.getTrackPublication(Track.Source.ScreenShare) const localPub = user.getTrackPublication(Track.Source.ScreenShare)
videoTiles.push({ videoTiles.push({
identity: user.identity, liveKitIdentity: user.identity,
isLocal: true, isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen", trackSid: localPub?.trackSid ?? "local-screen",
track: localPub?.track, track: localPub?.track,
@ -106,11 +104,11 @@
}) })
} }
for (const rp of room.remoteParticipants.values()) { for (const rp of livekit.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera) const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) { if (camPub?.isSubscribed && camPub.track) {
videoTiles.push({ videoTiles.push({
identity: rp.identity, liveKitIdentity: rp.identity,
isLocal: false, isLocal: false,
trackSid: camPub.trackSid, trackSid: camPub.trackSid,
track: camPub.track, track: camPub.track,
@ -120,16 +118,16 @@
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare) const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) { if (screenPub?.isSubscribed && screenPub.track) {
videoTiles.push({ videoTiles.push({
identity: rp.identity, liveKitIdentity: rp.identity,
isLocal: false, isLocal: false,
trackSid: screenPub.trackSid, trackSid: screenPub.trackSid,
track: screenPub.track, track: screenPub.track,
source: Track.Source.ScreenShare, source: Track.Source.ScreenShare,
}) })
} }
if (!videoTiles.some(t => t.identity === rp.identity)) { if (!videoTiles.some(t => t.liveKitIdentity === rp.identity)) {
videoTiles.push({ videoTiles.push({
identity: rp.identity, liveKitIdentity: rp.identity,
isLocal: false, isLocal: false,
trackSid: `avatar-${rp.identity}`, trackSid: `avatar-${rp.identity}`,
track: undefined, track: undefined,
@ -138,9 +136,9 @@
} }
} }
if (!videoTiles.some(t => t.identity === user.identity)) { if (!videoTiles.some(t => t.liveKitIdentity === user.identity)) {
videoTiles.push({ videoTiles.push({
identity: user.identity, liveKitIdentity: user.identity,
isLocal: true, isLocal: true,
trackSid: "local-avatar", trackSid: "local-avatar",
track: undefined, track: undefined,
@ -151,8 +149,8 @@
return videoTiles return videoTiles
}) })
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ /** LiveKit identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}` const tileKey = (t: VideoTileData) => `${t.liveKitIdentity}\x1f${t.source}`
const primaryTile = $derived.by(() => { const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey const k = $videoPrimaryTileKey
@ -183,13 +181,13 @@
$effect(() => { $effect(() => {
for (const t of videoTiles) { for (const t of videoTiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity) const pk = pubkeyFromLiveKitIdentity(t.liveKitIdentity)
if (pk) loadProfile(pk) if (pk) loadProfile(pk)
} }
}) })
const labelFor = (identity: string, source: VideoTileData["source"]) => { const labelFor = (liveKitIdentity: string, source: VideoTileData["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity) const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown" const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name return source === Track.Source.ScreenShare ? `${name} · screen` : name
} }
@ -212,7 +210,7 @@
</script> </script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)} {#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
{@const media = $mediaStateByIdentity(tile.identity)} {@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
<div <div
class={cx( class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm", "relative isolate overflow-hidden rounded-box shadow-sm",
@ -229,7 +227,7 @@
class="pointer-events-none absolute inset-0" /> class="pointer-events-none absolute inset-0" />
{:else} {:else}
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} /> <ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.liveKitIdentity)} {url} size={14} />
</div> </div>
{/if} {/if}
{#if tile.track} {#if tile.track}
@ -243,7 +241,7 @@
{/if} {/if}
<span <span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs"> class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""} {labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span> </span>
{#if videoTiles.length > 1} {#if videoTiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)} {@const pinned = $videoPrimaryTileKey === tileKey(tile)}

View file

@ -7,12 +7,17 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {currentVoiceSession, type VoiceSession} from "@app/call/stores" import {
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice" currentCallSession,
DeviceKind,
supportsAudioOutputSelection,
switchCallActiveDevice,
type CallSession,
} from "@app/call"
import {popModal} from "@app/modal" import {popModal} from "@app/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { const selectValueForActiveDevice = (session: CallSession, kind: DeviceKind): string => {
const livekitDeviceId = session.room.getActiveDevice(kind) const livekitDeviceId = session.livekit.getActiveDevice(kind)
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") { if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
return "" return ""
} }
@ -47,7 +52,7 @@
}) })
$effect(() => { $effect(() => {
const session = $currentVoiceSession const session = $currentCallSession
if (!session) { if (!session) {
popModal() popModal()
return return
@ -58,15 +63,15 @@
}) })
const onInputChange = () => { const onInputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput) void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
} }
const onOutputChange = () => { const onOutputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput) void switchCallActiveDevice(DeviceKind.AudioOutput, selectedOutput)
} }
const onVideoChange = () => { const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo) void switchCallActiveDevice(DeviceKind.VideoInput, selectedVideo)
} }
const onDone = () => { const onDone = () => {

View file

@ -13,19 +13,17 @@
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte" import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
import {makeRoomId} from "@app/groups" import {makeRoomId} from "@app/groups"
import { import {
VoiceState, CallState,
currentVoiceRoom, callTargetRoom,
isParticipantSpeaking, isParticipantSpeaking,
mediaStateByIdentity, mediaStateByIdentity,
participantKey, participantKey,
voiceState, callState,
type VoiceParticipant,
} from "@app/call/stores"
import {
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
deriveVoiceParticipants, deriveCallParticipants,
loadVoiceParticipants, loadCallParticipants,
} from "@app/call/voice" type CallParticipant,
} from "@app/call"
interface Props { interface Props {
url: string url: string
@ -36,13 +34,13 @@
const {url, h, replaceState = false, notification = false}: Props = $props() const {url, h, replaceState = false, notification = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h) const participants = deriveCallParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : []))) const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
const isActive = $derived( const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h), $callState === CallState.Connected && $callTargetRoom?.id === makeRoomId(url, h),
) )
const isJoining = $derived( const isJoining = $derived(
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h), $callState === CallState.Joining && $callTargetRoom?.id === makeRoomId(url, h),
) )
const handleClick = async (e: MouseEvent) => { const handleClick = async (e: MouseEvent) => {
@ -60,7 +58,7 @@
} }
$effect(() => { $effect(() => {
void loadVoiceParticipants(url, h) void loadCallParticipants(url, h)
}) })
$effect(() => { $effect(() => {
@ -87,8 +85,8 @@
</div> </div>
{#if participantPubkeys.length > 0} {#if participantPubkeys.length > 0}
{#if isActive} {#if isActive}
{#each $participants as p (participantKey(p as VoiceParticipant))} {#each $participants as p (participantKey(p as CallParticipant))}
{@const media = $mediaStateByIdentity(p.identity)} {@const media = $mediaStateByIdentity(p.liveKitIdentity)}
<div class="flex items-center gap-2 ml-6"> <div class="flex items-center gap-2 ml-6">
<div <div
class={cx( class={cx(

View file

@ -15,7 +15,7 @@
import {AbortError, TimeoutError} from "$lib/util" import {AbortError, TimeoutError} from "$lib/util"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {displayRoom} from "@app/groups" import {displayRoom} from "@app/groups"
import {deriveVoiceParticipants, joinVoiceRoom, loadVoiceParticipants} from "@app/call/voice" import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
import {popModal} from "@app/modal" import {popModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -27,7 +27,7 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const spaceLabel = $derived(displayRelayUrl(url)) const spaceLabel = $derived(displayRelayUrl(url))
const participants = deriveVoiceParticipants(url, h) const participants = deriveCallParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : []))) const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
let audioInputs = $state<MediaDeviceInfo[]>([]) let audioInputs = $state<MediaDeviceInfo[]>([])
@ -45,7 +45,7 @@
} }
$effect(() => { $effect(() => {
void loadVoiceParticipants(url, h) void loadCallParticipants(url, h)
void loadDevices() void loadDevices()
}) })

View file

@ -31,15 +31,15 @@
toggleCamera, toggleCamera,
toggleScreenShare, toggleScreenShare,
videoCallLayout, videoCallLayout,
} from "@app/call/video" CallState,
import { currentCallSession,
VoiceState, callTargetRoom,
currentVoiceSession, callMicMuted,
currentVoiceRoom, callState,
voiceMicMuted, cancelJoinVoiceRoom,
voiceState, leaveVoiceRoom,
} from "@app/call/stores" toggleMute,
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" } from "@app/call"
const {relay, h} = $derived($page.params) const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined) const url = $derived(relay ? decodeRelay(relay) : undefined)
@ -49,27 +49,27 @@
const routeDisplayedRoom = $derived($displayedRoomStore) const routeDisplayedRoom = $derived($displayedRoomStore)
const isViewingCurrentVoiceRoom = $derived( const isViewingCurrentVoiceRoom = $derived(
$currentVoiceRoom !== undefined && $callTargetRoom !== undefined &&
url !== undefined && url !== undefined &&
typeof h === "string" && typeof h === "string" &&
$currentVoiceRoom.url === url && $callTargetRoom.url === url &&
$currentVoiceRoom.h === h, $callTargetRoom.h === h,
) )
const targetRoom = $derived.by((): Room | undefined => { const targetRoom = $derived.by((): Room | undefined => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { if ($callState === CallState.Joining || $callState === CallState.Connected) {
return $currentVoiceRoom return $callTargetRoom
} }
if ($voiceState === VoiceState.Disconnected) { if ($callState === CallState.Disconnected) {
if (routeDisplayedRoom) { if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) { if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom return routeDisplayedRoom
} }
return undefined return undefined
} }
return $currentVoiceRoom return $callTargetRoom
} }
return $currentVoiceRoom return $callTargetRoom
}) })
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "") const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
@ -93,7 +93,7 @@
pushModal(VoiceCallAudioSettingsDialog) pushModal(VoiceCallAudioSettingsDialog)
} }
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom) const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
const isChatPanelActive = $derived( const isChatPanelActive = $derived(
showChatButton && showChatButton &&
@ -134,9 +134,9 @@
onclick={goToRoom} onclick={goToRoom}
aria-label="Open room {roomName}"> aria-label="Open room {roomName}">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining} {#if $callState === CallState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span> <span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected} {:else if $callState === CallState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span> <span class="text-sm font-semibold text-success">Voice Connected</span>
{:else} {:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span> <span class="text-sm font-semibold text-neutral-content">Disconnected</span>
@ -168,7 +168,7 @@
{/if} {/if}
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#if $voiceState === VoiceState.Joining} {#if $callState === CallState.Joining}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<Button <Button
data-tip="Cancel" data-tip="Cancel"
@ -176,18 +176,18 @@
onclick={cancelJoinVoiceRoom}> onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} /> <Icon icon={CloseCircle} size={4} />
</Button> </Button>
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {:else if $callState === CallState.Connected && $currentCallSession}
<Button <Button
data-tip={$voiceMicMuted ? "Unmute" : "Mute"} data-tip={$callMicMuted ? "Unmute" : "Mute"}
class={cx( class={cx(
mediaToggleClass, mediaToggleClass,
"overflow-visible", "overflow-visible",
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100", $callMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)} )}
onclick={toggleMute}> onclick={toggleMute}>
<span class="relative inline-flex items-center justify-center overflow-visible"> <span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} /> <Icon icon={Microphone} size={4} />
{#if $voiceMicMuted} {#if $callMicMuted}
<span <span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible" class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true"> aria-hidden="true">
@ -199,23 +199,21 @@
</span> </span>
</Button> </Button>
<Button <Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"} data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx( class={cx(
mediaToggleClass, mediaToggleClass,
"overflow-visible", "overflow-visible",
$currentVoiceSession.cameraOn && "text-primary", $currentCallSession.cameraOn && "text-primary",
!$currentVoiceSession.cameraOn && !$currentCallSession.cameraOn &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100", "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)} )}
onclick={toggleCamera}> onclick={toggleCamera}>
<Icon <Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4} />
</Button> </Button>
{#if !Capacitor.isNativePlatform()} {#if !Capacitor.isNativePlatform()}
<Button <Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"} data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")} class={cx(mediaToggleClass, $currentCallSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}> onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} /> <Icon icon={Monitor} size={4} />
</Button> </Button>

View file

@ -1,275 +0,0 @@
import {
ROOMS,
ROOM_DELETE,
ROOM_META,
asDecryptedEvent,
getGroupTags,
getListTags,
getRelayTagValues,
getTagValues,
isRelayUrl,
makeRoomMeta,
normalizeRelayUrl,
readList,
readRoomMeta,
} from "@welshman/util"
import type {List, PublishedRoomMeta, RelayProfile, RoomMeta, TrustedEvent} from "@welshman/util"
import {
deriveEventsByIdByUrl,
deriveItemsByKey,
getter,
makeDeriveItem,
makeLoadItem,
} from "@welshman/store"
import {makeOutboxLoader, makeUserData, makeUserLoader, repository, tracker} from "@welshman/app"
import {derived} from "svelte/store"
import {
addToMapKey,
call,
gte,
identity,
indexBy,
max,
partition,
sortBy,
spec,
tryCatch,
uniq,
uniqBy,
} from "@welshman/lib"
import {load} from "@welshman/net"
export const ROOM = "h"
export const PROTECTED = ["-"]
export enum RoomType {
Text = "text",
Voice = "voice",
}
export type Room = PublishedRoomMeta & {
id: string
url: string
}
export const getRoomType = (room: RoomMeta): RoomType =>
room.livekit ? RoomType.Voice : RoomType.Text
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const isRoomId = (id: string) => id.includes("'")
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29"))
export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
tracker,
repository,
filters: [{kinds: [ROOM_META, ROOM_DELETE]}],
})
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
const result = new Map<string, Room[]>()
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
const deletedByH = new Map<string, number>()
for (const event of deleteEvents) {
for (const h of getTagValues("h", event.tags)) {
deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
}
}
const metaById = new Map<string, Room>()
for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event))
if (!meta || gte(deletedByH.get(meta.h), meta.event.created_at)) {
continue
}
const id = makeRoomId(url, meta.h)
metaById.set(id, {...meta, url, id})
}
if (metaById.size > 0) {
result.set(url, Array.from(metaById.values()))
}
}
return result
})
export const roomsById = derived(roomsByUrl, roomsByUrl =>
indexBy(room => room.id, Array.from(roomsByUrl.values()).flatMap(identity)),
)
export const getRoomsById = getter(roomsById)
export const getRoom = (id: string) => getRoomsById().get(id)
export const loadRoom = call(() => {
const _fetchRoom = async (id: string) => {
const [url, h] = splitRoomId(id)
await load({
relays: [url],
filters: [
{kinds: [ROOM_META], "#d": [h]},
{kinds: [ROOM_DELETE], "#h": [h]},
],
})
}
const _loadRoom = makeLoadItem(_fetchRoom, getRoom)
return (url: string, h: string) => _loadRoom(makeRoomId(url, h))
})
export const deriveRoom = call(() => {
const _deriveRoom = makeDeriveItem(roomsById, loadRoom)
return (url: string, h: string) =>
derived(
_deriveRoom(makeRoomId(url, h)),
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
)
})
export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const deriveVoiceRooms = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set<string>()
for (const room of $roomsById.values()) {
if (room.url === url && room.livekit) {
set.add(room.h)
}
}
return set
})
export const deriveOtherVoiceRooms = (url: string) =>
derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
const rooms: string[] = []
for (const h of $roomsWithLivekit) {
if (!$userRooms.includes(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [ROOMS]}],
getKey: list => list.event.pubkey,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getGroupListsByPubkey = getter(groupListsByPubkey)
export const getGroupList = (pubkey: string) => getGroupListsByPubkey().get(pubkey)
export const loadGroupList = makeLoadItem(makeOutboxLoader(ROOMS), getGroupList)
export const deriveGroupList = makeDeriveItem(groupListsByPubkey, loadGroupList)
export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPubkey => {
const result = new Map<string, Set<string>>()
for (const list of $groupListsByPubkey.values()) {
const tags = getListTags(list)
for (const url of getRelayTagValues(tags)) {
addToMapKey(result, url, list.event.pubkey)
}
for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
addToMapKey(result, url, list.event.pubkey)
}
}
}
return result
})
export const deriveGroupListPubkeys = (url: string) =>
derived(groupListPubkeysByUrl, $groupListPubkeysByUrl => new Set($groupListPubkeysByUrl.get(url)))
export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => {
const tags = getListTags(groupList)
const urls = getRelayTagValues(tags)
for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
urls.push(url)
}
}
return uniqBy(normalizeRelayUrl, urls)
}
export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => {
const rooms: string[] = []
for (const [_, h, relay] of getGroupTags(getListTags(groupList))) {
if (url === relay) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
}
export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
export const loadUserGroupList = makeUserLoader(loadGroupList)
export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
export const deriveUserRooms = (url: string) =>
derived([userGroupList, roomsById], ([$userGroupList, $roomsById]) => {
const rooms: string[] = []
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
if ($roomsById.has(makeRoomId(url, h))) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), rooms)
})
export const deriveOtherRooms = (url: string) =>
derived(
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
([$userRooms, voiceRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
},
)

View file

@ -57,7 +57,7 @@ import {loadFeedsForPubkey} from "@app/feeds"
import {RELAY_ROLE} from "@app/members" import {RELAY_ROLE} from "@app/members"
import {FEATURED_CONTENT_D} from "@app/featured" import {FEATURED_CONTENT_D} from "@app/featured"
import {hasBlossomSupport} from "@app/uploads" import {hasBlossomSupport} from "@app/uploads"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call"
// Utils // Utils

View file

@ -41,8 +41,14 @@
import {userSettingsValues} from "@app/settings" import {userSettingsValues} from "@app/settings"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte" import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores" import {
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video" CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
videoTileCount,
} from "@app/call"
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked, deferredRoomPath, setChecked} from "@app/notifications" import {checked, deferredRoomPath, setChecked} from "@app/notifications"
@ -59,15 +65,13 @@
const voiceConnectedHere = $derived( const voiceConnectedHere = $derived(
isVoiceRoom && isVoiceRoom &&
$voiceState === VoiceState.Connected && $callState === CallState.Connected &&
$currentVoiceRoom?.url === url && $callTargetRoom?.url === url &&
$currentVoiceRoom?.h === h, $callTargetRoom?.h === h,
) )
const showMobileVideoPanel = $derived( const showMobileVideoPanel = $derived(
isVoiceRoom && isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
$voiceState === VoiceState.Connected &&
$videoCallLayout === VideoCallLayout.Video,
) )
const pageContentHiddenDesktopVideoOnly = $derived( const pageContentHiddenDesktopVideoOnly = $derived(
@ -92,13 +96,13 @@
let prevVideoTileCount = $state(0) let prevVideoTileCount = $state(0)
$effect(() => { $effect(() => {
if ($voiceState !== VoiceState.Connected) { if ($callState !== CallState.Connected) {
videoCallLayout.set(VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Chat)
prevVideoTileCount = 0 prevVideoTileCount = 0
return return
} }
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
const n = $videoTileCount const n = $videoTileCount
if (!here) { if (!here) {
@ -455,7 +459,7 @@
"flex min-h-0 min-w-0 flex-1 flex-col", "flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden", voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
)}> )}>
{#if isVoiceRoom && $voiceState === VoiceState.Connected} {#if isVoiceRoom && $callState === CallState.Connected}
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" /> <VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
{/if} {/if}
@ -584,7 +588,7 @@
{/key} {/key}
{/if} {/if}
</div> </div>
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected} {#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
<div <div
class={cx( class={cx(
"hide-on-keyboard flex-shrink-0 p-2 md:hidden", "hide-on-keyboard flex-shrink-0 p-2 md:hidden",