Factor video code and stores out of voice.ts

This commit is contained in:
mplorentz
2026-04-08 10:30:22 -04:00
committed by hodlbod
parent 3cff8271e6
commit 11fa8fd720
12 changed files with 223 additions and 214 deletions
+58
View File
@@ -0,0 +1,58 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import {type Room} from "@app/core/state"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
/** True when the local user is in LiveKits active-speakers list (currently talking). */
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
+105
View File
@@ -0,0 +1,105 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/util/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
if (!cameraOn) {
session.room.localParticipant.setCameraEnabled(false)
currentVoiceSession.set({...session, cameraOn})
return
}
try {
await session.room.localParticipant.setCameraEnabled(true)
currentVoiceSession.set({...session, cameraOn})
} catch (e) {
pushToast({theme: "error", message: "Could not access camera"})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
if (!screenShareOn) {
session.room.localParticipant.setScreenShareEnabled(false)
currentVoiceSession.set({...session, screenShareOn})
return
}
try {
await session.room.localParticipant.setScreenShareEnabled(true)
currentVoiceSession.set({...session, screenShareOn})
} catch (e) {
pushToast({theme: "error", message: "Could not start screen sharing"})
}
}
+17 -149
View File
@@ -12,14 +12,27 @@ import {
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
} from "livekit-client" } from "livekit-client"
import {derived, get, writable} from "svelte/store" import {derived, get} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib" import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state" import {
currentVoiceRoom,
currentVoiceSession,
participantFromLiveKitIdentity,
participantKey,
participantPubkeyMap,
pubkeyFromLiveKitIdentity,
speakingParticipants,
VoiceState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl, deriveRoom, makeRoomId} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
@@ -28,27 +41,6 @@ export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection} export {supportsAudioOutputSelection}
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
const LIVEKIT_DEFAULT_DEVICE_ID = "default" const LIVEKIT_DEFAULT_DEVICE_ID = "default"
export enum DeviceKind { export enum DeviceKind {
@@ -83,36 +75,6 @@ export const switchVoiceActiveDevice = async (
} }
} }
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
/** Chat-only, full-width video, or split (desktop). On narrow viewports, `split` shows as chat until resize remaps it. */
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
const resetVideoCallLayout = () => {
videoCallLayout.set(VideoCallLayout.Chat)
}
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Spotlight tile id — must match VideoCallContent `tileKey` (identity + source, not trackSid). */
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const triggerVideoTileCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
const addParticipant = (identity: string) => { const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => { participantPubkeyMap.update(m => {
const next = new Map(m) const next = new Map(m)
@@ -129,34 +91,6 @@ const deleteParticipant = (identity: string) => {
}) })
} }
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
/** True when the local user is in LiveKits active-speakers list (currently talking). */
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
const fetchLivekitToken = async ( const fetchLivekitToken = async (
url: string, url: string,
groupId: string, groupId: string,
@@ -260,14 +194,14 @@ const onTrackSubscribed = (track: Track) => {
document.body.appendChild(element) document.body.appendChild(element)
element.play().catch(() => {}) element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) { } else if (track.kind === Track.Kind.Video) {
triggerVideoTileCount() triggerVideoFeedCount()
} }
} }
const onTrackUnsubscribed = (track: Track) => { const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove()) track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) { if (track.kind === Track.Kind.Video) {
triggerVideoTileCount() triggerVideoFeedCount()
} }
} }
@@ -437,69 +371,3 @@ export const toggleMute = async () => {
pushToast({theme: "error", message: "Could not access microphone"}) pushToast({theme: "error", message: "Could not access microphone"})
} }
} }
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
if (!cameraOn) {
session.room.localParticipant.setCameraEnabled(false)
currentVoiceSession.set({...session, cameraOn})
return
}
try {
await session.room.localParticipant.setCameraEnabled(true)
currentVoiceSession.set({...session, cameraOn})
} catch (e) {
pushToast({theme: "error", message: "Could not access camera"})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
if (!screenShareOn) {
session.room.localParticipant.setScreenShareEnabled(false)
currentVoiceSession.set({...session, screenShareOn})
return
}
try {
await session.room.localParticipant.setScreenShareEnabled(true)
currentVoiceSession.set({...session, screenShareOn})
} catch (e) {
pushToast({theme: "error", message: "Could not start screen sharing"})
}
}
+25 -5
View File
@@ -8,14 +8,17 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte" import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {get} from "svelte/store"
import { import {
currentVoiceSession,
currentVoiceRoom,
VideoCallLayout, VideoCallLayout,
videoPrimaryTileKey, isDesktopLayout,
toggleVideoPrimaryTile, toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity, videoCallLayout,
} from "@app/voice" videoCallViewportSync,
ViewportSize,
videoPrimaryTileKey,
} from "@app/call/video"
import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores"
type Props = { type Props = {
layout: VideoCallLayout layout: VideoCallLayout
@@ -37,6 +40,23 @@
const {layout, mobile = false, url, h, class: className = ""}: Props = $props() const {layout, mobile = false, url, h, class: className = ""}: Props = $props()
$effect(() => {
const currentLayout = isDesktopLayout.current ? ViewportSize.Desktop : ViewportSize.Mobile
const {previousLayout} = videoCallViewportSync
if (previousLayout === undefined) {
videoCallViewportSync.previousLayout = currentLayout
return
}
if (previousLayout === currentLayout) return
const p = get(videoCallLayout)
if (previousLayout === ViewportSize.Desktop && currentLayout === ViewportSize.Mobile) {
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
} else if (previousLayout === ViewportSize.Mobile && currentLayout === ViewportSize.Desktop) {
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
}
videoCallViewportSync.previousLayout = currentLayout
})
const isViewingCurrentCallRoom = $derived( const isViewingCurrentCallRoom = $derived(
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
) )
@@ -1,28 +0,0 @@
<script module lang="ts">
import {MediaQuery} from "svelte/reactivity"
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
</script>
<script lang="ts">
import {get} from "svelte/store"
import {VideoCallLayout, videoCallLayout} from "@app/voice"
let prevLayout = $state<boolean | undefined>(undefined)
$effect(() => {
const currentLayout = isDesktopLayout.current
if (prevLayout === undefined) {
prevLayout = currentLayout
return
}
if (prevLayout === currentLayout) return
const p = get(videoCallLayout)
if (prevLayout && !currentLayout) {
if (p === VideoCallLayout.Split) videoCallLayout.set(VideoCallLayout.Video)
} else if (!prevLayout && currentLayout) {
if (p === VideoCallLayout.Chat) videoCallLayout.set(VideoCallLayout.Split)
}
prevLayout = currentLayout
})
</script>
@@ -7,13 +7,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import { import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
currentVoiceSession, import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
+3 -4
View File
@@ -12,14 +12,13 @@
import {makeRoomId} from "@app/core/state" import {makeRoomId} from "@app/core/state"
import { import {
VoiceState, VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom, currentVoiceRoom,
voiceState,
isParticipantSpeaking, isParticipantSpeaking,
participantKey, participantKey,
voiceState,
type VoiceParticipant, type VoiceParticipant,
} from "@app/voice" } from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
interface Props { interface Props {
url: string url: string
@@ -14,7 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util" import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state" import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice" import {joinVoiceRoom} from "@app/call/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
+9 -9
View File
@@ -19,7 +19,6 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {isDesktopLayout} from "@app/components/VideoCallLayoutViewportSync.svelte"
import { import {
decodeRelay, decodeRelay,
deriveRoom, deriveRoom,
@@ -32,19 +31,20 @@
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import { import {
VoiceState,
VideoCallLayout, VideoCallLayout,
isDesktopLayout,
toggleCamera,
toggleScreenShare,
videoCallLayout,
} from "@app/call/video"
import {
VoiceState,
currentVoiceSession, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
videoCallLayout,
isLocalSpeaking, isLocalSpeaking,
leaveVoiceRoom, } from "@app/call/stores"
toggleMute, import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
toggleCamera,
toggleScreenShare,
cancelJoinVoiceRoom,
} from "@app/voice"
const {relay, h} = $derived($page.params) const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined) const url = $derived(relay ? decodeRelay(relay) : undefined)
+1 -1
View File
@@ -55,7 +55,7 @@ import {
loadFeedsForPubkey, loadFeedsForPubkey,
} from "@app/core/state" } from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
// Utils // Utils
-2
View File
@@ -42,7 +42,6 @@
import {syncKeyboard} from "@app/util/keyboard" import {syncKeyboard} from "@app/util/keyboard"
import {getPageTitle} from "@app/util/title" import {getPageTitle} from "@app/util/title"
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
import VideoCallLayoutViewportSync from "@app/components/VideoCallLayoutViewportSync.svelte"
const {children} = $props() const {children} = $props()
@@ -230,6 +229,5 @@
<ModalContainer /> <ModalContainer />
<div class="tippy-target"></div> <div class="tippy-target"></div>
<NewNotificationSound /> <NewNotificationSound />
<VideoCallLayoutViewportSync />
</div> </div>
{/await} {/await}
+2 -8
View File
@@ -45,14 +45,8 @@
} from "@app/core/state" } from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte" import VideoCallContent from "@app/components/VideoCallContent.svelte"
import { import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores"
VoiceState, import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
VideoCallLayout,
currentVoiceRoom,
videoTileCount,
videoCallLayout,
voiceState,
} from "@app/voice"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"