From bd0adfdffe9ceb0e04c149e1c937280103a8c983 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Mar 2026 11:06:05 -0400 Subject: [PATCH] Add a dialog before joining voice rooms --- src/app/components/VoiceRoomItem.svelte | 21 +++- src/app/components/VoiceRoomJoinDialog.svelte | 116 ++++++++++++++++++ src/app/components/VoiceWidget.svelte | 68 +++++----- src/app/core/state.ts | 25 +++- src/app/voice.ts | 89 ++++++++++---- src/routes/spaces/[relay]/[h]/+page.svelte | 4 +- 6 files changed, 262 insertions(+), 61 deletions(-) create mode 100644 src/app/components/VoiceRoomJoinDialog.svelte diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 30c7fe9f..22253956 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,15 +1,17 @@ + + + + + Join voice room? + + + + {$room?.name || h} + · + {spaceLabel} + + + +

Select a microphone to join the call.

+
+
+ + +
+ + {#snippet label()} +

Microphone

+ {/snippet} + {#snippet input()} + + {/snippet} +
+
+
+ + + + +
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 239ba8f4..6b528869 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,22 +1,7 @@ - - -{#if $currentVoiceRoom} +{#if $targetRoom}
- {#if $voiceState === "joining"} + {#if $voiceState === VoiceState.Joining} Joining... - {:else if $voiceState === "connected"} + {:else if $voiceState === VoiceState.Connected} Voice Connected {:else} Disconnected @@ -64,7 +72,7 @@
- {#if $voiceState === "joining"} + {#if $voiceState === VoiceState.Joining} - {:else if $voiceState === "connected" && $currentVoiceSession} + {:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {/if} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 7b133f24..16e3eca4 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -1,7 +1,9 @@ import twColors from "tailwindcss/colors" import {context as pomadeContext} from "@pomade/core" import {Capacitor} from "@capacitor/core" -import {derived, readable, writable} from "svelte/store" +import {page} from "$app/stores" +import type {Page} from "@sveltejs/kit" +import {derived, readable, writable, type Readable} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import { on, @@ -583,6 +585,8 @@ export type Room = PublishedRoomMeta & { url: string } +export type RoomRef = {url: string; h: string} + export const getRoomType = (room: RoomMeta): RoomType => room.livekit ? RoomType.Voice : RoomType.Text @@ -805,6 +809,25 @@ export const deriveOtherRooms = (url: string) => }, ) +export const parseDisplayedRoomParams = (params: Page["params"]): RoomRef | undefined => { + const relay = params.relay + const h = params.h + if (!relay || !h || typeof h !== "string") return undefined + return {url: decodeRelay(relay), h} +} + +export const deriveDisplayedRoom = derived( + derived(page, $p => parseDisplayedRoomParams($p.params)), + ($p, set) => { + if (!$p) { + set(undefined) + return () => {} + } + const inner = deriveRoom($p.url, $p.h) + return inner.subscribe(set) + }, +) as Readable<(RoomMeta & {url: string; id: string}) | undefined> + // Space/room memberships export const deriveSpaceMembers = (url: string) => diff --git a/src/app/voice.ts b/src/app/voice.ts index 8856c2d2..091e2955 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -2,21 +2,26 @@ * 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 { + DisconnectReason, + Room, + RoomEvent, + Track, + type AudioCaptureOptions, + type LocalParticipant, +} from "livekit-client" import {derived, get, writable} from "svelte/store" import {map, removeUndefined, uniqBy} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" -import {AbortError, whenAborted, whenTimeout} from "$lib/util" -import {deriveLatestEventForUrl} from "@app/core/state" +import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" +import {deriveLatestEventForUrl, type RoomRef} from "@app/core/state" import {pushToast} from "@app/util/toast" export const LIVEKIT_PARTICIPANTS = 39004 -export {checkRelayHasLivekit} from "$lib/livekit" - export class VoiceJoinMembershipError extends Error { constructor() { super("Failed to join voice room: you must be a member.") @@ -24,6 +29,19 @@ export class VoiceJoinMembershipError extends Error { } } +const handleJoinError = (e: unknown) => { + if (e instanceof AbortError) return + console.error("Failed to join voice room", e) + let message = "Failed to join voice room" + if (e instanceof VoiceJoinMembershipError) message = e.message + else if (e instanceof TimeoutError) + message = "Connection timed out. Please check your network and try again." + else if (e instanceof Error && e.message === "No signer available") message = e.message + pushToast({theme: "error", message}) +} + +export {checkRelayHasLivekit} from "$lib/livekit" + export type VoiceSession = { url: string h: string @@ -35,13 +53,17 @@ export type Pubkey = string export type VoiceParticipant = {pubkey?: Pubkey; identity: string} -export type VoiceState = "joining" | "connected" | "disconnected" +export enum VoiceState { + Joining = "joining", + Connected = "connected", + Disconnected = "disconnected", +} export const currentVoiceSession = writable(undefined) -export const voiceState = writable("disconnected") +export const voiceState = writable(VoiceState.Disconnected) -export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined) +export const currentVoiceRoom = writable(undefined) export const participantPubkeyMap = writable>(new Map()) @@ -140,10 +162,33 @@ export const deriveVoiceParticipants = (url: string, h: string) => }, ) +const setUpMicrophone = async ( + startMuted: boolean, + preferredMicId: string | undefined, + participant: LocalParticipant, +): Promise => { + if (startMuted) { + return true + } + + let muted = true + let capture: AudioCaptureOptions | undefined = undefined + if (preferredMicId) { + capture = {deviceId: preferredMicId} + } + try { + await participant.setMicrophoneEnabled(true, capture) + muted = false + } catch (e) { + pushToast({theme: "error", message: "Could not access microphone"}) + } + return muted +} + const onRoomDisconnected = (reason?: DisconnectReason) => { currentVoiceSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { - voiceState.set("disconnected") + voiceState.set(VoiceState.Disconnected) const message = reason === DisconnectReason.JOIN_FAILURE ? "Could not connect to voice room. Please try again." @@ -191,14 +236,19 @@ export const cancelJoinVoiceRoom = () => { joinAbortController?.abort() } -export const joinVoiceRoom = async (url: string, h: string): Promise => { +export const joinVoiceRoom = async ( + url: string, + h: string, + startMuted = true, + preferredMicId?: string, +): Promise => { cancelJoinVoiceRoom() const session = get(currentVoiceSession) if (session) await leaveVoiceRoom() currentVoiceRoom.set({url, h}) - voiceState.set("joining") + voiceState.set(VoiceState.Joining) const controller = new AbortController() joinAbortController = controller @@ -238,20 +288,15 @@ export const joinVoiceRoom = async (url: string, h: string): Promise => { addParticipant(p.identity) } - let muted = false - try { - await room.localParticipant.setMicrophoneEnabled(true) - } catch (e) { - muted = true - pushToast({theme: "error", message: "Could not access microphone"}) - } + const muted = await setUpMicrophone(startMuted, preferredMicId, room.localParticipant) currentVoiceSession.set({url, h, room, muted}) - voiceState.set("connected") + voiceState.set(VoiceState.Connected) playJoinSound() } catch (e) { - if (isActive()) voiceState.set("disconnected") - throw e + if (isActive()) voiceState.set(VoiceState.Disconnected) + if (e instanceof AbortError) return + handleJoinError(e) } finally { if (isActive()) joinAbortController = undefined } @@ -264,7 +309,7 @@ export const leaveVoiceRoom = async () => { const audio = new Audio("/leave-voice-room.mp3") audio.play().catch(() => {}) - voiceState.set("disconnected") + voiceState.set(VoiceState.Disconnected) currentVoiceSession.set(undefined) session.room.disconnect() speakingParticipants.set([]) diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 2b139c4d..e85edaeb 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -50,7 +50,7 @@ userSettingsValues, } from "@app/core/state" import VoiceWidget from "@app/components/VoiceWidget.svelte" - import {voiceState} from "@app/voice" + import {VoiceState, voiceState} from "@app/voice" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {checked} from "@app/util/notifications" @@ -494,7 +494,7 @@ {/key} {/if}
- {#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"} + {#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}