191 lines
5.1 KiB
TypeScript
191 lines
5.1 KiB
TypeScript
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<VoiceSession | undefined>(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<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) => {
|
|
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)
|
|
}
|
|
}
|
|
}
|