diff --git a/src/app/call/voice.ts b/src/app/call.ts similarity index 62% rename from src/app/call/voice.ts rename to src/app/call.ts index 57796c45..42edbdda 100644 --- a/src/app/call/voice.ts +++ b/src/app/call.ts @@ -2,6 +2,7 @@ * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. */ +import {MediaQuery} from "svelte/reactivity" import { DisconnectReason, LocalParticipant, @@ -14,7 +15,7 @@ import { supportsAudioOutputSelection, type AudioCaptureOptions, } 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 type {TrustedEvent} 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 {getLivekitEndpoint} from "$lib/livekit" 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 {deriveRoom, makeRoomId} from "@app/groups" +import {deriveRoom, makeRoomId, type Room} from "@app/groups" import {pushToast} from "@app/toast" export const LIVEKIT_PARTICIPANTS = 39004 @@ -47,6 +34,43 @@ export {checkRelayHasLivekit} from "$lib/livekit" export {supportsAudioOutputSelection} 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 { AudioInput = "audioinput", @@ -54,312 +78,65 @@ export enum DeviceKind { VideoInput = "videoinput", } -export const switchVoiceActiveDevice = async ( - kind: DeviceKind, - targetDeviceId: string, -): Promise => { - 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}`}) - } -} +export const currentCallSession = writable(undefined) -const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({ - muted: !participant.isMicrophoneEnabled, - cameraOn: participant.isCameraEnabled, -}) +export const callState = writable(CallState.Disconnected) -const deleteParticipant = (identity: string) => { - participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m]))) -} +export const callTargetRoom = writable(undefined) -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 - }) -} +export const speakingParticipants = writable([]) -// LiveKit does not emit ParticipantConnected/Disconnected during reconnect. -const resyncAfterReconnect = (room: LiveKitRoom) => { - if (room !== activeRoom) return +export const participantMediaState = writable(new Map()) - const next = new Map() - for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) { - next.set(p.identity, participantMediaFrom(p)) - } - participantMediaState.set(next) - - const session = get(currentVoiceSession) - if (!session) return - - const {localParticipant} = room - voiceMicMuted.set(!localParticipant.isMicrophoneEnabled) - currentVoiceSession.set({ - ...session, - cameraOn: localParticipant.isCameraEnabled, - screenShareOn: localParticipant.isScreenShareEnabled, - }) - triggerVideoFeedCount() -} - -const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => { - 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) +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} }, - ) +) -const setUpMicrophone = async ( - startMuted: boolean, - preferredMicId: string | undefined, - participant: LocalParticipant, - signal?: AbortSignal, - settleSignal?: AbortSignal, -): Promise => { - if (startMuted) { - return true - } +export const isParticipantSpeaking = derived( + speakingParticipants, + $participants => (p: CallParticipant) => + $participants.some(sp => participantKey(sp) === participantKey(p)), +) - 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 +export const isLocalSpeaking = derived( + [currentCallSession, speakingParticipants], + ([$session, $speaking]) => { + if (!$session?.livekit) return false + const local = participantFromLiveKitIdentity($session.livekit.localParticipant.identity) + return $speaking.some(sp => participantKey(sp) === participantKey(local)) + }, +) + +export const videoTrackRevision = writable(0) + +export const triggerVideoTrackRevision = () => { + videoTrackRevision.update(n => n + 1) } -// 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 +export const isDesktopLayout = new MediaQuery("min-width: 768px", false) -const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] -let reconnectTimeout: ReturnType | undefined -let reconnectAttempt = 0 - -const clearReconnectSchedule = () => { - if (reconnectTimeout !== undefined) { - clearTimeout(reconnectTimeout) - reconnectTimeout = undefined - } - reconnectAttempt = 0 +export const videoCallViewportSync = { + previousLayout: undefined as ViewportSize | undefined, } -const attemptReconnect = async () => { - const target = get(currentVoiceRoom) - if (!target) return +export const videoCallLayout = writable(VideoCallLayout.Split) - try { - await joinVoiceRoom(target.url, target.h) - } catch { - if (reconnectAttempt >= RECONNECT_DELAYS.length) { - pushToast({theme: "error", message: "Voice connection lost."}) - clearReconnectSchedule() - return - } - scheduleReconnect() - } -} +export const videoPrimaryTileKey = writable(undefined) -const scheduleReconnect = () => { - if (reconnectTimeout !== undefined) return - if (!get(currentVoiceRoom)) return - if (reconnectAttempt >= RECONNECT_DELAYS.length) { - pushToast({theme: "error", message: "Voice connection lost."}) - return - } - - const delay = RECONNECT_DELAYS[reconnectAttempt]! - reconnectAttempt++ - reconnectTimeout = setTimeout(() => { - reconnectTimeout = undefined - void attemptReconnect() - }, delay) -} - -const makeOnRoomReconnected = (room: LiveKitRoom) => () => { - if (room !== activeRoom) return - resyncAfterReconnect(room) -} - -const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => { - // 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 videoTileCount = derived( + [currentCallSession, callState, videoTrackRevision], + ([$session, $state]) => { + if ($state !== CallState.Connected || !$session) return 0 + return countLiveVisualFeeds($session) + }, +) export const joinVoiceRoom = async ( url: string, @@ -369,8 +146,8 @@ export const joinVoiceRoom = async ( ): Promise => { abortJoinVoiceRoom() - currentVoiceRoom.set(get(deriveRoom(url, h))) - voiceState.set(VoiceState.Joining) + callTargetRoom.set(get(deriveRoom(url, h))) + callState.set(CallState.Joining) const controller = new AbortController() joinAbortController = controller @@ -384,7 +161,7 @@ export const joinVoiceRoom = async ( 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(currentVoiceSession)) { + if (get(currentCallSession)) { await Promise.race([ leaveVoiceRoom(), 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 }) - // leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining. - voiceState.set(VoiceState.Joining) + // leaveVoiceRoom flips callState to Disconnected; re-assert Joining. + callState.set(CallState.Joining) } if (signal.aborted) throw new AbortError() @@ -468,19 +245,19 @@ export const joinVoiceRoom = async ( throw new AbortError() } - voiceMicMuted.set(muted) - currentVoiceSession.set({ + callMicMuted.set(muted) + currentCallSession.set({ url, h, - room: liveKitRoom, + livekit: liveKitRoom, cameraOn: false, screenShareOn: false, }) - voiceState.set(VoiceState.Connected) + callState.set(CallState.Connected) clearReconnectSchedule() playJoinSound() } catch (e) { - if (isActive()) voiceState.set(VoiceState.Disconnected) + if (isActive()) callState.set(CallState.Disconnected) if (e instanceof AbortError) { clearReconnectSchedule() return @@ -494,7 +271,7 @@ export const joinVoiceRoom = async ( export const leaveVoiceRoom = async () => { clearReconnectSchedule() - const session = get(currentVoiceSession) + const session = get(currentCallSession) if (!session) return const audio = new Audio("/leave-voice-room.mp3") @@ -502,7 +279,7 @@ export const leaveVoiceRoom = async () => { if (session.cameraOn) { try { - await session.room.localParticipant.setCameraEnabled(false) + await session.livekit.localParticipant.setCameraEnabled(false) } catch { pushToast({theme: "error", message: "Error turning off camera."}) } @@ -510,46 +287,429 @@ export const leaveVoiceRoom = async () => { if (session.screenShareOn) { try { - await session.room.localParticipant.setScreenShareEnabled(false) + 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. - if (activeRoom === session.room) activeRoom = undefined - session.room.removeAllListeners() - session.room.disconnect() + if (activeRoom === session.livekit) activeRoom = undefined + session.livekit.removeAllListeners() + session.livekit.disconnect() // 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. - if (get(currentVoiceSession) === session) { - voiceState.set(VoiceState.Disconnected) - videoPrimaryTileKey.set(undefined) - voiceMicMuted.set(true) - currentVoiceSession.set(undefined) - resetVideoCallLayout() + if (get(currentCallSession) === session) { + 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(currentVoiceSession) + const session = get(currentCallSession) if (!session) return - voiceMicMuted.update(not) - if (get(voiceMicMuted)) { - // Disable and re-enable microphone to trigger permission prompt - session.room.localParticipant.setMicrophoneEnabled(false) + callMicMuted.update(not) + if (get(callMicMuted)) { + session.livekit.localParticipant.setMicrophoneEnabled(false) return } try { - await session.room.localParticipant.setMicrophoneEnabled(true) + await session.livekit.localParticipant.setMicrophoneEnabled(true) } catch (e) { - voiceMicMuted.set(true) + callMicMuted.set(true) 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 => { + 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 | 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() + 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 => { + 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() +} diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts deleted file mode 100644 index d7e4adff..00000000 --- a/src/app/call/stores.ts +++ /dev/null @@ -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(undefined) - -export const voiceState = writable(VoiceState.Disconnected) - -export const currentVoiceRoom = writable(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([]) - -export const participantMediaState = writable(new Map()) - -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)) - }, -) diff --git a/src/app/call/video.ts b/src/app/call/video.ts deleted file mode 100644 index 96e735c3..00000000 --- a/src/app/call/video.ts +++ /dev/null @@ -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.Split) - -export const resetVideoCallLayout = () => { - videoCallViewportSync.previousLayout = undefined - videoCallLayout.set(VideoCallLayout.Chat) -} - -export const videoPrimaryTileKey = writable(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", - }) - } -} diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 5d469821..6ca8af4b 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -18,13 +18,11 @@ videoCallViewportSync, ViewportSize, videoPrimaryTileKey, - } from "@app/call/video" - import { - currentVoiceSession, - currentVoiceRoom, + currentCallSession, + callTargetRoom, mediaStateByIdentity, pubkeyFromLiveKitIdentity, - } from "@app/call/stores" + } from "@app/call" type Props = { layout: VideoCallLayout @@ -35,7 +33,7 @@ } type VideoTileData = { - identity: string + liveKitIdentity: string isLocal: boolean trackSid: string track: Track | undefined @@ -64,7 +62,7 @@ }) const isViewingCurrentCallRoom = $derived( - $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, + $callTargetRoom?.url === url && $callTargetRoom?.h === h, ) const showVideoContent = $derived( @@ -75,19 +73,19 @@ ) const videoTiles = $derived.by(() => { - const session = $currentVoiceSession - if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) { + const session = $currentCallSession + if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) { return [] } - const room = session.room + const livekit = session.livekit const videoTiles: VideoTileData[] = [] - const user = room.localParticipant + const user = livekit.localParticipant if (session.cameraOn) { const localPub = user.getTrackPublication(Track.Source.Camera) videoTiles.push({ - identity: user.identity, + liveKitIdentity: user.identity, isLocal: true, trackSid: localPub?.trackSid ?? "local-camera", track: localPub?.track, @@ -98,7 +96,7 @@ if (session.screenShareOn) { const localPub = user.getTrackPublication(Track.Source.ScreenShare) videoTiles.push({ - identity: user.identity, + liveKitIdentity: user.identity, isLocal: true, trackSid: localPub?.trackSid ?? "local-screen", 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) if (camPub?.isSubscribed && camPub.track) { videoTiles.push({ - identity: rp.identity, + liveKitIdentity: rp.identity, isLocal: false, trackSid: camPub.trackSid, track: camPub.track, @@ -120,16 +118,16 @@ const screenPub = rp.getTrackPublication(Track.Source.ScreenShare) if (screenPub?.isSubscribed && screenPub.track) { videoTiles.push({ - identity: rp.identity, + liveKitIdentity: rp.identity, isLocal: false, trackSid: screenPub.trackSid, track: screenPub.track, source: Track.Source.ScreenShare, }) } - if (!videoTiles.some(t => t.identity === rp.identity)) { + if (!videoTiles.some(t => t.liveKitIdentity === rp.identity)) { videoTiles.push({ - identity: rp.identity, + liveKitIdentity: rp.identity, isLocal: false, trackSid: `avatar-${rp.identity}`, track: undefined, @@ -138,9 +136,9 @@ } } - if (!videoTiles.some(t => t.identity === user.identity)) { + if (!videoTiles.some(t => t.liveKitIdentity === user.identity)) { videoTiles.push({ - identity: user.identity, + liveKitIdentity: user.identity, isLocal: true, trackSid: "local-avatar", track: undefined, @@ -151,8 +149,8 @@ return videoTiles }) - /** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ - const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}` + /** LiveKit identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ + const tileKey = (t: VideoTileData) => `${t.liveKitIdentity}\x1f${t.source}` const primaryTile = $derived.by(() => { const k = $videoPrimaryTileKey @@ -183,13 +181,13 @@ $effect(() => { for (const t of videoTiles) { - const pk = pubkeyFromLiveKitIdentity(t.identity) + const pk = pubkeyFromLiveKitIdentity(t.liveKitIdentity) if (pk) loadProfile(pk) } }) - const labelFor = (identity: string, source: VideoTileData["source"]) => { - const pk = pubkeyFromLiveKitIdentity(identity) + const labelFor = (liveKitIdentity: string, source: VideoTileData["source"]) => { + const pk = pubkeyFromLiveKitIdentity(liveKitIdentity) const name = pk ? displayProfileByPubkey(pk) : "Unknown" return source === Track.Source.ScreenShare ? `${name} · screen` : name } @@ -212,7 +210,7 @@ {#snippet videoTile(tile: VideoTileData, layout: TileLayout)} - {@const media = $mediaStateByIdentity(tile.identity)} + {@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
{:else}
- +
{/if} {#if tile.track} @@ -243,7 +241,7 @@ {/if} - {labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""} + {labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""} {#if videoTiles.length > 1} {@const pinned = $videoPrimaryTileKey === tileKey(tile)} diff --git a/src/app/components/VoiceCallAudioSettingsDialog.svelte b/src/app/components/VoiceCallAudioSettingsDialog.svelte index 2e48a18a..7152466f 100644 --- a/src/app/components/VoiceCallAudioSettingsDialog.svelte +++ b/src/app/components/VoiceCallAudioSettingsDialog.svelte @@ -7,12 +7,17 @@ import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte" - import {currentVoiceSession, type VoiceSession} from "@app/call/stores" - import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice" + import { + currentCallSession, + DeviceKind, + supportsAudioOutputSelection, + switchCallActiveDevice, + type CallSession, + } from "@app/call" import {popModal} from "@app/modal" - const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { - const livekitDeviceId = session.room.getActiveDevice(kind) + const selectValueForActiveDevice = (session: CallSession, kind: DeviceKind): string => { + const livekitDeviceId = session.livekit.getActiveDevice(kind) if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") { return "" } @@ -47,7 +52,7 @@ }) $effect(() => { - const session = $currentVoiceSession + const session = $currentCallSession if (!session) { popModal() return @@ -58,15 +63,15 @@ }) const onInputChange = () => { - void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput) + void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput) } const onOutputChange = () => { - void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput) + void switchCallActiveDevice(DeviceKind.AudioOutput, selectedOutput) } const onVideoChange = () => { - void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo) + void switchCallActiveDevice(DeviceKind.VideoInput, selectedVideo) } const onDone = () => { diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 510e3d5e..2b35a4a6 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -13,19 +13,17 @@ import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte" import {makeRoomId} from "@app/groups" import { - VoiceState, - currentVoiceRoom, + CallState, + callTargetRoom, isParticipantSpeaking, mediaStateByIdentity, participantKey, - voiceState, - type VoiceParticipant, - } from "@app/call/stores" - import { + callState, cancelJoinVoiceRoom, - deriveVoiceParticipants, - loadVoiceParticipants, - } from "@app/call/voice" + deriveCallParticipants, + loadCallParticipants, + type CallParticipant, + } from "@app/call" interface Props { url: string @@ -36,13 +34,13 @@ 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 isActive = $derived( - $voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h), + $callState === CallState.Connected && $callTargetRoom?.id === makeRoomId(url, h), ) const isJoining = $derived( - $voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h), + $callState === CallState.Joining && $callTargetRoom?.id === makeRoomId(url, h), ) const handleClick = async (e: MouseEvent) => { @@ -60,7 +58,7 @@ } $effect(() => { - void loadVoiceParticipants(url, h) + void loadCallParticipants(url, h) }) $effect(() => { @@ -87,8 +85,8 @@
{#if participantPubkeys.length > 0} {#if isActive} - {#each $participants as p (participantKey(p as VoiceParticipant))} - {@const media = $mediaStateByIdentity(p.identity)} + {#each $participants as p (participantKey(p as CallParticipant))} + {@const media = $mediaStateByIdentity(p.liveKitIdentity)}
(p.pubkey ? [p.pubkey] : []))) let audioInputs = $state([]) @@ -45,7 +45,7 @@ } $effect(() => { - void loadVoiceParticipants(url, h) + void loadCallParticipants(url, h) void loadDevices() }) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 615daf49..e931c317 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -31,15 +31,15 @@ toggleCamera, toggleScreenShare, videoCallLayout, - } from "@app/call/video" - import { - VoiceState, - currentVoiceSession, - currentVoiceRoom, - voiceMicMuted, - voiceState, - } from "@app/call/stores" - import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" + CallState, + currentCallSession, + callTargetRoom, + callMicMuted, + callState, + cancelJoinVoiceRoom, + leaveVoiceRoom, + toggleMute, + } from "@app/call" const {relay, h} = $derived($page.params) const url = $derived(relay ? decodeRelay(relay) : undefined) @@ -49,27 +49,27 @@ const routeDisplayedRoom = $derived($displayedRoomStore) const isViewingCurrentVoiceRoom = $derived( - $currentVoiceRoom !== undefined && + $callTargetRoom !== undefined && url !== undefined && typeof h === "string" && - $currentVoiceRoom.url === url && - $currentVoiceRoom.h === h, + $callTargetRoom.url === url && + $callTargetRoom.h === h, ) const targetRoom = $derived.by((): Room | undefined => { - if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { - return $currentVoiceRoom + if ($callState === CallState.Joining || $callState === CallState.Connected) { + return $callTargetRoom } - if ($voiceState === VoiceState.Disconnected) { + if ($callState === CallState.Disconnected) { if (routeDisplayedRoom) { if (getRoomType(routeDisplayedRoom) === RoomType.Voice) { return routeDisplayedRoom } return undefined } - return $currentVoiceRoom + return $callTargetRoom } - return $currentVoiceRoom + return $callTargetRoom }) const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "") @@ -93,7 +93,7 @@ pushModal(VoiceCallAudioSettingsDialog) } - const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom) + const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom) const isChatPanelActive = $derived( showChatButton && @@ -134,9 +134,9 @@ onclick={goToRoom} aria-label="Open room {roomName}">
- {#if $voiceState === VoiceState.Joining} + {#if $callState === CallState.Joining} Joining... - {:else if $voiceState === VoiceState.Connected} + {:else if $callState === CallState.Connected} Voice Connected {:else} Disconnected @@ -168,7 +168,7 @@ {/if}
- {#if $voiceState === VoiceState.Joining} + {#if $callState === CallState.Joining} - {:else if $voiceState === VoiceState.Connected && $currentVoiceSession} + {:else if $callState === CallState.Connected && $currentCallSession} {#if !Capacitor.isNativePlatform()} diff --git a/src/app/rooms.ts b/src/app/rooms.ts deleted file mode 100644 index 875da0bc..00000000 --- a/src/app/rooms.ts +++ /dev/null @@ -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() - - for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { - const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) - const deletedByH = new Map() - - 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() - - 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() - 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>() - - 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)) - }, - ) diff --git a/src/app/sync.ts b/src/app/sync.ts index 72af9717..07abb6d8 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -57,7 +57,7 @@ import {loadFeedsForPubkey} from "@app/feeds" import {RELAY_ROLE} from "@app/members" import {FEATURED_CONTENT_D} from "@app/featured" import {hasBlossomSupport} from "@app/uploads" -import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" +import {LIVEKIT_PARTICIPANTS} from "@app/call" // Utils diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index feb455c3..2076a79c 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -41,8 +41,14 @@ import {userSettingsValues} from "@app/settings" import VoiceWidget from "@app/components/VoiceWidget.svelte" import VideoCallContent from "@app/components/VideoCallContent.svelte" - import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores" - import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video" + import { + CallState, + callTargetRoom, + callState, + VideoCallLayout, + videoCallLayout, + videoTileCount, + } from "@app/call" import {makeFeed} from "@app/feeds" import {popKey} from "@lib/implicit" import {checked, deferredRoomPath, setChecked} from "@app/notifications" @@ -59,15 +65,13 @@ const voiceConnectedHere = $derived( isVoiceRoom && - $voiceState === VoiceState.Connected && - $currentVoiceRoom?.url === url && - $currentVoiceRoom?.h === h, + $callState === CallState.Connected && + $callTargetRoom?.url === url && + $callTargetRoom?.h === h, ) const showMobileVideoPanel = $derived( - isVoiceRoom && - $voiceState === VoiceState.Connected && - $videoCallLayout === VideoCallLayout.Video, + isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video, ) const pageContentHiddenDesktopVideoOnly = $derived( @@ -92,13 +96,13 @@ let prevVideoTileCount = $state(0) $effect(() => { - if ($voiceState !== VoiceState.Connected) { + if ($callState !== CallState.Connected) { videoCallLayout.set(VideoCallLayout.Chat) prevVideoTileCount = 0 return } - const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h + const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h const n = $videoTileCount if (!here) { @@ -455,7 +459,7 @@ "flex min-h-0 min-w-0 flex-1 flex-col", voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden", )}> - {#if isVoiceRoom && $voiceState === VoiceState.Connected} + {#if isVoiceRoom && $callState === CallState.Connected} {/if} @@ -584,7 +588,7 @@ {/key} {/if}
- {#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected} + {#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}