Show VoiceWidget when disconnected but still viewing room page.
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pubkey: string
|
pubkey?: string
|
||||||
class?: string
|
class?: string
|
||||||
size?: number
|
size?: number
|
||||||
url?: string
|
url?: string
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
|
|
||||||
const {pubkey, url, size = 7, ...props}: Props = $props()
|
const {pubkey, url, size = 7, ...props}: Props = $props()
|
||||||
|
|
||||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
const profile = pubkey ? deriveProfile(pubkey, removeUndefined([url])) : undefined
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ImageIcon
|
<ImageIcon
|
||||||
{size}
|
{size}
|
||||||
alt=""
|
alt=""
|
||||||
class={cx(props.class, "rounded-full")}
|
class={cx(props.class, "rounded-full")}
|
||||||
src={$profile?.picture || UserRounded} />
|
src={(profile ? $profile?.picture : undefined) || UserRounded} />
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
import {
|
import {
|
||||||
deriveVoiceParticipants,
|
deriveVoiceParticipants,
|
||||||
joinVoiceRoom,
|
joinVoiceRoom,
|
||||||
currentVoiceSession,
|
cancelJoinVoiceRoom,
|
||||||
|
currentVoiceRoom,
|
||||||
|
voiceState,
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
participantKey,
|
participantKey,
|
||||||
type VoiceParticipant,
|
type VoiceParticipant,
|
||||||
@@ -25,25 +27,26 @@
|
|||||||
const {url, h, replaceState = false}: Props = $props()
|
const {url, h, replaceState = false}: Props = $props()
|
||||||
|
|
||||||
const participants = deriveVoiceParticipants(url, h)
|
const participants = deriveVoiceParticipants(url, h)
|
||||||
const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h)
|
const isActive = $derived(
|
||||||
let joinAbortController = $state<AbortController | undefined>(undefined)
|
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||||
|
)
|
||||||
|
const isJoining = $derived(
|
||||||
|
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||||
|
)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
if (isActive) {
|
if (isActive) return
|
||||||
|
|
||||||
|
if (isJoining) {
|
||||||
|
cancelJoinVoiceRoom()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (joinAbortController) {
|
|
||||||
joinAbortController.abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
joinAbortController = new AbortController()
|
|
||||||
try {
|
try {
|
||||||
await joinVoiceRoom(url, h, joinAbortController.signal)
|
await joinVoiceRoom(url, h)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to join voice room", e)
|
console.error("Failed to join voice room", e)
|
||||||
pushToast({theme: "error", message: "Failed to join voice room"})
|
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")}>
|
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 w-full min-w-0 flex-col gap-2">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{#if joinAbortController}
|
{#if isJoining}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<RoomImage {url} {h} size={4} />
|
<RoomImage {url} {h} size={4} />
|
||||||
|
|||||||
@@ -4,43 +4,76 @@
|
|||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||||
import PhoneRounded from "@assets/icons/phone-rounded.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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {displayRoom} from "@app/core/state"
|
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(
|
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>
|
</script>
|
||||||
|
|
||||||
{#if $currentVoiceSession}
|
{#if $currentVoiceRoom}
|
||||||
<div
|
<div
|
||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<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">
|
<span class="ellipsize text-xs opacity-70">
|
||||||
{roomName} / {spaceName}
|
{roomName} / {spaceName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
{#if $voiceState === "joining"}
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
<Button
|
||||||
? 'btn-error'
|
data-tip="Cancel"
|
||||||
: 'btn-ghost'}"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
onclick={toggleMute}>
|
onclick={cancelJoinVoiceRoom}>
|
||||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
<Icon icon={CloseCircle} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{:else if $voiceState === "connected" && $currentVoiceSession}
|
||||||
data-tip="Leave room"
|
<Button
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||||
onclick={leaveVoiceRoom}>
|
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||||
<Icon icon={PhoneRounded} size={4} />
|
? 'btn-error'
|
||||||
</Button>
|
: '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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+74
-45
@@ -28,8 +28,14 @@ export type Pubkey = string
|
|||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
|
export type VoiceState = "joining" | "connected" | "disconnected"
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
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())
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
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 =>
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : 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())
|
participantPubkeyMap.set(new Map())
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
|
voiceState.set("disconnected")
|
||||||
const message =
|
const message =
|
||||||
reason === DisconnectReason.JOIN_FAILURE
|
reason === DisconnectReason.JOIN_FAILURE
|
||||||
? "Could not connect to voice room. Please try again."
|
? "Could not connect to voice room. Please try again."
|
||||||
@@ -172,59 +177,77 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const joinVoiceRoom = async (
|
let joinAbortController: AbortController | undefined
|
||||||
url: string,
|
|
||||||
h: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> => {
|
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
|
|
||||||
|
export const cancelJoinVoiceRoom = () => {
|
||||||
|
joinAbortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
|
||||||
|
cancelJoinVoiceRoom()
|
||||||
|
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
if (session) await leaveVoiceRoom()
|
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 controller = new AbortController()
|
||||||
|
joinAbortController = controller
|
||||||
const room = new Room({adaptiveStream: true, dynacast: true})
|
const signal = controller.signal
|
||||||
|
const isActive = () => joinAbortController === controller
|
||||||
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)
|
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
room.disconnect()
|
if (isActive()) voiceState.set("disconnected")
|
||||||
if (e instanceof AbortError) return
|
if (e instanceof AbortError) return
|
||||||
throw e
|
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 () => {
|
export const leaveVoiceRoom = async () => {
|
||||||
@@ -236,10 +259,16 @@ export const leaveVoiceRoom = async () => {
|
|||||||
|
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
|
voiceState.set("disconnected")
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const rejoinVoiceRoom = () => {
|
||||||
|
const target = get(currentVoiceRoom)
|
||||||
|
if (target) joinVoiceRoom(target.url, target.h)
|
||||||
|
}
|
||||||
|
|
||||||
export const toggleMute = async () => {
|
export const toggleMute = async () => {
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|||||||
@@ -42,13 +42,15 @@
|
|||||||
decodeRelay,
|
decodeRelay,
|
||||||
deriveRoom,
|
deriveRoom,
|
||||||
deriveUserRoomMembershipStatus,
|
deriveUserRoomMembershipStatus,
|
||||||
|
getRoomType,
|
||||||
MESSAGE_KINDS,
|
MESSAGE_KINDS,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
|
RoomType,
|
||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import {currentVoiceSession} from "@app/voice"
|
import {voiceState} from "@app/voice"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
import {checked} from "@app/util/notifications"
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
const lastChecked = $checked[$page.url.pathname]
|
const lastChecked = $checked[$page.url.pathname]
|
||||||
const url = decodeRelay(relay)
|
const url = decodeRelay(relay)
|
||||||
const room = deriveRoom(url, h)
|
const room = deriveRoom(url, h)
|
||||||
|
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||||
@@ -497,7 +500,7 @@
|
|||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $currentVoiceSession}
|
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
|
||||||
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user