From 16a73f27c96398eb1e176bde26ad917321234f2b Mon Sep 17 00:00:00 2001 From: Matt Lorentz Date: Fri, 27 Mar 2026 19:02:56 +0000 Subject: [PATCH] Add a dialog before joining voice rooms (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat. This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP. ![Screenshot 2026-03-27 at 11.10.53 AM.png](/attachments/3ac271a6-5d17-4063-9ac6-3e5bdef10ccf) Co-authored-by: mplorentz Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/109 Co-authored-by: Matt Lorentz Co-committed-by: Matt Lorentz --- src/app/components/VoiceRoomItem.svelte | 18 ++- src/app/components/VoiceRoomJoinDialog.svelte | 115 ++++++++++++++++++ src/app/components/VoiceWidget.svelte | 79 +++++++----- src/app/core/state.ts | 2 +- src/app/voice.ts | 111 ++++++++++------- src/routes/spaces/[relay]/[h]/+page.svelte | 4 +- 6 files changed, 246 insertions(+), 83 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..5de7178a 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,15 +1,18 @@ + + + + + Join voice room? + + + + {displayRoom(url, 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..603d2efc 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,22 +1,8 @@ - - -{#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 +83,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..f6f2cdf5 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -669,7 +669,7 @@ export const deriveRoom = call(() => { return (url: string, h: string) => derived( _deriveRoom(makeRoomId(url, h)), - room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}, + room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room, ) }) diff --git a/src/app/voice.ts b/src/app/voice.ts index 8856c2d2..a4453ed4 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -2,7 +2,14 @@ * 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 as LiveKitRoom, + 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" @@ -10,24 +17,17 @@ 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 {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} 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.") - this.name = "VoiceJoinMembershipError" - } -} - export type VoiceSession = { url: string h: string - room: Room + room: LiveKitRoom muted: boolean } @@ -35,13 +35,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()) @@ -102,7 +106,6 @@ const fetchLivekitToken = async ( if (!response.ok) { const text = await response.text() - if (response.status === 403) throw new VoiceJoinMembershipError() throw new Error(`Token request failed (${response.status}): ${text}`) } @@ -118,10 +121,7 @@ export const deriveVoiceParticipants = (url: string, h: string) => deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), ], ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { - const inCall = - $participantPubkeyMap.size > 0 && - $currentVoiceRoom?.url === url && - $currentVoiceRoom?.h === h + const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) if (inCall) { const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) @@ -140,10 +140,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 +214,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") + currentVoiceRoom.set(get(deriveRoom(url, h))) + voiceState.set(VoiceState.Joining) const controller = new AbortController() joinAbortController = controller @@ -210,47 +238,42 @@ export const joinVoiceRoom = async (url: string, h: string): Promise => { if (signal.aborted) throw new AbortError() - const room = new Room({adaptiveStream: true, dynacast: true}) + const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true}) - room.on(RoomEvent.Disconnected, onRoomDisconnected) - room.on(RoomEvent.ParticipantConnected, onParticipantConnected) - room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) - room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) - room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) - room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected) + liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) + liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) + liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) + liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) try { await Promise.race([ - room.connect(server_url, participant_token, {maxRetries: 0}), + liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), whenTimeout(5_000, { message: "Connection timed out. Please check your network and try again.", }), whenAborted(signal), ]) } catch (e) { - room.disconnect() + liveKitRoom.disconnect() throw e } participantPubkeyMap.set(new Map()) - addParticipant(room.localParticipant.identity) - for (const p of room.remoteParticipants.values()) { + addParticipant(liveKitRoom.localParticipant.identity) + for (const p of liveKitRoom.remoteParticipants.values()) { 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, liveKitRoom.localParticipant) - currentVoiceSession.set({url, h, room, muted}) - voiceState.set("connected") + currentVoiceSession.set({url, h, room: liveKitRoom, muted}) + voiceState.set(VoiceState.Connected) playJoinSound() } catch (e) { - if (isActive()) voiceState.set("disconnected") + if (isActive()) voiceState.set(VoiceState.Disconnected) + if (e instanceof AbortError) return throw e } finally { if (isActive()) joinAbortController = undefined @@ -264,7 +287,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}