Files
flotilla/src/app/voice.ts
T

242 lines
6.3 KiB
TypeScript

/**
* 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 {getToken} from "nostr-tools/nip98"
import {derived, get, writable} from "svelte/store"
import {now} from "@welshman/lib"
import {makeEvent, getTagValue} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app"
import {deriveEventsForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const ROOM_PRESENCE = 10312
const livekitEndpoint = (url: string, groupId: string) => {
const httpUrl = url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
.replace(/\/$/, "")
return `${httpUrl}/.well-known/nip29/livekit/${groupId}`
}
export const checkRelayHasLivekit = async (url: string): Promise<boolean> => {
const endpoint = livekitEndpoint(url, "nop")
try {
// Currently we are hitting the API with no auth because zooid returns a 401 livekit
// is configured and 404 if it is not. But we need a standardized solution in the NIP.
const response = await fetch(endpoint)
return response.status === 401
} catch {
return false
}
}
const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300
export type VoiceSession = {
url: string
h: string
room: Room
muted: boolean
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = livekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new Error("Join cancelled")
const authHeader = await getToken(
endpoint,
"GET",
template =>
$signer.sign(
makeEvent(template.kind, {
tags: template.tags,
content: template.content ?? "",
}),
),
true,
)
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
}
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const deriveVoiceParticipants = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => {
const cutoff = now() - PRESENCE_EXPIRY_S
const pubkeys: string[] = []
for (const event of $events) {
if (event.created_at < cutoff) continue
if (getTagValue("h", event.tags) === h) {
pubkeys.push(event.pubkey)
}
}
return pubkeys
})
const publishPresence = (url: string, h: string) => {
const event = makeEvent(ROOM_PRESENCE, {
tags: [["h", h]],
})
return publishThunk({event, relays: [url]})
}
const deletePresence = (url: string) => {
const event = makeEvent(ROOM_PRESENCE, {tags: []})
return publishThunk({event, relays: [url]})
}
let presenceInterval: ReturnType<typeof setInterval> | undefined
const startPresenceHeartbeat = (url: string, h: string) => {
stopPresenceHeartbeat()
publishPresence(url, h)
presenceInterval = setInterval(() => publishPresence(url, h), PRESENCE_INTERVAL_MS)
}
const stopPresenceHeartbeat = () => {
if (presenceInterval) {
clearInterval(presenceInterval)
presenceInterval = undefined
}
}
export const joinVoiceRoom = async (
url: string,
h: string,
signal?: AbortSignal,
): Promise<void> => {
const session = get(currentVoiceSession)
if (session) {
if (session.url === url && session.h === h) return
await leaveVoiceRoom()
}
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
if (signal?.aborted) throw new Error("Join cancelled")
const room = new Room({
adaptiveStream: true,
dynacast: true,
})
room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
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())
})
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
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,
),
),
])
} catch (e) {
room.disconnect()
if (signal?.aborted) {
throw new Error("Join cancelled")
}
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})
startPresenceHeartbeat(url, h)
}
export const leaveVoiceRoom = async () => {
const session = get(currentVoiceSession)
if (!session) return
stopPresenceHeartbeat()
session.room.disconnect()
deletePresence(session.url)
currentVoiceSession.set(undefined)
}
export const toggleMute = () => {
const session = get(currentVoiceSession)
if (!session) return
const muted = !session.muted
session.room.localParticipant.setMicrophoneEnabled(!muted)
currentVoiceSession.set({...session, muted})
}