From b34f6b27547e88c8ee2a05232220f178435ffb69 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 16 Mar 2026 09:23:54 -0400 Subject: [PATCH] Show VoiceWidget when disconnected but still viewing room page. --- src/app/components/ProfileCircle.svelte | 6 +- src/app/components/VoiceRoomItem.svelte | 29 ++--- src/app/components/VoiceWidget.svelte | 71 ++++++++---- src/app/voice.ts | 119 +++++++++++++-------- src/routes/spaces/[relay]/[h]/+page.svelte | 7 +- 5 files changed, 150 insertions(+), 82 deletions(-) diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index 255cbffe..abf970c6 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -6,7 +6,7 @@ import ImageIcon from "@lib/components/ImageIcon.svelte" type Props = { - pubkey: string + pubkey?: string class?: string size?: number url?: string @@ -14,11 +14,11 @@ const {pubkey, url, size = 7, ...props}: Props = $props() - const profile = deriveProfile(pubkey, removeUndefined([url])) + const profile = pubkey ? deriveProfile(pubkey, removeUndefined([url])) : undefined + src={(profile ? $profile?.picture : undefined) || UserRounded} /> diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index d24fef95..5676c314 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -10,7 +10,9 @@ import { deriveVoiceParticipants, joinVoiceRoom, - currentVoiceSession, + cancelJoinVoiceRoom, + currentVoiceRoom, + voiceState, isParticipantSpeaking, participantKey, type VoiceParticipant, @@ -25,25 +27,26 @@ const {url, h, replaceState = false}: Props = $props() const participants = deriveVoiceParticipants(url, h) - const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) - let joinAbortController = $state(undefined) + const isActive = $derived( + $voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, + ) + const isJoining = $derived( + $voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, + ) const handleClick = async () => { - if (isActive) { + if (isActive) return + + if (isJoining) { + cancelJoinVoiceRoom() return } - if (joinAbortController) { - joinAbortController.abort() - return - } - joinAbortController = new AbortController() + try { - await joinVoiceRoom(url, h, joinAbortController.signal) + await joinVoiceRoom(url, h) } catch (e) { console.error("Failed to join voice room", e) pushToast({theme: "error", message: "Failed to join voice room"}) - } finally { - joinAbortController = undefined } } @@ -61,7 +64,7 @@ class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
- {#if joinAbortController} + {#if isJoining} {:else} diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 11acfbb3..ae3f3eb5 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -4,43 +4,76 @@ import Microphone from "@assets/icons/microphone.svg?dataurl" import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" + import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl" + import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import {displayRoom} from "@app/core/state" - import {currentVoiceSession, leaveVoiceRoom, toggleMute} from "@app/voice" + import { + currentVoiceSession, + currentVoiceRoom, + voiceState, + leaveVoiceRoom, + toggleMute, + rejoinVoiceRoom, + cancelJoinVoiceRoom, + } from "@app/voice" const roomName = $derived( - $currentVoiceSession ? displayRoom($currentVoiceSession.url, $currentVoiceSession.h) : "", + $currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "", ) - const spaceName = $derived($currentVoiceSession ? displayRelayUrl($currentVoiceSession.url) : "") + const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "") -{#if $currentVoiceSession} +{#if $currentVoiceRoom}
- Voice Connected + {#if $voiceState === "joining"} + Joining... + {:else if $voiceState === "connected"} + Voice Connected + {:else} + Disconnected + {/if} {roomName} / {spaceName}
- - + {#if $voiceState === "joining"} + + + {:else if $voiceState === "connected" && $currentVoiceSession} + + + {:else} + + {/if}
{/if} diff --git a/src/app/voice.ts b/src/app/voice.ts index 3b2893df..40aa962c 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -28,8 +28,14 @@ export type Pubkey = string export type VoiceParticipant = {pubkey?: Pubkey; identity: string} +export type VoiceState = "joining" | "connected" | "disconnected" + export const currentVoiceSession = writable(undefined) +export const voiceState = writable("disconnected") + +export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined) + export const participantPubkeyMap = writable>(new Map()) const addParticipant = (identity: string) => { @@ -48,8 +54,6 @@ const deleteParticipant = (identity: string) => { }) } -const currentVoiceRoom = derived(currentVoiceSession, s => (s ? {url: s.url, h: s.h} : undefined)) - export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined => /^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined @@ -133,6 +137,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => { participantPubkeyMap.set(new Map()) currentVoiceSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + voiceState.set("disconnected") const message = reason === DisconnectReason.JOIN_FAILURE ? "Could not connect to voice room. Please try again." @@ -172,59 +177,77 @@ const onParticipantDisconnected = (participant: {identity: string}) => { deleteParticipant(participant.identity) } -export const joinVoiceRoom = async ( - url: string, - h: string, - signal?: AbortSignal, -): Promise => { - const session = get(currentVoiceSession) +let joinAbortController: AbortController | undefined +export const cancelJoinVoiceRoom = () => { + joinAbortController?.abort() +} + +export const joinVoiceRoom = async (url: string, h: string): Promise => { + cancelJoinVoiceRoom() + + const session = get(currentVoiceSession) if (session) await leaveVoiceRoom() - const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + currentVoiceRoom.set({url, h}) + voiceState.set("joining") - if (signal?.aborted) return - - const room = new Room({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) - - 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.", - }) - const abort = whenAborted(signal) + const controller = new AbortController() + joinAbortController = controller + const signal = controller.signal + const isActive = () => joinAbortController === controller try { - await Promise.race([connect, timeout, abort]) + const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + + if (signal.aborted) throw new AbortError() + + const room = new Room({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) + + try { + await Promise.race([ + room.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() + throw e + } + + participantPubkeyMap.set(new Map()) + addParticipant(room.localParticipant.identity) + for (const p of room.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"}) + } + + currentVoiceSession.set({url, h, room, muted}) + voiceState.set("connected") + playJoinSound() } catch (e) { - room.disconnect() + if (isActive()) voiceState.set("disconnected") if (e instanceof AbortError) return throw e + } finally { + if (isActive()) joinAbortController = undefined } - - participantPubkeyMap.set(new Map()) - addParticipant(room.localParticipant.identity) - for (const p of room.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"}) - } - - currentVoiceSession.set({url, h, room, muted}) - - playJoinSound() } export const leaveVoiceRoom = async () => { @@ -236,10 +259,16 @@ export const leaveVoiceRoom = async () => { speakingParticipants.set([]) participantPubkeyMap.set(new Map()) + voiceState.set("disconnected") session.room.disconnect() currentVoiceSession.set(undefined) } +export const rejoinVoiceRoom = () => { + const target = get(currentVoiceRoom) + if (target) joinVoiceRoom(target.url, target.h) +} + export const toggleMute = async () => { const session = get(currentVoiceSession) if (!session) return diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index fde2bcfe..6fc79453 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -42,13 +42,15 @@ decodeRelay, deriveRoom, deriveUserRoomMembershipStatus, + getRoomType, MESSAGE_KINDS, MembershipStatus, PROTECTED, + RoomType, userSettingsValues, } from "@app/core/state" import VoiceWidget from "@app/components/VoiceWidget.svelte" - import {currentVoiceSession} from "@app/voice" + import {voiceState} from "@app/voice" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {checked} from "@app/util/notifications" @@ -60,6 +62,7 @@ const lastChecked = $checked[$page.url.pathname] const url = decodeRelay(relay) const room = deriveRoom(url, h) + const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice) const shouldProtect = canEnforceNip70(url) const membershipStatus = deriveUserRoomMembershipStatus(url, h) const at = $derived(parseInt($page.url.searchParams.get("at")!)) @@ -497,7 +500,7 @@ {/key} {/if}
- {#if $currentVoiceSession} + {#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}