Simplify how video call layout is stored

This commit is contained in:
mplorentz
2026-04-07 14:57:24 -04:00
parent 136b5f4a37
commit 117299b97b
7 changed files with 93 additions and 77 deletions
+21 -17
View File
@@ -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,
),
)
</script>
{#snippet videoTile(tile: Tile, layout: TileLayout)}
{#snippet videoTile(tile: VideoTile, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
@@ -241,7 +245,7 @@
{#if showPanel}
<div class={panelChrome}>
{#if variant === "mobile"}
{#if mobile}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@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">
import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition"
import {browser} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
@@ -20,6 +19,7 @@
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,
@@ -33,11 +33,11 @@
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
VideoCallLayout,
currentVoiceSession,
currentVoiceRoom,
voiceState,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
videoCallLayout,
isLocalSpeaking,
leaveVoiceRoom,
toggleMute,
@@ -90,21 +90,6 @@
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(
$voiceState === VoiceState.Connected &&
targetRoom !== undefined &&
@@ -115,17 +100,24 @@
h === targetRoom.h,
)
const layoutToggleActive = $derived(
const isChatPanelActive = $derived(
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 (isMd) {
voiceDesktopRoomPanel.update(p => (p === "split" ? "video" : "split"))
if (isDesktopLayout.current) {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
)
} else {
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat"))
videoCallLayout.update(p =>
p === VideoCallLayout.Video ? VideoCallLayout.Chat : VideoCallLayout.Video,
)
}
}
@@ -177,9 +169,9 @@
class={cx(
mediaToggleClass,
"relative shrink-0 overflow-visible",
layoutToggleActive && "text-primary",
isChatPanelActive && "text-primary",
)}
onclick={onLayoutToggle}>
onclick={onChatToggle}>
<span class="relative inline-flex">
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
+11 -9
View File
@@ -87,15 +87,17 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
/** Mobile room UI: full-screen chat vs video (see VoiceWidget layout toggle). */
export const voiceMobileRoomPanel = writable<"chat" | "video">("chat")
/** 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",
}
/** Desktop room UI: messages only, video only, or split (see VoiceWidget layout toggle). */
export const voiceDesktopRoomPanel = writable<"chat" | "video" | "split">("split")
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
const resetVoiceRoomPanels = () => {
voiceMobileRoomPanel.set("chat")
voiceDesktopRoomPanel.set("chat")
const resetVideoCallLayout = () => {
videoCallLayout.set(VideoCallLayout.Chat)
}
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -240,7 +242,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
@@ -408,7 +410,7 @@ export const leaveVoiceRoom = async () => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
resetVideoCallLayout()
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())