forked from coracle/flotilla
242 lines
6.3 KiB
TypeScript
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})
|
|
}
|