Show VoiceWidget when disconnected but still viewing room page.

This commit is contained in:
mplorentz
2026-03-16 09:23:54 -04:00
parent 00573580e4
commit b34f6b2754
5 changed files with 150 additions and 82 deletions
+3 -3
View File
@@ -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
</script>
<ImageIcon
{size}
alt=""
class={cx(props.class, "rounded-full")}
src={$profile?.picture || UserRounded} />
src={(profile ? $profile?.picture : undefined) || UserRounded} />
+16 -13
View File
@@ -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<AbortController | undefined>(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")}>
<div class="flex w-full min-w-0 flex-col gap-2">
<div class="flex gap-2 items-center">
{#if joinAbortController}
{#if isJoining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<RoomImage {url} {h} size={4} />
+52 -19
View File
@@ -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) : "")
</script>
{#if $currentVoiceSession}
{#if $currentVoiceRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-success">Voice Connected</span>
{#if $voiceState === "joining"}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === "connected"}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-1">
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
onclick={leaveVoiceRoom}>
<Icon icon={PhoneRounded} size={4} />
</Button>
{#if $voiceState === "joining"}
<span class="loading loading-spinner loading-sm"></span>
<Button
data-tip="Cancel"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $voiceState === "connected" && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
onclick={leaveVoiceRoom}>
<Icon icon={PhoneRounded} size={4} />
</Button>
{:else}
<Button
data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={rejoinVoiceRoom}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
</div>
</div>
{/if}
+74 -45
View File
@@ -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<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>("disconnected")
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(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<void> => {
const session = get(currentVoiceSession)
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
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
+5 -2
View File
@@ -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}
</div>
{#if $currentVoiceSession}
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget />
</div>