forked from coracle/flotilla
16a73f27c9
After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat. This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP.  Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social> Reviewed-on: coracle/flotilla#109 Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
322 lines
9.6 KiB
TypeScript
322 lines
9.6 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 as LiveKitRoom,
|
|
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, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
|
import {pushToast} from "@app/util/toast"
|
|
|
|
export const LIVEKIT_PARTICIPANTS = 39004
|
|
|
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
|
|
|
export type VoiceSession = {
|
|
url: string
|
|
h: string
|
|
room: LiveKitRoom
|
|
muted: boolean
|
|
}
|
|
|
|
export type Pubkey = string
|
|
|
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
|
|
|
export enum VoiceState {
|
|
Joining = "joining",
|
|
Connected = "connected",
|
|
Disconnected = "disconnected",
|
|
}
|
|
|
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
|
|
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|
|
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
|
|
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
|
|
|
const addParticipant = (identity: string) => {
|
|
participantPubkeyMap.update(m => {
|
|
const next = new Map(m)
|
|
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
|
|
return next
|
|
})
|
|
}
|
|
|
|
const deleteParticipant = (identity: string) => {
|
|
participantPubkeyMap.update(m => {
|
|
const next = new Map(m)
|
|
next.delete(identity)
|
|
return next
|
|
})
|
|
}
|
|
|
|
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
|
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
|
|
|
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
return pk ? {pubkey: pk, identity} : {identity}
|
|
}
|
|
|
|
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
|
|
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
|
|
export const isParticipantSpeaking = derived(
|
|
speakingParticipants,
|
|
$participants => (p: VoiceParticipant) =>
|
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
)
|
|
|
|
const fetchLivekitToken = async (
|
|
url: string,
|
|
groupId: string,
|
|
signal?: AbortSignal,
|
|
): Promise<{server_url: string; participant_token: string}> => {
|
|
const endpoint = getLivekitEndpoint(url, groupId)
|
|
|
|
const $signer = signer.get()
|
|
if (!$signer) throw new Error("No signer available")
|
|
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
|
|
const template = await makeHttpAuth(endpoint, "GET")
|
|
const signedEvent = await $signer.sign(template)
|
|
const authHeader = makeHttpAuthHeader(signedEvent)
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: {Authorization: authHeader},
|
|
signal,
|
|
})
|
|
|
|
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) =>
|
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
|
derived(
|
|
[
|
|
participantPubkeyMap,
|
|
currentVoiceRoom,
|
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
|
],
|
|
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
|
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
|
|
|
if (inCall) {
|
|
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
|
} else {
|
|
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
|
if (!latestEvent) return []
|
|
const participants = removeUndefined(
|
|
map(
|
|
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
|
|
getTags("participant", latestEvent.tags),
|
|
),
|
|
)
|
|
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
|
}
|
|
},
|
|
)
|
|
|
|
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(VoiceState.Disconnected)
|
|
const message =
|
|
reason === DisconnectReason.JOIN_FAILURE
|
|
? "Could not connect to voice room. Please try again."
|
|
: "Voice connection lost."
|
|
pushToast({theme: "error", message})
|
|
}
|
|
speakingParticipants.set([])
|
|
participantPubkeyMap.set(new Map())
|
|
}
|
|
|
|
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}[]) => {
|
|
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
|
}
|
|
|
|
const playJoinSound = () => {
|
|
const audio = new Audio("/join-voice-room.mp3")
|
|
audio.play().catch(() => {})
|
|
}
|
|
|
|
const onParticipantConnected = (participant: {identity: string}) => {
|
|
addParticipant(participant.identity)
|
|
playJoinSound()
|
|
}
|
|
|
|
const onParticipantDisconnected = (participant: {identity: string}) => {
|
|
deleteParticipant(participant.identity)
|
|
}
|
|
|
|
let joinAbortController: AbortController | undefined
|
|
|
|
export const cancelJoinVoiceRoom = () => {
|
|
joinAbortController?.abort()
|
|
}
|
|
|
|
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(get(deriveRoom(url, h)))
|
|
voiceState.set(VoiceState.Joining)
|
|
|
|
const controller = new AbortController()
|
|
joinAbortController = controller
|
|
const signal = controller.signal
|
|
const isActive = () => joinAbortController === controller
|
|
|
|
try {
|
|
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
|
|
|
|
if (signal.aborted) throw new AbortError()
|
|
|
|
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
|
|
|
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
|
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
|
|
try {
|
|
await Promise.race([
|
|
liveKitRoom.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) {
|
|
liveKitRoom.disconnect()
|
|
throw e
|
|
}
|
|
|
|
participantPubkeyMap.set(new Map())
|
|
addParticipant(liveKitRoom.localParticipant.identity)
|
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
|
addParticipant(p.identity)
|
|
}
|
|
|
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
|
|
|
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
|
voiceState.set(VoiceState.Connected)
|
|
playJoinSound()
|
|
} catch (e) {
|
|
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
|
if (e instanceof AbortError) return
|
|
throw e
|
|
} finally {
|
|
if (isActive()) joinAbortController = undefined
|
|
}
|
|
}
|
|
|
|
export const leaveVoiceRoom = async () => {
|
|
const session = get(currentVoiceSession)
|
|
if (!session) return
|
|
|
|
const audio = new Audio("/leave-voice-room.mp3")
|
|
audio.play().catch(() => {})
|
|
|
|
voiceState.set(VoiceState.Disconnected)
|
|
currentVoiceSession.set(undefined)
|
|
session.room.disconnect()
|
|
speakingParticipants.set([])
|
|
participantPubkeyMap.set(new Map())
|
|
}
|
|
|
|
export const rejoinVoiceRoom = async (): Promise<void> => {
|
|
const target = get(currentVoiceRoom)
|
|
if (!target) return
|
|
return joinVoiceRoom(target.url, target.h)
|
|
}
|
|
|
|
export const toggleMute = async () => {
|
|
const session = get(currentVoiceSession)
|
|
if (!session) return
|
|
|
|
const muted = !session.muted
|
|
if (muted) {
|
|
// Disable and re-enable microphone to trigger permission prompt
|
|
session.room.localParticipant.setMicrophoneEnabled(false)
|
|
currentVoiceSession.set({...session, muted})
|
|
return
|
|
}
|
|
|
|
try {
|
|
await session.room.localParticipant.setMicrophoneEnabled(true)
|
|
currentVoiceSession.set({...session, muted})
|
|
} catch (e) {
|
|
pushToast({theme: "error", message: "Could not access microphone"})
|
|
}
|
|
}
|