+
diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte
index 992f5142..d173498e 100644
--- a/src/app/components/SpaceMenuRoomItem.svelte
+++ b/src/app/components/SpaceMenuRoomItem.svelte
@@ -1,12 +1,13 @@
-
-
- {#if showDifferenceIcon}
-
- {/if}
-
+{#if roomType === RoomType.Voice}
+
+{:else}
+
+
+ {#if showDifferenceIcon}
+
+ {/if}
+
+{/if}
diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte
new file mode 100644
index 00000000..5676c314
--- /dev/null
+++ b/src/app/components/VoiceRoomItem.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {#if isJoining}
+
+ {:else}
+
+ {/if}
+
+
+ {#if $participants.length > 0}
+ {#each $participants as p (participantKey(p as VoiceParticipant))}
+
+
+
+ {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
+
+
+ {/each}
+ {/if}
+
+
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte
new file mode 100644
index 00000000..ae3f3eb5
--- /dev/null
+++ b/src/app/components/VoiceWidget.svelte
@@ -0,0 +1,79 @@
+
+
+{#if $currentVoiceRoom}
+
+
+ {#if $voiceState === "joining"}
+ Joining...
+ {:else if $voiceState === "connected"}
+ Voice Connected
+ {:else}
+ Disconnected
+ {/if}
+
+ {roomName} / {spaceName}
+
+
+
+ {#if $voiceState === "joining"}
+
+
+ {:else if $voiceState === "connected" && $currentVoiceSession}
+
+
+ {:else}
+
+ {/if}
+
+
+{/if}
diff --git a/src/app/core/state.ts b/src/app/core/state.ts
index 7e64a12f..04a7751b 100644
--- a/src/app/core/state.ts
+++ b/src/app/core/state.ts
@@ -125,6 +125,7 @@ import type {
RelayProfile,
PublishedList,
PublishedRoomMeta,
+ RoomMeta,
List,
Filter,
} from "@welshman/util"
@@ -146,6 +147,7 @@ import {
displayProfileByPubkey,
getProfile,
} from "@welshman/app"
+import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -567,11 +569,19 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
// Rooms
+export enum RoomType {
+ Text = "text",
+ Voice = "voice",
+}
+
export type Room = PublishedRoomMeta & {
id: string
url: string
}
+export const getRoomType = (room: RoomMeta): RoomType =>
+ room.livekit ? RoomType.Voice : RoomType.Text
+
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitRoomId = (id: string) => id.split("'")
@@ -663,6 +673,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
+export const deriveVoiceRooms = (url: string) =>
+ derived(roomsById, $roomsById => {
+ const set = new Set
()
+ for (const room of $roomsById.values()) {
+ if (room.url === url && room.livekit) {
+ set.add(room.h)
+ }
+ }
+ return set
+ })
+
+export const deriveOtherVoiceRooms = (url: string) =>
+ derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
+ const rooms: string[] = []
+
+ for (const h of $roomsWithLivekit) {
+ if (!$userRooms.includes(h)) {
+ rooms.push(h)
+ }
+ }
+
+ return sortBy(roomComparator(url), uniq(rooms))
+ })
+
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
@@ -752,17 +786,20 @@ export const deriveUserRooms = (url: string) =>
})
export const deriveOtherRooms = (url: string) =>
- derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => {
- const rooms: string[] = []
+ derived(
+ [deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
+ ([$userRooms, voiceRooms, $roomsByUrl]) => {
+ const rooms: string[] = []
- for (const {h} of $roomsByUrl.get(url) || []) {
- if (!$userRooms.includes(h)) {
- rooms.push(h)
+ for (const {h} of $roomsByUrl.get(url) || []) {
+ if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
+ rooms.push(h)
+ }
}
- }
- return sortBy(roomComparator(url), uniq(rooms))
- })
+ return sortBy(roomComparator(url), uniq(rooms))
+ },
+ )
// Space/room memberships
@@ -1165,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
})
})
+export const deriveHasLivekit = simpleCache(([url]: [string]) =>
+ readable(undefined, set => {
+ checkRelayHasLivekit(url).then(has => set(has))
+ }),
+)
+
export const deriveTimeout = (timeout: number) => {
const store = writable(false)
diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts
index 96eab24f..ebe46474 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 {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
})
}
+ pullAndListen({
+ url,
+ signal: controller.signal,
+ filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
+ })
+
return () => controller.abort()
}
diff --git a/src/app/voice.ts b/src/app/voice.ts
new file mode 100644
index 00000000..40aa962c
--- /dev/null
+++ b/src/app/voice.ts
@@ -0,0 +1,290 @@
+/**
+ * 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, RoomEvent, Track} 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} 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: Room
+ muted: boolean
+}
+
+export type Pubkey = string
+
+export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
+
+export type VoiceState = "joining" | "connected" | "disconnected"
+
+export const currentVoiceSession = writable(undefined)
+
+export const voiceState = writable("disconnected")
+
+export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
+
+export const participantPubkeyMap = writable