diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts new file mode 100644 index 00000000..95ccc0b8 --- /dev/null +++ b/src/app/call/stores.ts @@ -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(undefined) + +export const voiceState = writable(VoiceState.Disconnected) + +export const currentVoiceRoom = writable(undefined) + +export const participantPubkeyMap = writable>(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([]) + +export const isParticipantSpeaking = derived( + speakingParticipants, + $participants => (p: VoiceParticipant) => + $participants.some(sp => participantKey(sp) === participantKey(p)), +) + +/** True when the local user is in LiveKit’s 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)) + }, +) diff --git a/src/app/call/video.ts b/src/app/call/video.ts new file mode 100644 index 00000000..3a420005 --- /dev/null +++ b/src/app/call/video.ts @@ -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.Split) + +export const resetVideoCallLayout = () => { + videoCallViewportSync.previousLayout = undefined + videoCallLayout.set(VideoCallLayout.Chat) +} + +export const videoPrimaryTileKey = writable(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"}) + } +} diff --git a/src/app/voice.ts b/src/app/call/voice.ts similarity index 69% rename from src/app/voice.ts rename to src/app/call/voice.ts index ff84c8d0..33449ebc 100644 --- a/src/app/voice.ts +++ b/src/app/call/voice.ts @@ -12,14 +12,27 @@ import { supportsAudioOutputSelection, type AudioCaptureOptions, } from "livekit-client" -import {derived, get, writable} from "svelte/store" +import {derived, get} 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, 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" export const LIVEKIT_PARTICIPANTS = 39004 @@ -28,27 +41,6 @@ export {checkRelayHasLivekit} from "$lib/livekit" 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(undefined) - const LIVEKIT_DEFAULT_DEVICE_ID = "default" export enum DeviceKind { @@ -83,36 +75,6 @@ export const switchVoiceActiveDevice = async ( } } -export const voiceState = writable(VoiceState.Disconnected) - -export const currentVoiceRoom = writable(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.Split) - -const resetVideoCallLayout = () => { - videoCallLayout.set(VideoCallLayout.Chat) -} - -export const participantPubkeyMap = writable>(new Map()) - -/** Spotlight tile id — must match VideoCallContent `tileKey` (identity + source, not trackSid). */ -export const videoPrimaryTileKey = writable(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) => { participantPubkeyMap.update(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([]) - -export const isParticipantSpeaking = derived( - speakingParticipants, - $participants => (p: VoiceParticipant) => - $participants.some(sp => participantKey(sp) === participantKey(p)), -) - -/** True when the local user is in LiveKit’s 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 ( url: string, groupId: string, @@ -260,14 +194,14 @@ const onTrackSubscribed = (track: Track) => { document.body.appendChild(element) element.play().catch(() => {}) } else if (track.kind === Track.Kind.Video) { - triggerVideoTileCount() + triggerVideoFeedCount() } } const onTrackUnsubscribed = (track: Track) => { track.detach().forEach(el => el.remove()) if (track.kind === Track.Kind.Video) { - triggerVideoTileCount() + triggerVideoFeedCount() } } @@ -437,69 +371,3 @@ export const toggleMute = async () => { 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"}) - } -} diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 7a50bdcf..1f69da7a 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -8,14 +8,17 @@ import ProfileCircle from "@app/components/ProfileCircle.svelte" import VideoCallVideo from "@app/components/VideoCallVideo.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte" + import {get} from "svelte/store" import { - currentVoiceSession, - currentVoiceRoom, VideoCallLayout, - videoPrimaryTileKey, + isDesktopLayout, toggleVideoPrimaryTile, - pubkeyFromLiveKitIdentity, - } from "@app/voice" + videoCallLayout, + videoCallViewportSync, + ViewportSize, + videoPrimaryTileKey, + } from "@app/call/video" + import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores" type Props = { layout: VideoCallLayout @@ -37,6 +40,23 @@ 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( $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, ) diff --git a/src/app/components/VideoCallLayoutViewportSync.svelte b/src/app/components/VideoCallLayoutViewportSync.svelte deleted file mode 100644 index 6fb1fc3d..00000000 --- a/src/app/components/VideoCallLayoutViewportSync.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/src/app/components/VoiceCallAudioSettingsDialog.svelte b/src/app/components/VoiceCallAudioSettingsDialog.svelte index 5f86ec26..003d6814 100644 --- a/src/app/components/VoiceCallAudioSettingsDialog.svelte +++ b/src/app/components/VoiceCallAudioSettingsDialog.svelte @@ -7,13 +7,8 @@ import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte" - import { - currentVoiceSession, - DeviceKind, - supportsAudioOutputSelection, - switchVoiceActiveDevice, - type VoiceSession, - } from "@app/voice" + import {currentVoiceSession, type VoiceSession} from "@app/call/stores" + import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice" import {popModal} from "@app/util/modal" const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => { diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 26b7f709..965d1ba4 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -12,14 +12,13 @@ import {makeRoomId} from "@app/core/state" import { VoiceState, - deriveVoiceParticipants, - cancelJoinVoiceRoom, currentVoiceRoom, - voiceState, isParticipantSpeaking, participantKey, + voiceState, type VoiceParticipant, - } from "@app/voice" + } from "@app/call/stores" + import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice" interface Props { url: string diff --git a/src/app/components/VoiceRoomJoinDialog.svelte b/src/app/components/VoiceRoomJoinDialog.svelte index 8bba88b5..7da58f63 100644 --- a/src/app/components/VoiceRoomJoinDialog.svelte +++ b/src/app/components/VoiceRoomJoinDialog.svelte @@ -14,7 +14,7 @@ import ModalTitle from "@lib/components/ModalTitle.svelte" import {AbortError, TimeoutError} from "$lib/util" import {displayRoom} from "@app/core/state" - import {joinVoiceRoom} from "@app/voice" + import {joinVoiceRoom} from "@app/call/voice" import {popModal} from "@app/util/modal" import {pushToast} from "@app/util/toast" diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 40a888bb..56f33e1a 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -19,7 +19,6 @@ import Button from "@lib/components/Button.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" - import {isDesktopLayout} from "@app/components/VideoCallLayoutViewportSync.svelte" import { decodeRelay, deriveRoom, @@ -32,19 +31,20 @@ import {notifications} from "@app/util/notifications" import {makeRoomPath} from "@app/util/routes" import { - VoiceState, VideoCallLayout, + isDesktopLayout, + toggleCamera, + toggleScreenShare, + videoCallLayout, + } from "@app/call/video" + import { + VoiceState, currentVoiceSession, currentVoiceRoom, voiceState, - videoCallLayout, isLocalSpeaking, - leaveVoiceRoom, - toggleMute, - toggleCamera, - toggleScreenShare, - cancelJoinVoiceRoom, - } from "@app/voice" + } from "@app/call/stores" + import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" const {relay, h} = $derived($page.params) const url = $derived(relay ? decodeRelay(relay) : undefined) diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 131730a9..7a206a8b 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -57,7 +57,7 @@ import { loadFeedsForPubkey, } from "@app/core/state" import {hasBlossomSupport} from "@app/core/commands" -import {LIVEKIT_PARTICIPANTS} from "@app/voice" +import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" // Utils diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c38febcf..9085728f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -42,7 +42,6 @@ import {syncKeyboard} from "@app/util/keyboard" import {getPageTitle} from "@app/util/title" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" - import VideoCallLayoutViewportSync from "@app/components/VideoCallLayoutViewportSync.svelte" const {children} = $props() @@ -230,6 +229,5 @@
- {/await} diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 7f067506..a3648ef4 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -45,14 +45,8 @@ } from "@app/core/state" import VoiceWidget from "@app/components/VoiceWidget.svelte" import VideoCallContent from "@app/components/VideoCallContent.svelte" - import { - VoiceState, - VideoCallLayout, - currentVoiceRoom, - videoTileCount, - videoCallLayout, - voiceState, - } from "@app/voice" + import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores" + import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {checked} from "@app/util/notifications"