From 9f6b16089ba84f9c0e8a6d1a70ea3de2e84f42d7 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Mar 2026 10:49:14 -0400 Subject: [PATCH 01/20] add video to livekit calls --- src/app.css | 4 +- src/app/components/VideoCallContent.svelte | 131 +++++++++++++++++ src/app/components/VideoCallVideo.svelte | 27 ++++ src/app/components/VoiceWidget.svelte | 16 ++- src/app/voice.ts | 86 ++++++++++- src/lib/components/PageContent.svelte | 7 +- src/routes/spaces/[relay]/[h]/+layout.svelte | 11 +- src/routes/spaces/[relay]/[h]/+page.svelte | 144 ++++++++++++++++++- 8 files changed, 411 insertions(+), 15 deletions(-) create mode 100644 src/app/components/VideoCallContent.svelte create mode 100644 src/app/components/VideoCallVideo.svelte diff --git a/src/app.css b/src/app.css index c0a85c58..c854c9bd 100644 --- a/src/app.css +++ b/src/app.css @@ -400,7 +400,7 @@ progress[value]::-webkit-progress-value { transition: width 0.5s; } -/* content width for fixed elements */ +/* Anchors for fixed overlays (compose, search, reply) — main scroll lives in Page / PageContent flow */ .left-content { @apply md:left-[calc(18.5rem+var(--sail))]; @@ -415,7 +415,7 @@ body.keyboard-open .hide-on-keyboard { /* chat view */ .chat__compose { - @apply z-compose relative mb-14 grow md:mb-0; + @apply relative z-compose mb-14 shrink-0 md:mb-0; } .chat__compose .chat__compose-inner { diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte new file mode 100644 index 00000000..1da29813 --- /dev/null +++ b/src/app/components/VideoCallContent.svelte @@ -0,0 +1,131 @@ + + +{#if showPanel && (showTileGrid || allowEmptyPanel)} + +{/if} diff --git a/src/app/components/VideoCallVideo.svelte b/src/app/components/VideoCallVideo.svelte new file mode 100644 index 00000000..fbc3af89 --- /dev/null +++ b/src/app/components/VideoCallVideo.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 1ed8356e..64375bc9 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -6,6 +6,8 @@ import {displayRelayUrl} from "@welshman/util" import Microphone from "@assets/icons/microphone.svg?dataurl" import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" + import Videocamera from "@assets/icons/videocamera.svg?dataurl" + import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl" @@ -31,6 +33,7 @@ voiceState, leaveVoiceRoom, toggleMute, + toggleCamera, cancelJoinVoiceRoom, } from "@app/voice" @@ -107,9 +110,16 @@ + + + + + {/if} + + {/if} + +{/snippet} + +{#if showPanel && (showTileGrid || allowEmptyPanel)} +
{#if showTileGrid} - {#each tiles as tile (tile.trackSid + tile.identity)} -
- {#if tile.attachable} - - {:else} -
- + {#if useSpotlightLayout && primaryTile} +
+ {@render videoTile(primaryTile, "spotlight")} + {#if secondaryTiles.length > 0} +
+ {#each secondaryTiles as tile (tileKey(tile))} + {@render videoTile(tile, "strip")} + {/each}
{/if} - - {labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""} -
- {/each} + {:else if useMultiGrid} +
+ {#each tiles as tile (tileKey(tile))} + {@render videoTile(tile, "default")} + {/each} +
+ {:else} +
+ {#each tiles as tile (tileKey(tile))} + {@render videoTile(tile, "default")} + {/each} +
+ {/if} {:else}
diff --git a/src/app/voice.ts b/src/app/voice.ts index e100c93b..6fd60293 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -88,6 +88,13 @@ export const participantPubkeyMap = writable>(new Map()) /** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */ export const videoCallLayoutRevision = writable(0) +/** 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 bumpVideoCallLayoutRevision = () => videoCallLayoutRevision.update(n => n + 1) const addParticipant = (identity: string) => { @@ -206,6 +213,7 @@ const setUpMicrophone = async ( const onRoomDisconnected = (reason?: DisconnectReason) => { videoCallLayoutRevision.set(0) + videoPrimaryTileKey.set(undefined) currentVoiceSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { voiceState.set(VoiceState.Disconnected) @@ -372,6 +380,7 @@ export const leaveVoiceRoom = async () => { voiceState.set(VoiceState.Disconnected) videoCallLayoutRevision.set(0) + videoPrimaryTileKey.set(undefined) currentVoiceSession.set(undefined) session.room.disconnect() speakingParticipants.set([]) -- 2.52.0 From 7c27846d0d52c4f9e6751160178c7f3ff54badc7 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Mar 2026 11:42:08 -0400 Subject: [PATCH 04/20] Improve pinned video layout --- src/app/components/VideoCallContent.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 46829f05..51114a05 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -222,7 +222,7 @@
{:else if useMultiGrid}
+ class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2"> {#each tiles as tile (tileKey(tile))} {@render videoTile(tile, "default")} {/each} -- 2.52.0 From 8f6f628bd7652413c73716381656721fa6c8dac3 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Mar 2026 11:44:01 -0400 Subject: [PATCH 05/20] Change screen sharing icon --- src/app/components/VoiceWidget.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 8877ad48..f4630e73 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -8,8 +8,7 @@ import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import Videocamera from "@assets/icons/videocamera.svg?dataurl" import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl" - import ScreenShare from "@assets/icons/screen-share.svg?dataurl" - import Screencast from "@assets/icons/screencast.svg?dataurl" + import Monitor from "@assets/icons/monitor.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl" @@ -131,7 +130,7 @@ ? 'btn-ghost' : 'btn-error'}" onclick={toggleScreenShare}> - + + - diff --git a/src/app/voice.ts b/src/app/voice.ts index 81e7e510..60248ed9 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -135,6 +135,16 @@ export const isParticipantSpeaking = derived( $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, -- 2.52.0 From b545f225a5a901eb489c4a658ebf64b2ffe1a8d6 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Apr 2026 10:01:13 -0400 Subject: [PATCH 10/20] Style pin icon more better --- src/app/components/VideoCallContent.svelte | 11 +++++++---- src/lib/components/Button.svelte | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 51114a05..ca1eaa82 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -176,8 +176,6 @@ layout === "default" && "aspect-video w-full min-h-0", layout === "strip" && "aspect-video w-44 shrink-0", tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100", - $videoPrimaryTileKey === tileKey(tile) && - "ring-2 ring-primary ring-offset-2 ring-offset-base-300", )}> {#if tile.attachable} {#if tiles.length > 1} + {@const pinned = $videoPrimaryTileKey === tileKey(tile)} diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index 4184b172..21f35430 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -14,6 +14,7 @@ style?: string disabled?: boolean "data-tip"?: string + "aria-pressed"?: boolean } = $props() const className = $derived(`text-left ${restProps.class}`) -- 2.52.0 From 31d7041e5c5f67889aa9b4ff1d8c0b544ef786a4 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Apr 2026 10:36:14 -0400 Subject: [PATCH 11/20] rework video + text chat display controls --- src/app.css | 11 +++ src/app/components/VideoCallContent.svelte | 101 +++++++++++---------- src/app/components/VoiceWidget.svelte | 69 ++++++++++++-- src/app/voice.ts | 33 +++---- src/routes/spaces/[relay]/[h]/+page.svelte | 68 ++++---------- 5 files changed, 158 insertions(+), 124 deletions(-) diff --git a/src/app.css b/src/app.css index c854c9bd..412b4232 100644 --- a/src/app.css +++ b/src/app.css @@ -22,6 +22,17 @@ @apply pl-sai pr-sai; } +/* root */ + +:root { + font-family: Lato; + --sait: var(--safe-area-inset-top, env(safe-area-inset-top)); + --saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom)); + --sail: var(--safe-area-inset-left, env(safe-area-inset-left)); + --sair: var(--safe-area-inset-right, env(safe-area-inset-right)); + --video-call-panel-bg: #181e24; +} + @utility py-sai { @apply pt-sai pb-sai; } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index ca1eaa82..6fda6390 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -7,10 +7,10 @@ import Icon from "@lib/components/Icon.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import VideoCallVideo from "@app/components/VideoCallVideo.svelte" + import VoiceWidget from "@app/components/VoiceWidget.svelte" import { currentVoiceSession, currentVoiceRoom, - videoCallContentActive, videoCallLayoutRevision, videoPrimaryTileKey, toggleVideoPrimaryTile, @@ -41,13 +41,7 @@ const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h) - const allowEmptyPanel = $derived(variant === "desktop-split" || variant === "desktop-full") - - const showPanel = $derived( - visible && - roomMatches && - (variant === "mobile" ? $videoCallContentActive : $videoCallContentActive || allowEmptyPanel), - ) + const showPanel = $derived(visible && roomMatches) const tiles = $derived.by(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes @@ -158,11 +152,11 @@ const panelChrome = $derived( cx( variant === "mobile" && - "cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-hidden p-2 md:hidden", + "cb top-[calc(var(--sait)+6rem)] cw z-compose bg-[var(--video-call-panel-bg)] fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2 pt-1 md:hidden", variant === "desktop-split" && - "cb ct cw-split-video z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex", + "cb ct cw-split-video z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex", variant === "desktop-full" && - "cb ct cw z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex", + "cb ct cw z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex", className, ), ) @@ -208,43 +202,56 @@
{/snippet} -{#if showPanel && (showTileGrid || allowEmptyPanel)} -
- {#if showTileGrid} - {#if useSpotlightLayout && primaryTile} -
- {@render videoTile(primaryTile, "spotlight")} - {#if secondaryTiles.length > 0} -
- {#each secondaryTiles as tile (tileKey(tile))} - {@render videoTile(tile, "strip")} - {/each} -
- {/if} -
- {:else if useMultiGrid} -
- {#each tiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} - {/each} -
- {:else} -
- {#each tiles as tile (tileKey(tile))} - {@render videoTile(tile, "default")} - {/each} -
- {/if} - {:else} -
-

No camera or screen share yet.

-

- Use the camera or screen share control in the voice widget to share video. -

+{#snippet videoPanelBody()} + {#if showTileGrid} + {#if useSpotlightLayout && primaryTile} +
+ {@render videoTile(primaryTile, "spotlight")} + {#if secondaryTiles.length > 0} +
+ {#each secondaryTiles as tile (tileKey(tile))} + {@render videoTile(tile, "strip")} + {/each} +
+ {/if}
+ {:else if useMultiGrid} +
+ {#each tiles as tile (tileKey(tile))} + {@render videoTile(tile, "default")} + {/each} +
+ {:else} +
+ {#each tiles as tile (tileKey(tile))} + {@render videoTile(tile, "default")} + {/each} +
+ {/if} + {:else} +
+

No camera or screen share yet.

+

Use the camera or screen share control to share video.

+
+ {/if} +{/snippet} + +{#if showPanel} +
+ {#if variant === "mobile"} +
+
+ {@render videoPanelBody()} +
+
+ +
+
+ {:else} + {@render videoPanelBody()} {/if}
{/if} diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 7270b930..dfcdebb6 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,6 +1,7 @@ @@ -96,18 +139,28 @@ out:fly={{y: 60, duration: 250}} class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
- {#if $voiceState === VoiceState.Joining} - Joining... - {:else if $voiceState === VoiceState.Connected} - Voice Connected - {:else} - Disconnected - {/if} +
+ {#if $voiceState === VoiceState.Joining} + Joining... + {:else if $voiceState === VoiceState.Connected} + Voice Connected + {:else} + Disconnected + {/if} + {#if showVoiceLayoutToggle} + + {/if} +
{roomName} / {spaceName}
-
+
{#if $voiceState === VoiceState.Joining} - -
- - {/if} - {/if} -
- - {roomName} / {spaceName} - +
+ + {#if showVoiceLayoutToggle} + + {/if}
{#if $voiceState === VoiceState.Joining} -- 2.52.0 From ceca21e867881d065dc0dda4631a435dbc573e0d Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Apr 2026 10:51:28 -0400 Subject: [PATCH 13/20] Show unread indicator on chat icon in VoiceWidget --- src/app/components/VoiceWidget.svelte | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 41664f4d..96ea123f 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,6 +1,6 @@ @@ -167,10 +172,22 @@ {#if showVoiceLayoutToggle} {/if}
-- 2.52.0 From 4d90092f4b495bc478a4aa1b2394a54c8a258d03 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 6 Apr 2026 10:46:02 -0400 Subject: [PATCH 14/20] Hide screen share button on ios and android --- src/app/components/VoiceWidget.svelte | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 96ea123f..6287f106 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -15,6 +15,7 @@ import ChatRound from "@assets/icons/chat-round.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl" + import {Capacitor} from "@capacitor/core" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" @@ -222,12 +223,14 @@ onclick={toggleCamera}> - + {#if !Capacitor.isNativePlatform()} + + {/if} - {:else} - {:else} - + {/if} - Join Room - - {/if} -
-
- {:else} - {#if loadingForward} -

- Looking for messages... -

- {/if} - {#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)} - {#if type === "new-messages"} -
-
-

New Messages

-
-
- {:else if type === "date"} - {value} - {:else} - {@const event = $state.snapshot(value as TrustedEvent)} - {#if event.kind === ROOM_ADD_MEMBER} - - {:else} -
-
- {/if} - {/if} - {/each} -

- {#if loadingBackward} - Looking for messages... +

{:else} - End of message history + {#if loadingForward} +

+ Looking for messages... +

+ {/if} + {#each elements as { type, id, value, showPubkey, addSpaceBelow } (id)} + {#if type === "new-messages"} +
+
+

New Messages

+
+
+ {:else if type === "date"} + {value} + {:else} + {@const event = $state.snapshot(value as TrustedEvent)} + {#if event.kind === ROOM_ADD_MEMBER} + + {:else} +
+ +
+ {/if} + {/if} + {/each} +

+ {#if loadingBackward} + Looking for messages... + {:else} + End of message history + {/if} +

{/if} -

- {/if} -
+
- {#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} - - {:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} -
-

Only members are allowed to post to this room.

- {#if $membershipStatus === MembershipStatus.Pending} - - {:else} - {:else} - + {/if} - Ask to Join - +
+ {:else} +
+ {#if parent} + + {/if} + {#if share} + + {/if} + {#if eventToEdit} + + {/if} +
+ {#key eventToEdit} + + {/key} {/if}
- {:else} -
- {#if parent} - - {/if} - {#if share} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key eventToEdit} - - {/key} - {/if} -
- {#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected} -
- -
- {/if} + {#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected} +
+ +
+ {/if}
-- 2.52.0 From 2c4fe7bcf37003d1a64da44b205d13e943beefcc Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 7 Apr 2026 14:57:24 -0400 Subject: [PATCH 17/20] Simplify how video call layout is stored --- src/app.css | 2 +- src/app/components/VideoCallContent.svelte | 38 +++++++++------- .../VideoCallLayoutViewportSync.svelte | 28 ++++++++++++ src/app/components/VoiceWidget.svelte | 44 ++++++++----------- src/app/voice.ts | 20 +++++---- src/routes/+layout.svelte | 2 + src/routes/spaces/[relay]/[h]/+page.svelte | 36 +++++---------- 7 files changed, 93 insertions(+), 77 deletions(-) create mode 100644 src/app/components/VideoCallLayoutViewportSync.svelte diff --git a/src/app.css b/src/app.css index 7ed78c07..54c39834 100644 --- a/src/app.css +++ b/src/app.css @@ -410,7 +410,7 @@ progress[value]::-webkit-progress-value { transition: width 0.5s; } -/* Anchors for fixed overlays (compose, search, reply) — main scroll lives in Page / PageContent flow */ +/* content width for fixed elements */ .left-content { @apply md:left-[calc(18.5rem+var(--sail))]; diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index b947ce03..35217cb7 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -11,23 +11,22 @@ import { currentVoiceSession, currentVoiceRoom, + VideoCallLayout, videoCallLayoutRevision, videoPrimaryTileKey, toggleVideoPrimaryTile, pubkeyFromLiveKitIdentity, } from "@app/voice" - type Variant = "mobile" | "desktop-split" | "desktop-full" - type Props = { - variant: Variant + layout: VideoCallLayout + mobile?: boolean url: string h: string - visible?: boolean class?: string } - type Tile = { + type VideoTile = { identity: string isLocal: boolean trackSid: string @@ -37,11 +36,18 @@ type TileLayout = "spotlight" | "default" | "strip" - const {variant, url, h, visible = true, class: className = ""}: Props = $props() + const {layout, mobile = false, url, h, class: className = ""}: Props = $props() - const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h) + const isViewingCurrentCallRoom = $derived( + $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, + ) - const showPanel = $derived(visible && roomMatches) + const showPanel = $derived( + isViewingCurrentCallRoom && + (mobile + ? layout === VideoCallLayout.Video + : layout === VideoCallLayout.Split || layout === VideoCallLayout.Video), + ) const tiles = $derived.by(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes @@ -52,7 +58,7 @@ } const room = session.room - const out: Tile[] = [] + const out: VideoTile[] = [] const lp = room.localParticipant if (session.cameraOn) { @@ -104,7 +110,7 @@ }) /** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ - const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}` + const tileKey = (t: VideoTile) => `${t.identity}\x1f${t.source}` const primaryTile = $derived.by(() => { const k = $videoPrimaryTileKey @@ -137,7 +143,7 @@ } }) - const labelFor = (identity: string, source: Tile["source"]) => { + const labelFor = (identity: string, source: VideoTile["source"]) => { const pk = pubkeyFromLiveKitIdentity(identity) const name = pk ? displayProfileByPubkey(pk) : "Unknown" return source === Track.Source.ScreenShare ? `${name} · screen` : name @@ -151,18 +157,16 @@ const panelChrome = $derived( cx( - variant === "mobile" && + mobile && "flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-base-200 px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]", - variant === "desktop-split" && - "flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4", - variant === "desktop-full" && + !mobile && "flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-base-200 px-2 pb-2 pt-4", className, ), ) -{#snippet videoTile(tile: Tile, layout: TileLayout)} +{#snippet videoTile(tile: VideoTile, layout: TileLayout)}
- {#if variant === "mobile"} + {#if mobile}
{@render videoPanelBody()} diff --git a/src/app/components/VideoCallLayoutViewportSync.svelte b/src/app/components/VideoCallLayoutViewportSync.svelte new file mode 100644 index 00000000..6fb1fc3d --- /dev/null +++ b/src/app/components/VideoCallLayoutViewportSync.svelte @@ -0,0 +1,28 @@ + + + diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index ec9afd81..40a888bb 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,7 +1,6 @@ - - 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 af1651a7..e3a0125a 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -55,7 +55,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" -- 2.52.0 From 81ab03311abff0a70c35bb02bb3e9ff83466fd68 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 8 Apr 2026 11:09:52 -0400 Subject: [PATCH 20/20] Cleanup remaining files --- src/app/call/stores.ts | 1 - src/app/call/video.ts | 30 +++++------ src/app/call/voice.ts | 4 +- src/app/components/VideoCallContent.svelte | 28 +++++----- ...oCallVideo.svelte => VideoCallTile.svelte} | 14 ++--- .../VoiceCallAudioSettingsDialog.svelte | 13 ++--- src/app/components/VoiceWidget.svelte | 52 +++++++++---------- 7 files changed, 62 insertions(+), 80 deletions(-) rename src/app/components/{VideoCallVideo.svelte => VideoCallTile.svelte} (66%) diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts index 95ccc0b8..abe4084e 100644 --- a/src/app/call/stores.ts +++ b/src/app/call/stores.ts @@ -47,7 +47,6 @@ export const isParticipantSpeaking = derived( $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]) => { diff --git a/src/app/call/video.ts b/src/app/call/video.ts index 3a420005..eccd4397 100644 --- a/src/app/call/video.ts +++ b/src/app/call/video.ts @@ -71,17 +71,14 @@ export const toggleCamera = async () => { 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) + await session.room.localParticipant.setCameraEnabled(cameraOn) currentVoiceSession.set({...session, cameraOn}) - } catch (e) { - pushToast({theme: "error", message: "Could not access camera"}) + } catch { + pushToast({ + theme: "error", + message: cameraOn ? "Could not access camera" : "Could not turn off camera", + }) } } @@ -90,16 +87,13 @@ export const toggleScreenShare = async () => { 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) + await session.room.localParticipant.setScreenShareEnabled(screenShareOn) currentVoiceSession.set({...session, screenShareOn}) - } catch (e) { - pushToast({theme: "error", message: "Could not start screen sharing"}) + } catch { + pushToast({ + theme: "error", + message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing", + }) } } diff --git a/src/app/call/voice.ts b/src/app/call/voice.ts index 33449ebc..95cab6a5 100644 --- a/src/app/call/voice.ts +++ b/src/app/call/voice.ts @@ -325,7 +325,7 @@ export const leaveVoiceRoom = async () => { try { await session.room.localParticipant.setCameraEnabled(false) } catch { - /* pass */ + pushToast({theme: "error", message: "Error turning off camera."}) } } @@ -333,7 +333,7 @@ export const leaveVoiceRoom = async () => { try { await session.room.localParticipant.setScreenShareEnabled(false) } catch { - /* pass */ + pushToast({theme: "error", message: "Error turning off screen sharing."}) } } diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 1f69da7a..ce65a7ed 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -6,7 +6,7 @@ import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" - import VideoCallVideo from "@app/components/VideoCallVideo.svelte" + import VideoCallTile from "@app/components/VideoCallTile.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte" import {get} from "svelte/store" import { @@ -28,11 +28,11 @@ class?: string } - type VideoTile = { + type VideoTileData = { identity: string isLocal: boolean trackSid: string - attachable: Track | undefined + track: Track | undefined source: Track.Source.Camera | Track.Source.ScreenShare } @@ -75,7 +75,7 @@ } const room = session.room - const videoTiles: VideoTile[] = [] + const videoTiles: VideoTileData[] = [] const user = room.localParticipant if (session.cameraOn) { @@ -84,7 +84,7 @@ identity: user.identity, isLocal: true, trackSid: localPub?.trackSid ?? "local-camera", - attachable: localPub?.track, + track: localPub?.track, source: Track.Source.Camera, }) } @@ -95,7 +95,7 @@ identity: user.identity, isLocal: true, trackSid: localPub?.trackSid ?? "local-screen", - attachable: localPub?.track, + track: localPub?.track, source: Track.Source.ScreenShare, }) } @@ -107,7 +107,7 @@ identity: rp.identity, isLocal: false, trackSid: camPub.trackSid, - attachable: camPub.track, + track: camPub.track, source: Track.Source.Camera, }) } @@ -117,7 +117,7 @@ identity: rp.identity, isLocal: false, trackSid: screenPub.trackSid, - attachable: screenPub.track, + track: screenPub.track, source: Track.Source.ScreenShare, }) } @@ -127,7 +127,7 @@ }) /** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ - const tileKey = (t: VideoTile) => `${t.identity}\x1f${t.source}` + const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}` const primaryTile = $derived.by(() => { const k = $videoPrimaryTileKey @@ -160,7 +160,7 @@ } }) - const labelFor = (identity: string, source: VideoTile["source"]) => { + const labelFor = (identity: string, source: VideoTileData["source"]) => { const pk = pubkeyFromLiveKitIdentity(identity) const name = pk ? displayProfileByPubkey(pk) : "Unknown" return source === Track.Source.ScreenShare ? `${name} · screen` : name @@ -183,7 +183,7 @@ ) -{#snippet videoTile(tile: VideoTile, layout: TileLayout)} +{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
- {#if tile.attachable} - diff --git a/src/app/components/VideoCallVideo.svelte b/src/app/components/VideoCallTile.svelte similarity index 66% rename from src/app/components/VideoCallVideo.svelte rename to src/app/components/VideoCallTile.svelte index ec42547e..6f8db1f9 100644 --- a/src/app/components/VideoCallVideo.svelte +++ b/src/app/components/VideoCallTile.svelte @@ -11,21 +11,21 @@ const {track, muted = true, fit = "cover", class: className = ""}: Props = $props() - let el = $state() + let videoElement = $state() $effect(() => { - const v = el - const t = track - if (!v) return - t.attach(v) + const element = videoElement + const activeTrack = track + if (!element) return + activeTrack.attach(element) return () => { - t.detach(v) + activeTrack.detach(element) } }) diff --git a/src/app/components/VoiceCallAudioSettingsDialog.svelte b/src/app/components/VoiceCallAudioSettingsDialog.svelte index 003d6814..a5fab918 100644 --- a/src/app/components/VoiceCallAudioSettingsDialog.svelte +++ b/src/app/components/VoiceCallAudioSettingsDialog.svelte @@ -41,16 +41,9 @@ } $effect(() => { - void loadDevices() - const md = navigator.mediaDevices - if (!md?.addEventListener) return - const onDeviceChange = () => { - void loadDevices() - } - md.addEventListener("devicechange", onDeviceChange) - return () => { - md.removeEventListener("devicechange", onDeviceChange) - } + loadDevices() + navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices) + return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices) }) $effect(() => { diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 56f33e1a..c8601644 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -53,6 +53,14 @@ ) const routeDisplayedRoom = $derived($displayedRoomStore) + const isViewingCurrentVoiceRoom = $derived( + $currentVoiceRoom !== undefined && + url !== undefined && + typeof h === "string" && + $currentVoiceRoom.url === url && + $currentVoiceRoom.h === h, + ) + const targetRoom = $derived.by((): Room | undefined => { if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { return $currentVoiceRoom @@ -90,26 +98,17 @@ pushModal(VoiceCallAudioSettingsDialog) } - const showVoiceLayoutToggle = $derived( - $voiceState === VoiceState.Connected && - targetRoom !== undefined && - getRoomType(targetRoom) === RoomType.Voice && - typeof h === "string" && - relay !== undefined && - decodeRelay(relay) === targetRoom.url && - h === targetRoom.h, - ) + const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom) const isChatPanelActive = $derived( - showVoiceLayoutToggle && - ((!isDesktopLayout.current && - ($videoCallLayout === VideoCallLayout.Chat || - $videoCallLayout === VideoCallLayout.Split)) || - (isDesktopLayout.current && $videoCallLayout === VideoCallLayout.Split)), + showChatButton && + (isDesktopLayout.current + ? $videoCallLayout === VideoCallLayout.Split + : $videoCallLayout === VideoCallLayout.Chat), ) const onChatToggle = () => { - if (!showVoiceLayoutToggle) return + if (!showChatButton) return if (isDesktopLayout.current) { videoCallLayout.update(p => p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split, @@ -128,17 +127,6 @@ const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost" -{#snippet mutedSlash(show: boolean)} - {#if show} - - {/if} -{/snippet} - {#if targetRoom}
- {#if showVoiceLayoutToggle} + {#if showChatButton}