rework video + text chat display controls

This commit is contained in:
mplorentz
2026-04-03 10:36:14 -04:00
parent dff9abddf2
commit f7fd557d96
5 changed files with 158 additions and 124 deletions
+11
View File
@@ -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;
}
+54 -47
View File
@@ -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 @@
</div>
{/snippet}
{#if showPanel && (showTileGrid || allowEmptyPanel)}
<div class={panelChrome}>
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
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}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-100/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">
Use the camera or screen share control in the voice widget to share video.
</p>
{#snippet videoPanelBody()}
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
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}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
</div>
{/if}
{/snippet}
{#if showPanel}
<div class={panelChrome}>
{#if variant === "mobile"}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
</div>
{/if}
+61 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition"
import {browser} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
@@ -11,6 +12,7 @@
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 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 Icon from "@lib/components/Icon.svelte"
@@ -32,6 +34,8 @@
currentVoiceSession,
currentVoiceRoom,
voiceState,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
isLocalSpeaking,
leaveVoiceRoom,
toggleMute,
@@ -76,6 +80,45 @@
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 &&
getRoomType(targetRoom) === RoomType.Voice &&
typeof h === "string" &&
relay !== undefined &&
decodeRelay(relay) === targetRoom.url &&
h === targetRoom.h,
)
const layoutToggleActive = $derived(
showVoiceLayoutToggle &&
((!isMd && $voiceMobileRoomPanel === "chat") || (isMd && $voiceDesktopRoomPanel === "split")),
)
const onLayoutToggle = () => {
if (!showVoiceLayoutToggle) return
if (isMd) {
voiceDesktopRoomPanel.update(p => (p === "split" ? "chat" : "split"))
} else {
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat"))
}
}
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
</script>
@@ -96,18 +139,28 @@
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<div class="flex items-center justify-between gap-2">
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
{#if showVoiceLayoutToggle}
<Button
data-tip="Toggle full-screen chat ↔ video (mobile) or split view (desktop)"
class={cx(mediaToggleClass, "shrink-0", layoutToggleActive && "text-primary")}
onclick={onLayoutToggle}>
<Icon icon={ChatRound} size={4} />
</Button>
{/if}
</div>
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
{#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span>
<Button
+13 -20
View File
@@ -87,6 +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")
/** Desktop room UI: messages only, video only, or split (see VoiceWidget layout toggle). */
export const voiceDesktopRoomPanel = writable<"chat" | "video" | "split">("split")
const resetVoiceRoomPanels = () => {
voiceMobileRoomPanel.set("chat")
voiceDesktopRoomPanel.set("chat")
}
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
@@ -229,6 +240,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
@@ -396,6 +408,7 @@ export const leaveVoiceRoom = async () => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
resetVoiceRoomPanels()
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
@@ -429,26 +442,6 @@ export const toggleMute = async () => {
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const roomHasSubscribedRemoteVisual = (room: LiveKitRoom): boolean => {
for (const p of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = p.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) return true
}
}
return false
}
/** True when local camera/screen share is on or any subscribed remote camera/screen track. */
export const videoCallContentActive = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== VoiceState.Connected || !$session) return false
if ($session.cameraOn || $session.screenShareOn) return true
return roomHasSubscribedRemoteVisual($session.room)
},
)
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
+19 -49
View File
@@ -45,7 +45,14 @@
} from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {VoiceState, currentVoiceRoom, videoTileCount, voiceState} from "@app/voice"
import {
VoiceState,
currentVoiceRoom,
videoTileCount,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
voiceState,
} from "@app/voice"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
@@ -66,23 +73,20 @@
$currentVoiceRoom?.h === h,
)
let mobileRoomPanel = $state<"chat" | "video">("chat")
let voiceDesktopPanel = $state<"chat" | "video" | "split">("split")
const showMobileVideoPanel = $derived(
isVoiceRoom && $voiceState === VoiceState.Connected && mobileRoomPanel === "video",
isVoiceRoom && $voiceState === VoiceState.Connected && $voiceMobileRoomPanel === "video",
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && voiceDesktopPanel === "video",
voiceConnectedHere && $voiceDesktopRoomPanel === "video",
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== VoiceState.Connected) {
mobileRoomPanel = "chat"
voiceDesktopPanel = "chat"
voiceMobileRoomPanel.set("chat")
voiceDesktopRoomPanel.set("chat")
prevVideoTileCount = 0
return
}
@@ -96,8 +100,8 @@
}
if (prevVideoTileCount === 0 && n >= 1) {
voiceDesktopPanel = "video"
mobileRoomPanel = "video"
voiceDesktopRoomPanel.set("video")
voiceMobileRoomPanel.set("video")
}
prevVideoTileCount = n
})
@@ -402,40 +406,6 @@
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
{#if voiceConnectedHere}
<div class="flex gap-1 md:hidden">
<Button
class={cx("btn btn-sm", mobileRoomPanel === "chat" && "btn-primary")}
onclick={() => (mobileRoomPanel = "chat")}>
Chat
</Button>
<Button
class={cx("btn btn-sm", mobileRoomPanel === "video" && "btn-primary")}
onclick={() => (mobileRoomPanel = "video")}>
Video
</Button>
</div>
<div class="hidden flex-wrap gap-1 md:flex">
<Button
data-tip="Messages only"
class={cx("btn btn-sm", voiceDesktopPanel === "chat" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "chat")}>
Chat
</Button>
<Button
data-tip="Video only"
class={cx("btn btn-sm", voiceDesktopPanel === "video" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "video")}>
Video
</Button>
<Button
data-tip="Video and chat side by side"
class={cx("btn btn-sm", voiceDesktopPanel === "split" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "split")}>
Video + Chat
</Button>
</div>
{/if}
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
@@ -446,34 +416,34 @@
<div
class={cx(
"flex min-h-0 flex-1 flex-col",
voiceConnectedHere && voiceDesktopPanel === "split" && "md:flex-row",
voiceConnectedHere && $voiceDesktopRoomPanel === "split" && "md:flex-row",
)}>
{#if voiceConnectedHere}
<VideoCallContent
variant="desktop-split"
{url}
{h}
visible={voiceDesktopPanel === "split"}
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={voiceDesktopPanel === "video"}
visible={$voiceDesktopRoomPanel === "video"}
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
{/if}
<div
class={cx(
"flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && voiceDesktopPanel === "video" && "md:hidden",
voiceConnectedHere && $voiceDesktopRoomPanel === "video" && "md:hidden",
)}>
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
<VideoCallContent
variant="mobile"
{url}
{h}
visible={mobileRoomPanel === "video"}
visible={$voiceMobileRoomPanel === "video"}
class="md:hidden" />
{/if}