diff --git a/.env.template b/.env.template index 5067aedd..9d7cf686 100644 --- a/.env.template +++ b/.env.template @@ -18,5 +18,8 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y +VITE_LIVEKIT_URL= +VITE_LIVEKIT_API_KEY= +VITE_LIVEKIT_API_SECRET= VITE_GLITCHTIP_API_KEY= GLITCHTIP_AUTH_TOKEN= diff --git a/package.json b/package.json index 6e91a8fc..3f3b2cd4 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "fuse.js": "^7.1.0", "husky": "^9.1.7", "idb": "^8.0.3", + "jose": "^6.1.3", + "livekit-client": "^2.17.2", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-tools": "^2.19.4", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 028a3ad2..bdf0d005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,12 @@ importers: idb: specifier: ^8.0.3 version: 8.0.3 + jose: + specifier: ^6.1.3 + version: 6.1.3 + livekit-client: + specifier: ^2.17.2 + version: 2.17.2(@types/dom-mediacapture-record@1.0.22) nostr-signer-capacitor-plugin: specifier: github:coracle-social/nostr-signer-capacitor-plugin#main version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1) @@ -737,6 +743,9 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bufbuild/protobuf@1.10.1': + resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + '@canvas/image-data@1.1.0': resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} @@ -1283,6 +1292,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@livekit/mutex@1.1.1': + resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} + + '@livekit/protocol@1.44.0': + resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==} + '@noble/ciphers@0.5.3': resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} @@ -1823,6 +1838,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/dom-mediacapture-record@1.0.22': + resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} + '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} @@ -3334,6 +3352,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -3438,6 +3459,11 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + livekit-client@2.17.2: + resolution: {integrity: sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==} + peerDependencies: + '@types/dom-mediacapture-record': ^1 + load-json-file@4.0.0: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} @@ -3472,6 +3498,10 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4277,6 +4307,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4306,6 +4339,13 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + sdp-transform@2.15.0: + resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} + hasBin: true + + sdp@3.2.1: + resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4641,6 +4681,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-debounce@4.0.0: + resolution: {integrity: sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -4707,6 +4750,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + typescript-eslint@8.53.1: resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4851,6 +4897,10 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webrtc-adapter@9.0.4: + resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5737,6 +5787,8 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bufbuild/protobuf@1.10.1': {} + '@canvas/image-data@1.1.0': {} '@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)': @@ -6302,6 +6354,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@livekit/mutex@1.1.1': {} + + '@livekit/protocol@1.44.0': + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@noble/ciphers@0.5.3': {} '@noble/ciphers@1.3.0': {} @@ -6863,6 +6921,8 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/dom-mediacapture-record@1.0.22': {} + '@types/eslint@9.6.1': dependencies: '@types/estree': 1.0.8 @@ -8534,6 +8594,8 @@ snapshots: jiti@1.21.7: {} + jose@6.1.3: {} + js-base64@3.7.8: {} js-tokens@4.0.0: {} @@ -8609,6 +8671,20 @@ snapshots: linkifyjs@4.3.2: {} + livekit-client@2.17.2(@types/dom-mediacapture-record@1.0.22): + dependencies: + '@livekit/mutex': 1.1.1 + '@livekit/protocol': 1.44.0 + '@types/dom-mediacapture-record': 1.0.22 + events: 3.3.0 + jose: 6.1.3 + loglevel: 1.9.2 + sdp-transform: 2.15.0 + ts-debounce: 4.0.0 + tslib: 2.8.1 + typed-emitter: 2.1.0 + webrtc-adapter: 9.0.4 + load-json-file@4.0.0: dependencies: graceful-fs: 4.2.11 @@ -8641,6 +8717,8 @@ snapshots: lodash@4.17.23: {} + loglevel@1.9.2: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -9440,6 +9518,11 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + optional: true + sade@1.8.1: dependencies: mri: 1.2.0 @@ -9471,6 +9554,10 @@ snapshots: sax@1.4.4: {} + sdp-transform@2.15.0: {} + + sdp@3.2.1: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -9920,6 +10007,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-debounce@4.0.0: {} + ts-interface-checker@0.1.13: {} ts-node@10.9.2(@types/node@25.0.10)(typescript@5.9.3): @@ -9995,6 +10084,10 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typed-emitter@2.1.0: + optionalDependencies: + rxjs: 7.8.2 + typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -10103,6 +10196,10 @@ snapshots: webidl-conversions@4.0.2: {} + webrtc-adapter@9.0.4: + dependencies: + sdp: 3.2.1 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 540fa88c..086c6298 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -38,6 +38,8 @@ import SpaceReports from "@app/components/SpaceReports.svelte" import RoomCreate from "@app/components/RoomCreate.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" + import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte" + import VoiceWidget from "@app/components/VoiceWidget.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import { ENABLE_ZAPS, @@ -257,6 +259,7 @@ {/if} {#each $userRooms as h, i (h)} + {/each} {#if $otherRooms.length > 0}
@@ -276,6 +279,7 @@ {/if} {#each $roomSearch.searchValues(term) as h, i (h)} + {/each} {#if $canCreateRoom} @@ -286,7 +290,8 @@ {/if} -
+
+ diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte new file mode 100644 index 00000000..71dc7002 --- /dev/null +++ b/src/app/components/VoiceRoomItem.svelte @@ -0,0 +1,46 @@ + + +
+ + + + + {#if $participants.length > 0} +
+ {#each $participants as pk (pk)} +
+ + + {displayProfileByPubkey(pk)} + +
+ {/each} +
+ {/if} +
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte new file mode 100644 index 00000000..7d740394 --- /dev/null +++ b/src/app/components/VoiceWidget.svelte @@ -0,0 +1,39 @@ + + +{#if $currentVoiceSession} +
+
+ Voice Connected + + {roomName} / {spaceName} + +
+
+ + +
+
+{/if} diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 96eab24f..82ee874d 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -55,6 +55,7 @@ import { loadFeedsForPubkey, } from "@app/core/state" import {hasBlossomSupport} from "@app/core/commands" +import {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice" // Utils @@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => { }) } + pullAndListen({ + url, + signal: controller.signal, + filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}], + }) + return () => controller.abort() } diff --git a/src/app/voice.ts b/src/app/voice.ts new file mode 100644 index 00000000..9d9f7426 --- /dev/null +++ b/src/app/voice.ts @@ -0,0 +1,177 @@ +import {derived, get, writable} from "svelte/store" +import * as nip19 from "nostr-tools/nip19" +import {SignJWT} from "jose" +import {Room, RoomEvent} 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() + }) + + 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) + } + } +}