WIP voice channels
This commit is contained in:
@@ -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)}
|
||||
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
@@ -276,6 +279,7 @@
|
||||
{/if}
|
||||
{#each $roomSearch.searchValues(term) as h, i (h)}
|
||||
<SpaceMenuRoomItem {replaceState} {url} {h} />
|
||||
<VoiceRoomItem {url} {h} />
|
||||
{/each}
|
||||
{#if $canCreateRoom}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
@@ -286,7 +290,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
<div class="flex flex-col gap-2 pb-2 p-4 pt-0">
|
||||
<div class="flex flex-col gap-2 p-4 pb-2 pt-0">
|
||||
<VoiceWidget />
|
||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
|
||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {deriveVoiceParticipants, joinVoiceRoom, currentVoiceSession} from "@app/voice"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
h: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const participants = deriveVoiceParticipants(url, h)
|
||||
const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h)
|
||||
|
||||
const handleClick = () => joinVoiceRoom(url, h)
|
||||
|
||||
$effect(() => {
|
||||
for (const pk of $participants) {
|
||||
loadProfile(pk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<SecondaryNavItem onclick={handleClick} class={isActive ? "!bg-base-100 !text-base-content" : ""}>
|
||||
<Icon icon={Volume} size={4} class="opacity-70" />
|
||||
<RoomName {url} {h} />
|
||||
</SecondaryNavItem>
|
||||
{#if $participants.length > 0}
|
||||
<div class="flex flex-col gap-1 pb-1 pl-10">
|
||||
{#each $participants as pk (pk)}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircle pubkey={pk} size={5} class="h-5 w-5" />
|
||||
<span class="ellipsize text-xs opacity-70">
|
||||
{displayProfileByPubkey(pk)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
|
||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {displayRoom} from "@app/core/state"
|
||||
import {currentVoiceSession, leaveVoiceRoom, toggleMute} from "@app/voice"
|
||||
|
||||
const roomName = $derived(
|
||||
$currentVoiceSession ? displayRoom($currentVoiceSession.url, $currentVoiceSession.h) : "",
|
||||
)
|
||||
const spaceName = $derived($currentVoiceSession ? displayRelayUrl($currentVoiceSession.url) : "")
|
||||
|
||||
const handleDisconnect = () => leaveVoiceRoom()
|
||||
const handleToggleMute = () => toggleMute()
|
||||
</script>
|
||||
|
||||
{#if $currentVoiceSession}
|
||||
<div class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||
<span class="ellipsize text-xs opacity-70">
|
||||
{roomName} / {spaceName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
class="btn btn-sm btn-square {$currentVoiceSession.muted ? 'btn-error' : 'btn-ghost'}"
|
||||
onclick={handleToggleMute}>
|
||||
<Icon icon={$currentVoiceSession.muted ? VolumeCross : Microphone} size={4} />
|
||||
</Button>
|
||||
<Button class="btn btn-sm btn-square btn-error" onclick={handleDisconnect}>
|
||||
<Icon icon={PhoneRounded} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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()
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user