Add a dialog before joining voice rooms
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {goto} from "$app/navigation"
|
||||
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomImage from "@app/components/RoomImage.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {handleJoinError} from "@app/components/VoiceWidget.svelte"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||
import {
|
||||
VoiceState,
|
||||
deriveVoiceParticipants,
|
||||
joinVoiceRoom,
|
||||
cancelJoinVoiceRoom,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
@@ -28,21 +30,28 @@
|
||||
|
||||
const participants = deriveVoiceParticipants(url, h)
|
||||
const isActive = $derived(
|
||||
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||
$voiceState === VoiceState.Connected &&
|
||||
$currentVoiceRoom?.url === url &&
|
||||
$currentVoiceRoom?.h === h,
|
||||
)
|
||||
const isJoining = $derived(
|
||||
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
||||
$voiceState === VoiceState.Joining &&
|
||||
$currentVoiceRoom?.url === url &&
|
||||
$currentVoiceRoom?.h === h,
|
||||
)
|
||||
|
||||
const handleClick = async () => {
|
||||
const handleClick = async (e: MouseEvent) => {
|
||||
if (isActive) return
|
||||
|
||||
if (isJoining) {
|
||||
e.preventDefault()
|
||||
cancelJoinVoiceRoom()
|
||||
return
|
||||
}
|
||||
|
||||
await joinVoiceRoom(url, h).catch(handleJoinError)
|
||||
e.preventDefault()
|
||||
await goto(makeRoomPath(url, h), {replaceState})
|
||||
pushModal(VoiceRoomJoinDialog, {url, h})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {deriveRoom} from "@app/core/state"
|
||||
import {joinVoiceRoom} from "@app/voice"
|
||||
import {popModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const spaceLabel = $derived(displayRelayUrl(url))
|
||||
|
||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||
let selectedDeviceId = $state("")
|
||||
let startWithoutMic = $state(false)
|
||||
|
||||
const loadDevices = async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||
} catch {
|
||||
audioInputs = []
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadDevices()
|
||||
})
|
||||
|
||||
const goBack = () => history.back()
|
||||
|
||||
const joinVoice = async () => {
|
||||
popModal()
|
||||
await joinVoiceRoom(
|
||||
url,
|
||||
h,
|
||||
startWithoutMic,
|
||||
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Join voice room?</ModalTitle>
|
||||
<ModalSubtitle>
|
||||
<span class="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
|
||||
<Icon icon={Volume} size={4} class="shrink-0" />
|
||||
<span class="ellipsize min-w-0">{$room?.name || h}</span>
|
||||
<span>·</span>
|
||||
<span>{spaceLabel}</span>
|
||||
</span>
|
||||
</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<p class="text-sm opacity-80">Select a microphone to join the call.</p>
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="voice-start-without-mic"
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
bind:checked={startWithoutMic} />
|
||||
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
|
||||
Join without microphone (you can unmute later)
|
||||
</label>
|
||||
</div>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Microphone</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selectedDeviceId}
|
||||
disabled={startWithoutMic}
|
||||
aria-label="Microphone">
|
||||
<option value="">Default microphone</option>
|
||||
{#each audioInputs as d (d.deviceId)}
|
||||
<option value={d.deviceId}>
|
||||
{d.label || `Microphone ${d.deviceId.slice(0, 8)}…`}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={goBack}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Don't join
|
||||
</Button>
|
||||
<Button class="btn btn-primary" onclick={joinVoice}>
|
||||
Join voice
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,22 +1,7 @@
|
||||
<script module lang="ts">
|
||||
import {AbortError, TimeoutError} from "$lib/util"
|
||||
import {VoiceJoinMembershipError} from "@app/voice"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
export function 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})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {derived, get} from "svelte/store"
|
||||
import {fly} from "svelte/transition"
|
||||
import {goto} from "$app/navigation"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||
@@ -25,36 +10,59 @@
|
||||
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 VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||
import {deriveDisplayedRoom, displayRoom, getRoomType, RoomType} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {
|
||||
VoiceState,
|
||||
currentVoiceSession,
|
||||
currentVoiceRoom,
|
||||
voiceState,
|
||||
leaveVoiceRoom,
|
||||
toggleMute,
|
||||
rejoinVoiceRoom,
|
||||
cancelJoinVoiceRoom,
|
||||
} from "@app/voice"
|
||||
|
||||
const roomName = $derived(
|
||||
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
|
||||
const targetRoom = derived(
|
||||
[voiceState, currentVoiceRoom, deriveDisplayedRoom],
|
||||
([voiceState, currentVoiceRoom, routeDisplayedRoom]) => {
|
||||
if (voiceState === VoiceState.Joining || voiceState === VoiceState.Connected) {
|
||||
return currentVoiceRoom
|
||||
}
|
||||
if (voiceState === VoiceState.Disconnected) {
|
||||
if (routeDisplayedRoom) {
|
||||
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
|
||||
return {url: routeDisplayedRoom.url, h: routeDisplayedRoom.h}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return currentVoiceRoom
|
||||
}
|
||||
return currentVoiceRoom
|
||||
},
|
||||
)
|
||||
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
|
||||
|
||||
const handleRejoin = () => {
|
||||
void rejoinVoiceRoom().catch(handleJoinError)
|
||||
const roomName = $derived($targetRoom ? displayRoom($targetRoom.url, $targetRoom.h) : "")
|
||||
const spaceName = $derived($targetRoom ? displayRelayUrl($targetRoom.url) : "")
|
||||
|
||||
const openJoinDialog = async () => {
|
||||
const target = get(targetRoom)
|
||||
if (!target) return
|
||||
await goto(makeRoomPath(target.url, target.h))
|
||||
pushModal(VoiceRoomJoinDialog, {url: target.url, h: target.h})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $currentVoiceRoom}
|
||||
{#if $targetRoom}
|
||||
<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">
|
||||
{#if $voiceState === "joining"}
|
||||
{#if $voiceState === VoiceState.Joining}
|
||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||
{:else if $voiceState === "connected"}
|
||||
{:else if $voiceState === 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>
|
||||
@@ -64,7 +72,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if $voiceState === "joining"}
|
||||
{#if $voiceState === VoiceState.Joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<Button
|
||||
data-tip="Cancel"
|
||||
@@ -72,7 +80,7 @@
|
||||
onclick={cancelJoinVoiceRoom}>
|
||||
<Icon icon={CloseCircle} size={4} />
|
||||
</Button>
|
||||
{:else if $voiceState === "connected" && $currentVoiceSession}
|
||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||
<Button
|
||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||
@@ -91,7 +99,7 @@
|
||||
<Button
|
||||
data-tip="Join Voice"
|
||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
|
||||
onclick={handleRejoin}>
|
||||
onclick={openJoinDialog}>
|
||||
<Icon icon={PhoneCallingRounded} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
+24
-1
@@ -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) =>
|
||||
|
||||
+67
-22
@@ -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<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const voiceState = writable<VoiceState>("disconnected")
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
|
||||
export const currentVoiceRoom = writable<RoomRef | undefined>(undefined)
|
||||
|
||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||
|
||||
@@ -140,10 +162,33 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
},
|
||||
)
|
||||
|
||||
const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
): Promise<boolean> => {
|
||||
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<void> => {
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
startMuted = true,
|
||||
preferredMicId?: string,
|
||||
): Promise<void> => {
|
||||
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<void> => {
|
||||
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([])
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
|
||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
||||
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
||||
<VoiceWidget />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user