diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 912cd2c4..ca81dd61 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -7,6 +7,7 @@ import {isMobileViewport} from "@lib/html" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" + import {errorMessage} from "@lib/util" import {pushToast} from "@app/util/toast" import { deriveVoiceParticipants, @@ -46,10 +47,7 @@ try { await joinVoiceRoom(url, h, joinAbortController.signal) } catch (e) { - if (e instanceof Error && e.message === "Join cancelled") return - if (e instanceof DOMException && e.name === "AbortError") return - const message = e instanceof Error ? e.message : String(e) - pushToast({theme: "error", message: `Failed to join voice room: ${message}`}) + pushToast({theme: "error", message: `Failed to join voice room: ${errorMessage(e)}`}) } finally { isJoining = false joinAbortController = undefined diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 3148f2ca..0be11aa1 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,5 +1,4 @@ @@ -41,10 +38,10 @@
-
diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 8978797d..69a0d479 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -103,6 +103,7 @@ import { getListTags, getPubkeyTagValues, getRelayTagValues, + getTag, getTagValues, isRelayUrl, normalizeRelayUrl, @@ -668,7 +669,7 @@ export const deriveRoomsWithLivekit = (url: string) => derived(roomsById, $roomsById => { const set = new Set() for (const room of $roomsById.values()) { - if (room.url === url && room.event?.tags?.some(t => t[0] === "livekit")) { + if (room.url === url && getTag("livekit", room.event?.tags ?? [])) { set.add(room.h) } } @@ -679,7 +680,7 @@ export const deriveRoomsNoText = (url: string) => derived(roomsById, $roomsById => { const set = new Set() for (const room of $roomsById.values()) { - if (room.url === url && room.event?.tags?.some(t => t[0] === "no-text")) { + if (room.url === url && getTag("no-text", room.event?.tags ?? [])) { set.add(room.h) } } @@ -688,12 +689,12 @@ export const deriveRoomsNoText = (url: string) => export const roomHasLivekit = (url: string, h: string) => { const room = getRoom(makeRoomId(url, h)) - return room?.event?.tags?.some(t => t[0] === "livekit") ?? false + return !!getTag("livekit", room?.event?.tags ?? []) } export const roomIsNoText = (url: string, h: string) => { const room = getRoom(makeRoomId(url, h)) - return room?.event?.tags?.some(t => t[0] === "no-text") ?? false + return !!getTag("no-text", room?.event?.tags ?? []) } // User space/room lists diff --git a/src/app/voice.ts b/src/app/voice.ts index 7dfefc9b..4ac68189 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -3,12 +3,12 @@ * (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 {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util" import {signer, publishThunk} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" +import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {deriveEventsForUrl} from "@app/core/state" import {pushToast} from "@app/util/toast" @@ -28,7 +28,7 @@ export type VoiceSession = { export const currentVoiceSession = writable(undefined) -export const speakingPubkeys = writable>(new Set()) +export const speakingPubkeys = writable(new Set()) const fetchLivekitToken = async ( url: string, @@ -40,31 +40,16 @@ const fetchLivekitToken = async ( const $signer = signer.get() if (!$signer) throw new Error("No signer available") - if (signal?.aborted) throw new Error("Join cancelled") + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") - const authHeader = await getToken( - endpoint, - "GET", - template => - $signer.sign( - makeEvent(template.kind, { - tags: template.tags, - content: template.content ?? "", - }), - ), - true, - ) + const template = await makeHttpAuth(endpoint, "GET") + const signedEvent = await $signer.sign(template) + const authHeader = makeHttpAuthHeader(signedEvent) - 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 - } + const response = await fetch(endpoint, { + headers: {Authorization: authHeader}, + signal, + }) if (!response.ok) { const text = await response.text() @@ -119,6 +104,36 @@ const stopPresenceHeartbeat = () => { } } +const onRoomDisconnected = (reason?: DisconnectReason) => { + speakingPubkeys.set(new Set()) + 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}) + } +} + +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(() => {}) + } +} + +const onTrackUnsubscribed = (track: Track) => { + track.detach().forEach(el => el.remove()) +} + +const onActiveSpeakersChanged = (participants: {identity: string}[]) => { + speakingPubkeys.set(new Set(participants.map(p => p.identity))) +} + export const joinVoiceRoom = async ( url: string, h: string, @@ -126,83 +141,33 @@ export const joinVoiceRoom = async ( ): Promise => { const session = get(currentVoiceSession) - if (session) { - if (session.url === url && session.h === h) return - await leaveVoiceRoom() - } + if (session) await leaveVoiceRoom() const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) - if (signal?.aborted) throw new Error("Join cancelled") + if (signal?.aborted) return - const room = new Room({ - adaptiveStream: true, - dynacast: true, + const room = new Room({adaptiveStream: true, dynacast: true}) + + room.on(RoomEvent.Disconnected, onRoomDisconnected) + room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) + room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + + const connect = room.connect(server_url, participant_token, {maxRetries: 0}) + const timeout = whenTimeout(5_000, { + message: "Connection timed out. Please check your network and try again.", }) - - room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { - speakingPubkeys.set(new Set()) - 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()) - }) - - room.on(RoomEvent.ActiveSpeakersChanged, participants => { - speakingPubkeys.set(new Set(participants.map(p => p.identity))) - }) - - 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 + const abort = whenAborted(signal) 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, - ), - ), - ]) + await Promise.race([connect, timeout, abort]) } catch (e) { room.disconnect() - if (signal?.aborted) { - throw new Error("Join cancelled") - } + if (e instanceof AbortError) return 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}) diff --git a/src/lib/util.ts b/src/lib/util.ts index e22c36db..59f9fb21 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "") +export class AbortError extends Error { + constructor() { + super("Aborted") + this.name = "AbortError" + } +} + +export class TimeoutError extends Error { + constructor(message = "Timed out") { + super(message) + this.name = "TimeoutError" + } +} + +/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */ +export const whenAborted = (signal?: AbortSignal) => { + if (!signal) return new Promise(() => {}) + + return new Promise((_, reject) => { + const onAborted = () => reject(new AbortError()) + if (signal.aborted) onAborted() + else signal.addEventListener("abort", onAborted, {once: true}) + }) +} + +/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */ +export const whenTimeout = (ms: number, opts: {message?: string} = {}) => { + return new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), ms)) +} + export const buildUrl = (base: string | URL, ...pathname: string[]) => { const url = new URL(base)