Simplify how video call layout is stored

This commit is contained in:
mplorentz
2026-04-07 14:57:24 -04:00
committed by hodlbod
parent fb60d493b9
commit 2c4fe7bcf3
7 changed files with 93 additions and 77 deletions
+1 -1
View File
@@ -410,7 +410,7 @@ progress[value]::-webkit-progress-value {
transition: width 0.5s; 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 { .left-content {
@apply md:left-[calc(18.5rem+var(--sail))]; @apply md:left-[calc(18.5rem+var(--sail))];
+21 -17
View File
@@ -11,23 +11,22 @@
import { import {
currentVoiceSession, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
VideoCallLayout,
videoCallLayoutRevision, videoCallLayoutRevision,
videoPrimaryTileKey, videoPrimaryTileKey,
toggleVideoPrimaryTile, toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity, pubkeyFromLiveKitIdentity,
} from "@app/voice" } from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = { type Props = {
variant: Variant layout: VideoCallLayout
mobile?: boolean
url: string url: string
h: string h: string
visible?: boolean
class?: string class?: string
} }
type Tile = { type VideoTile = {
identity: string identity: string
isLocal: boolean isLocal: boolean
trackSid: string trackSid: string
@@ -37,11 +36,18 @@
type TileLayout = "spotlight" | "default" | "strip" 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(() => { const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
@@ -52,7 +58,7 @@
} }
const room = session.room const room = session.room
const out: Tile[] = [] const out: VideoTile[] = []
const lp = room.localParticipant const lp = room.localParticipant
if (session.cameraOn) { if (session.cameraOn) {
@@ -104,7 +110,7 @@
}) })
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */ /** 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 primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey 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 pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown" const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name return source === Track.Source.ScreenShare ? `${name} · screen` : name
@@ -151,18 +157,16 @@
const panelChrome = $derived( const panelChrome = $derived(
cx( 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))]", "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" && !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",
variant === "desktop-full" &&
"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", "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, className,
), ),
) )
</script> </script>
{#snippet videoTile(tile: Tile, layout: TileLayout)} {#snippet videoTile(tile: VideoTile, layout: TileLayout)}
<div <div
class={cx( class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm", "relative isolate overflow-hidden rounded-box shadow-sm",
@@ -241,7 +245,7 @@
{#if showPanel} {#if showPanel}
<div class={panelChrome}> <div class={panelChrome}>
{#if variant === "mobile"} {#if mobile}
<div class="flex min-h-0 flex-1 flex-col gap-2"> <div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()} {@render videoPanelBody()}
@@ -0,0 +1,28 @@
<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>
+18 -26
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition" import {fade, fly} from "svelte/transition"
import {browser} from "$app/environment"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores" import {page} from "$app/stores"
import cx from "classnames" import cx from "classnames"
@@ -20,6 +19,7 @@
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,
@@ -33,11 +33,11 @@
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import { import {
VoiceState, VoiceState,
VideoCallLayout,
currentVoiceSession, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
voiceMobileRoomPanel, videoCallLayout,
voiceDesktopRoomPanel,
isLocalSpeaking, isLocalSpeaking,
leaveVoiceRoom, leaveVoiceRoom,
toggleMute, toggleMute,
@@ -90,21 +90,6 @@
pushModal(VoiceCallAudioSettingsDialog) pushModal(VoiceCallAudioSettingsDialog)
} }
let isMd = $state(
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches,
)
$effect(() => {
if (!browser) return
const mq = window.matchMedia("(min-width: 768px)")
const sync = () => {
isMd = mq.matches
}
sync()
mq.addEventListener("change", sync)
return () => mq.removeEventListener("change", sync)
})
const showVoiceLayoutToggle = $derived( const showVoiceLayoutToggle = $derived(
$voiceState === VoiceState.Connected && $voiceState === VoiceState.Connected &&
targetRoom !== undefined && targetRoom !== undefined &&
@@ -115,17 +100,24 @@
h === targetRoom.h, h === targetRoom.h,
) )
const layoutToggleActive = $derived( const isChatPanelActive = $derived(
showVoiceLayoutToggle && showVoiceLayoutToggle &&
((!isMd && $voiceMobileRoomPanel === "chat") || (isMd && $voiceDesktopRoomPanel === "split")), ((!isDesktopLayout.current &&
($videoCallLayout === VideoCallLayout.Chat ||
$videoCallLayout === VideoCallLayout.Split)) ||
(isDesktopLayout.current && $videoCallLayout === VideoCallLayout.Split)),
) )
const onLayoutToggle = () => { const onChatToggle = () => {
if (!showVoiceLayoutToggle) return if (!showVoiceLayoutToggle) return
if (isMd) { if (isDesktopLayout.current) {
voiceDesktopRoomPanel.update(p => (p === "split" ? "video" : "split")) videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
} else { } else {
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat")) videoCallLayout.update(p =>
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
)
} }
} }
@@ -177,9 +169,9 @@
class={cx( class={cx(
mediaToggleClass, mediaToggleClass,
"relative shrink-0 overflow-visible", "relative shrink-0 overflow-visible",
layoutToggleActive && "text-primary", isChatPanelActive && "text-primary",
)} )}
onclick={onLayoutToggle}> onclick={onChatToggle}>
<span class="relative inline-flex"> <span class="relative inline-flex">
<Icon icon={ChatRound} size={4} /> <Icon icon={ChatRound} size={4} />
{#if chatUnread} {#if chatUnread}
+11 -9
View File
@@ -87,15 +87,17 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined) export const currentVoiceRoom = writable<Room | undefined>(undefined)
/** Mobile room UI: full-screen chat vs video (see VoiceWidget layout toggle). */ /** Chat-only, full-width video, or split (desktop). On narrow viewports, `split` shows as chat until resize remaps it. */
export const voiceMobileRoomPanel = writable<"chat" | "video">("chat") export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
/** Desktop room UI: messages only, video only, or split (see VoiceWidget layout toggle). */ export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const voiceDesktopRoomPanel = writable<"chat" | "video" | "split">("split")
const resetVoiceRoomPanels = () => { const resetVideoCallLayout = () => {
voiceMobileRoomPanel.set("chat") videoCallLayout.set(VideoCallLayout.Chat)
voiceDesktopRoomPanel.set("chat")
} }
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map()) export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -240,7 +242,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0) videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVoiceRoomPanels() resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected) voiceState.set(VoiceState.Disconnected)
const message = const message =
@@ -408,7 +410,7 @@ export const leaveVoiceRoom = async () => {
videoCallLayoutRevision.set(0) videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined) videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
resetVoiceRoomPanels() resetVideoCallLayout()
session.room.disconnect() session.room.disconnect()
speakingParticipants.set([]) speakingParticipants.set([])
participantPubkeyMap.set(new Map()) participantPubkeyMap.set(new Map())
+2
View File
@@ -42,6 +42,7 @@
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()
@@ -229,5 +230,6 @@
<ModalContainer /> <ModalContainer />
<div class="tippy-target"></div> <div class="tippy-target"></div>
<NewNotificationSound /> <NewNotificationSound />
<VideoCallLayoutViewportSync />
</div> </div>
{/await} {/await}
+12 -24
View File
@@ -47,10 +47,10 @@
import VideoCallContent from "@app/components/VideoCallContent.svelte" import VideoCallContent from "@app/components/VideoCallContent.svelte"
import { import {
VoiceState, VoiceState,
VideoCallLayout,
currentVoiceRoom, currentVoiceRoom,
videoTileCount, videoTileCount,
voiceMobileRoomPanel, videoCallLayout,
voiceDesktopRoomPanel,
voiceState, voiceState,
} from "@app/voice" } from "@app/voice"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
@@ -74,19 +74,20 @@
) )
const showMobileVideoPanel = $derived( const showMobileVideoPanel = $derived(
isVoiceRoom && $voiceState === VoiceState.Connected && $voiceMobileRoomPanel === "video", isVoiceRoom &&
$voiceState === VoiceState.Connected &&
$videoCallLayout === VideoCallLayout.Video,
) )
const pageContentHiddenDesktopVideoOnly = $derived( const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && $voiceDesktopRoomPanel === "video", voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
) )
let prevVideoTileCount = $state(0) let prevVideoTileCount = $state(0)
$effect(() => { $effect(() => {
if ($voiceState !== VoiceState.Connected) { if ($voiceState !== VoiceState.Connected) {
voiceMobileRoomPanel.set("chat") videoCallLayout.set(VideoCallLayout.Chat)
voiceDesktopRoomPanel.set("chat")
prevVideoTileCount = 0 prevVideoTileCount = 0
return return
} }
@@ -100,8 +101,7 @@
} }
if (prevVideoTileCount === 0 && n >= 1) { if (prevVideoTileCount === 0 && n >= 1) {
voiceDesktopRoomPanel.set("video") videoCallLayout.set(VideoCallLayout.Video)
voiceMobileRoomPanel.set("video")
} }
prevVideoTileCount = n prevVideoTileCount = n
}) })
@@ -416,35 +416,23 @@
<div <div
class={cx( class={cx(
"flex min-h-0 flex-1 flex-col", "flex min-h-0 flex-1 flex-col",
voiceConnectedHere && $voiceDesktopRoomPanel === "split" && "md:flex-row", voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split && "md:flex-row",
)}> )}>
{#if voiceConnectedHere} {#if voiceConnectedHere}
<VideoCallContent <VideoCallContent
variant="desktop-split" layout={$videoCallLayout}
{url} {url}
{h} {h}
visible={$voiceDesktopRoomPanel === "split"}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
<VideoCallContent
variant="desktop-full"
{url}
{h}
visible={$voiceDesktopRoomPanel === "video"}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" /> class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
{/if} {/if}
<div <div
class={cx( class={cx(
"flex min-h-0 min-w-0 flex-1 flex-col", "flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && $voiceDesktopRoomPanel === "video" && "md:hidden", voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
)}> )}>
{#if isVoiceRoom && $voiceState === VoiceState.Connected} {#if isVoiceRoom && $voiceState === VoiceState.Connected}
<VideoCallContent <VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
variant="mobile"
{url}
{h}
visible={$voiceMobileRoomPanel === "video"}
class="md:hidden" />
{/if} {/if}
<PageContent <PageContent