Address remaining PR comments
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
import {isMobileViewport} from "@lib/html"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {
|
||||
deriveVoiceParticipants,
|
||||
@@ -46,10 +47,7 @@
|
||||
try {
|
||||
await joinVoiceRoom(url, h, joinAbortController.signal)
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Join cancelled") return
|
||||
if (e instanceof DOMException && e.name === "AbortError") return
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
pushToast({theme: "error", message: `Failed to join voice room: ${message}`})
|
||||
pushToast({theme: "error", message: `Failed to join voice room: ${errorMessage(e)}`})
|
||||
} finally {
|
||||
isJoining = false
|
||||
joinAbortController = undefined
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import {get} from "svelte/store"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||
@@ -17,10 +16,8 @@
|
||||
)
|
||||
const spaceName = $derived($currentVoiceSession ? displayRelayUrl($currentVoiceSession.url) : "")
|
||||
|
||||
const handleDisconnect = () => leaveVoiceRoom()
|
||||
const handleToggleMute = () => toggleMute()
|
||||
const showRoomDetail = () => {
|
||||
const session = get(currentVoiceSession)
|
||||
const session = $currentVoiceSession
|
||||
if (session) pushModal(RoomDetail, {url: session.url, h: session.h})
|
||||
}
|
||||
</script>
|
||||
@@ -41,10 +38,10 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
class="btn btn-sm btn-square {$currentVoiceSession.muted ? 'btn-error' : 'btn-ghost'}"
|
||||
onclick={handleToggleMute}>
|
||||
onclick={toggleMute}>
|
||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
||||
</Button>
|
||||
<Button class="btn btn-sm btn-square btn-error" onclick={handleDisconnect}>
|
||||
<Button class="btn btn-sm btn-square btn-error" onclick={leaveVoiceRoom}>
|
||||
<Icon icon={PhoneRounded} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,7 @@ import {
|
||||
getListTags,
|
||||
getPubkeyTagValues,
|
||||
getRelayTagValues,
|
||||
getTag,
|
||||
getTagValues,
|
||||
isRelayUrl,
|
||||
normalizeRelayUrl,
|
||||
@@ -668,7 +669,7 @@ export const deriveRoomsWithLivekit = (url: string) =>
|
||||
derived(roomsById, $roomsById => {
|
||||
const set = new Set<string>()
|
||||
for (const room of $roomsById.values()) {
|
||||
if (room.url === url && room.event?.tags?.some(t => t[0] === "livekit")) {
|
||||
if (room.url === url && getTag("livekit", room.event?.tags ?? [])) {
|
||||
set.add(room.h)
|
||||
}
|
||||
}
|
||||
@@ -679,7 +680,7 @@ export const deriveRoomsNoText = (url: string) =>
|
||||
derived(roomsById, $roomsById => {
|
||||
const set = new Set<string>()
|
||||
for (const room of $roomsById.values()) {
|
||||
if (room.url === url && room.event?.tags?.some(t => t[0] === "no-text")) {
|
||||
if (room.url === url && getTag("no-text", room.event?.tags ?? [])) {
|
||||
set.add(room.h)
|
||||
}
|
||||
}
|
||||
@@ -688,12 +689,12 @@ export const deriveRoomsNoText = (url: string) =>
|
||||
|
||||
export const roomHasLivekit = (url: string, h: string) => {
|
||||
const room = getRoom(makeRoomId(url, h))
|
||||
return room?.event?.tags?.some(t => t[0] === "livekit") ?? false
|
||||
return !!getTag("livekit", room?.event?.tags ?? [])
|
||||
}
|
||||
|
||||
export const roomIsNoText = (url: string, h: string) => {
|
||||
const room = getRoom(makeRoomId(url, h))
|
||||
return room?.event?.tags?.some(t => t[0] === "no-text") ?? false
|
||||
return !!getTag("no-text", room?.event?.tags ?? [])
|
||||
}
|
||||
|
||||
// User space/room lists
|
||||
|
||||
+57
-92
@@ -3,12 +3,12 @@
|
||||
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||
*/
|
||||
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
|
||||
import {getToken} from "nostr-tools/nip98"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {now} from "@welshman/lib"
|
||||
import {makeEvent, getTagValue} from "@welshman/util"
|
||||
import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util"
|
||||
import {signer, publishThunk} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
@@ -28,7 +28,7 @@ export type VoiceSession = {
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const speakingPubkeys = writable<Set<string>>(new Set())
|
||||
export const speakingPubkeys = writable(new Set<string>())
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
@@ -40,31 +40,16 @@ const fetchLivekitToken = async (
|
||||
const $signer = signer.get()
|
||||
if (!$signer) throw new Error("No signer available")
|
||||
|
||||
if (signal?.aborted) throw new Error("Join cancelled")
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
|
||||
const authHeader = await getToken(
|
||||
endpoint,
|
||||
"GET",
|
||||
template =>
|
||||
$signer.sign(
|
||||
makeEvent(template.kind, {
|
||||
tags: template.tags,
|
||||
content: template.content ?? "",
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
const template = await makeHttpAuth(endpoint, "GET")
|
||||
const signedEvent = await $signer.sign(template)
|
||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
headers: {Authorization: authHeader},
|
||||
signal,
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") throw new Error("Join cancelled")
|
||||
throw e
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: authHeader},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
@@ -119,6 +104,36 @@ const stopPresenceHeartbeat = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||
speakingPubkeys.set(new Set())
|
||||
currentVoiceSession.set(undefined)
|
||||
stopPresenceHeartbeat()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
const message =
|
||||
reason === DisconnectReason.JOIN_FAILURE
|
||||
? "Could not connect to voice room. Please try again."
|
||||
: "Voice connection lost."
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach()
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackUnsubscribed = (track: Track) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
}
|
||||
|
||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||
speakingPubkeys.set(new Set(participants.map(p => p.identity)))
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
@@ -126,83 +141,33 @@ export const joinVoiceRoom = async (
|
||||
): Promise<void> => {
|
||||
const session = get(currentVoiceSession)
|
||||
|
||||
if (session) {
|
||||
if (session.url === url && session.h === h) return
|
||||
await leaveVoiceRoom()
|
||||
}
|
||||
if (session) await leaveVoiceRoom()
|
||||
|
||||
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
||||
|
||||
if (signal?.aborted) throw new Error("Join cancelled")
|
||||
if (signal?.aborted) return
|
||||
|
||||
const room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
const room = new Room({adaptiveStream: true, dynacast: true})
|
||||
|
||||
room.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||
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.",
|
||||
})
|
||||
|
||||
room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
|
||||
speakingPubkeys.set(new Set())
|
||||
currentVoiceSession.set(undefined)
|
||||
stopPresenceHeartbeat()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
const message =
|
||||
reason === DisconnectReason.JOIN_FAILURE
|
||||
? "Could not connect to voice room. Please try again."
|
||||
: "Voice connection lost."
|
||||
pushToast({theme: "error", message})
|
||||
}
|
||||
})
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach()
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
room.on(RoomEvent.TrackUnsubscribed, track => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
})
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, participants => {
|
||||
speakingPubkeys.set(new Set(participants.map(p => p.identity)))
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
room.disconnect()
|
||||
}
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
room.disconnect()
|
||||
throw new Error("Join cancelled")
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, {once: true})
|
||||
}
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5_000
|
||||
const abort = whenAborted(signal)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
room.connect(server_url, participant_token, {maxRetries: 0}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Connection timed out. Please check your network and try again.")),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
])
|
||||
await Promise.race([connect, timeout, abort])
|
||||
} catch (e) {
|
||||
room.disconnect()
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Join cancelled")
|
||||
}
|
||||
if (e instanceof AbortError) return
|
||||
throw e
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
if (signal?.aborted) throw new Error("Join cancelled")
|
||||
|
||||
await room.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
currentVoiceSession.set({url, h, room, muted: false})
|
||||
|
||||
@@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
|
||||
|
||||
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted")
|
||||
this.name = "AbortError"
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message = "Timed out") {
|
||||
super(message)
|
||||
this.name = "TimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
|
||||
export const whenAborted = (signal?: AbortSignal) => {
|
||||
if (!signal) return new Promise<never>(() => {})
|
||||
|
||||
return new Promise<never>((_, reject) => {
|
||||
const onAborted = () => reject(new AbortError())
|
||||
if (signal.aborted) onAborted()
|
||||
else signal.addEventListener("abort", onAborted, {once: true})
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
|
||||
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
|
||||
return new Promise<never>((_, reject) => setTimeout(() => reject(new TimeoutError()), ms))
|
||||
}
|
||||
|
||||
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
|
||||
const url = new URL(base)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user