diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 45f82737..46829f05 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -2,6 +2,9 @@ import cx from "classnames" import {Track} from "livekit-client" import {displayProfileByPubkey, loadProfile} from "@welshman/app" + import Pin from "@assets/icons/pin.svg?dataurl" + 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 { @@ -9,6 +12,8 @@ currentVoiceRoom, videoCallContentActive, videoCallLayoutRevision, + videoPrimaryTileKey, + toggleVideoPrimaryTile, pubkeyFromLiveKitIdentity, } from "@app/voice" @@ -30,6 +35,8 @@ source: Track.Source.Camera | Track.Source.ScreenShare } + type TileLayout = "spotlight" | "default" | "strip" + const {variant, url, h, visible = true, class: className = ""}: Props = $props() const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h) @@ -102,6 +109,33 @@ return out }) + /** 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 primaryTile = $derived.by(() => { + const k = $videoPrimaryTileKey + if (k === undefined) return undefined + return tiles.find(t => tileKey(t) === k) + }) + + const secondaryTiles = $derived.by(() => { + const p = primaryTile + if (p === undefined) return tiles + const pk = tileKey(p) + return tiles.filter(t => tileKey(t) !== pk) + }) + + const useSpotlightLayout = $derived(primaryTile !== undefined) + const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2) + + $effect(() => { + const k = $videoPrimaryTileKey + if (k === undefined) return + if (!tiles.some(t => tileKey(t) === k)) { + videoPrimaryTileKey.set(undefined) + } + }) + $effect(() => { for (const t of tiles) { const pk = pubkeyFromLiveKitIdentity(t.identity) @@ -116,43 +150,90 @@ } const showTileGrid = $derived(tiles.length > 0) + + const spotlightHandlerFor = (key: string) => () => { + toggleVideoPrimaryTile(key) + } + + 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", + 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", + 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", + className, + ), + ) -{#if showPanel && (showTileGrid || allowEmptyPanel)} +{#snippet videoTile(tile: Tile, layout: TileLayout)} +{/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 40f0b1d1..d186194d 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -50,6 +50,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) => { @@ -148,6 +155,7 @@ export const deriveVoiceParticipants = (url: string, h: string) => const onRoomDisconnected = (reason?: DisconnectReason) => { videoCallLayoutRevision.set(0) + videoPrimaryTileKey.set(undefined) speakingParticipants.set([]) participantPubkeyMap.set(new Map()) currentVoiceSession.set(undefined) @@ -308,6 +316,7 @@ export const leaveVoiceRoom = async () => { voiceState.set("disconnected") videoCallLayoutRevision.set(0) + videoPrimaryTileKey.set(undefined) currentVoiceSession.set(undefined) session.room.disconnect() speakingParticipants.set([])