import {derived, get, writable} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import {SignJWT} from "jose" import {Room, RoomEvent, Track} from "livekit-client" import {now} from "@welshman/lib" import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util" import {pubkey, publishThunk} from "@welshman/app" import {deriveEventsForUrl, displayRoom} from "@app/core/state" export const LIVE_ACTIVITY = 30311 export const ROOM_PRESENCE = 10312 const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || "" const LIVEKIT_API_KEY = import.meta.env.VITE_LIVEKIT_API_KEY || "" const LIVEKIT_API_SECRET = import.meta.env.VITE_LIVEKIT_API_SECRET || "" 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(undefined) const makeLivekitRoomName = (url: string, h: string) => `${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_") const generateToken = async (roomName: string, identity: string) => { const secret = new TextEncoder().encode(LIVEKIT_API_SECRET) const jwt = await new SignJWT({ video: {roomJoin: true, room: roomName, canPublish: true, canSubscribe: true}, sub: identity, iss: LIVEKIT_API_KEY, jti: identity, }) .setProtectedHeader({alg: "HS256"}) .setIssuedAt() .setExpirationTime("6h") .sign(secret) return jwt } 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 const aTag = getTag("a", event.tags) if (!aTag) continue const [, , dTag] = aTag[1].split(":") if (dTag === h) { pubkeys.push(event.pubkey) } } return pubkeys }) const publishLiveActivity = (url: string, h: string, status: "live" | "ended") => { const pk = get(pubkey)! const title = displayRoom(url, h) const event = makeEvent(LIVE_ACTIVITY, { tags: [ ["d", h], ["h", h], ["title", title], ["service", LIVEKIT_URL], ["status", status], ["starts", String(now())], ["p", pk, "", "Host"], ], }) return publishThunk({event, relays: [url]}) } const publishPresence = (url: string, h: string) => { const pk = get(pubkey)! const aTag = `${LIVE_ACTIVITY}:${pk}:${h}` const event = makeEvent(ROOM_PRESENCE, { tags: [["a", aTag, url, "root"]], }) return publishThunk({event, relays: [url]}) } const deletePresence = (url: string) => { const event = makeEvent(ROOM_PRESENCE, {tags: []}) return publishThunk({event, relays: [url]}) } let presenceInterval: ReturnType | 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) => { const session = get(currentVoiceSession) if (session) { if (session.url === url && session.h === h) return await leaveVoiceRoom() } const pk = get(pubkey)! const identity = nip19.npubEncode(pk) const roomName = makeLivekitRoomName(url, h) const token = await generateToken(roomName, identity) const room = new Room({ adaptiveStream: true, dynacast: true, }) room.on(RoomEvent.Disconnected, () => { currentVoiceSession.set(undefined) stopPresenceHeartbeat() }) 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()) }) await room.connect(LIVEKIT_URL, token) await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) publishLiveActivity(url, h, "live") startPresenceHeartbeat(url, h) } export const leaveVoiceRoom = async () => { const session = get(currentVoiceSession) if (!session) return stopPresenceHeartbeat() session.room.disconnect() deletePresence(session.url) publishLiveActivity(session.url, session.h, "ended") 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}) } export const toggleDeafen = () => { const session = get(currentVoiceSession) if (!session) return for (const participant of session.room.remoteParticipants.values()) { for (const pub of participant.audioTrackPublications.values()) { pub.setEnabled(!pub.isEnabled) } } }