/** * 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 {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" import {getToken} from "nostr-tools/nip98" import {derived, get, writable} from "svelte/store" import {now} from "@welshman/lib" import {makeEvent, getTagValue} from "@welshman/util" import {signer, publishThunk} from "@welshman/app" import {deriveEventsForUrl} from "@app/core/state" import {pushToast} from "@app/util/toast" export const ROOM_PRESENCE = 10312 const livekitEndpoint = (url: string, groupId: string) => { const httpUrl = url .replace(/^wss:\/\//, "https://") .replace(/^ws:\/\//, "http://") .replace(/\/$/, "") return `${httpUrl}/.well-known/nip29/livekit/${groupId}` } export const checkRelayHasLivekit = async (url: string): Promise => { const endpoint = livekitEndpoint(url, "nop") try { // Currently we are hitting the API with no auth because zooid returns a 401 livekit // is configured and 404 if it is not. But we need a standardized solution in the NIP. const response = await fetch(endpoint) return response.status === 401 } catch { return false } } const PRESENCE_INTERVAL_MS = 60_000 const PRESENCE_EXPIRY_S = 300 export type VoiceSession = { url: string h: string room: Room muted: boolean } export const currentVoiceSession = writable(undefined) const fetchLivekitToken = async ( url: string, groupId: string, signal?: AbortSignal, ): Promise<{server_url: string; participant_token: string}> => { const endpoint = livekitEndpoint(url, groupId) const $signer = signer.get() if (!$signer) throw new Error("No signer available") if (signal?.aborted) throw new Error("Join cancelled") const authHeader = await getToken( endpoint, "GET", template => $signer.sign( makeEvent(template.kind, { tags: template.tags, content: template.content ?? "", }), ), true, ) let response: Response try { response = await fetch(endpoint, { headers: {Authorization: authHeader}, signal, }) } catch (e) { if (e instanceof DOMException && e.name === "AbortError") throw new Error("Join cancelled") throw e } if (!response.ok) { const text = await response.text() throw new Error(`Token request failed (${response.status}): ${text}`) } return response.json() } export const deriveVoiceParticipants = (url: string, h: string) => derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => { const cutoff = now() - PRESENCE_EXPIRY_S const pubkeys: string[] = [] for (const event of $events) { if (event.created_at < cutoff) continue if (getTagValue("h", event.tags) === h) { pubkeys.push(event.pubkey) } } return pubkeys }) const publishPresence = (url: string, h: string) => { const event = makeEvent(ROOM_PRESENCE, { tags: [["h", h]], }) return publishThunk({event, relays: [url]}) } const deletePresence = (url: string) => { const event = makeEvent(ROOM_PRESENCE, {tags: []}) return publishThunk({event, relays: [url]}) } let presenceInterval: ReturnType | undefined const startPresenceHeartbeat = (url: string, h: string) => { stopPresenceHeartbeat() publishPresence(url, h) presenceInterval = setInterval(() => publishPresence(url, h), PRESENCE_INTERVAL_MS) } const stopPresenceHeartbeat = () => { if (presenceInterval) { clearInterval(presenceInterval) presenceInterval = undefined } } export const joinVoiceRoom = async ( url: string, h: string, signal?: AbortSignal, ): Promise => { const session = get(currentVoiceSession) if (session) { if (session.url === url && session.h === h) return await leaveVoiceRoom() } const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) if (signal?.aborted) throw new Error("Join cancelled") const room = new Room({ adaptiveStream: true, dynacast: true, }) room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { currentVoiceSession.set(undefined) stopPresenceHeartbeat() if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { const message = reason === DisconnectReason.JOIN_FAILURE ? "Could not connect to voice room. Please try again." : "Voice connection lost." pushToast({theme: "error", message}) } }) room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => { if (track.kind === Track.Kind.Audio) { const element = track.attach() element.style.display = "none" document.body.appendChild(element) element.play().catch(() => {}) } }) room.on(RoomEvent.TrackUnsubscribed, track => { track.detach().forEach(el => el.remove()) }) const onAbort = () => { room.disconnect() } if (signal) { if (signal.aborted) { room.disconnect() throw new Error("Join cancelled") } signal.addEventListener("abort", onAbort, {once: true}) } const CONNECT_TIMEOUT_MS = 5_000 try { await Promise.race([ room.connect(server_url, participant_token, {maxRetries: 0}), new Promise((_, reject) => setTimeout( () => reject(new Error("Connection timed out. Please check your network and try again.")), CONNECT_TIMEOUT_MS, ), ), ]) } catch (e) { room.disconnect() if (signal?.aborted) { throw new Error("Join cancelled") } throw e } finally { signal?.removeEventListener("abort", onAbort) } if (signal?.aborted) throw new Error("Join cancelled") await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) startPresenceHeartbeat(url, h) } export const leaveVoiceRoom = async () => { const session = get(currentVoiceSession) if (!session) return stopPresenceHeartbeat() session.room.disconnect() deletePresence(session.url) currentVoiceSession.set(undefined) } export const toggleMute = () => { const session = get(currentVoiceSession) if (!session) return const muted = !session.muted session.room.localParticipant.setMicrophoneEnabled(!muted) currentVoiceSession.set({...session, muted}) }