Files
backspace/packages/web/src/components/voice/VoiceGrid.tsx
T
Jannis Braun 78555a43f8 fix(voice): stabilize grid layout across focus-mode and fullscreen transitions
Refactor useGridLayout from RefObject to callback-ref API so the
ResizeObserver reattaches when the grid container remounts after
focus-mode toggle. Fixes tiles rendering at stale fullscreen dimensions
on 4:3 monitors.

Also clamp focus-mode participant strip height (max-h-[20vh] min-h-[80px])
to prevent it from crowding the focused stream on short viewports.
2026-03-25 03:08:45 +01:00

182 lines
6.6 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
import { deriveGridTiles } from '../../hooks/useLiveKit';
import { useGridLayout } from '../../hooks/useGridLayout';
import type { ParticipantInfo, GridTile } from '../../hooks/useLiveKit';
interface VoiceGridProps {
participants: ParticipantInfo[];
}
export function VoiceGrid({ participants }: VoiceGridProps) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const [stripHidden, setStripHidden] = useState(false);
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
const { cols, tileWidth, tileHeight, ref: gridRef } = useGridLayout(tiles.length);
// Reset strip visibility when focus target changes
useEffect(() => {
setStripHidden(false);
}, [focusedParticipantId]);
// Unfocus if the focused stream tile no longer exists
useEffect(() => {
const currentStreamKeys = new Set(
tiles
.filter(
(t): t is GridTile & { kind: 'stream' } =>
t.kind === 'stream' && t.screenTrack?.readyState === 'live',
)
.map((t) => t.key),
);
if (
focusedParticipantId &&
focusedParticipantId.endsWith(':stream') &&
!currentStreamKeys.has(focusedParticipantId)
) {
setFocusedParticipant(null);
}
}, [tiles, focusedParticipantId, setFocusedParticipant]);
if (tiles.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="currentColor"
className="text-txt-tertiary/40 mx-auto mb-3"
>
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<p className="text-txt-tertiary text-sm">
Waiting for others to join...
</p>
</div>
</div>
);
}
const focusedTile = focusedParticipantId
? tiles.find((t) => t.key === focusedParticipantId)
: null;
// Render a single tile polymorphically
const renderTile = (tile: GridTile, large?: boolean) =>
tile.kind === 'user' ? (
<VoiceUser tile={tile} large={large} />
) : (
<StreamTile tile={tile} large={large} />
);
// Focus mode: one large tile + bottom strip
if (focusedTile) {
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Main focused view */}
<div
className="flex-1 p-2 min-h-0 cursor-pointer relative"
onClick={() => setFocusedParticipant(null)}
title="Click to return to grid view"
>
{renderTile(focusedTile, true)}
{/* Grid button — top-right */}
<button
onClick={(e) => {
e.stopPropagation();
setFocusedParticipant(null);
}}
className="absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors"
title="Back to grid view"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
</svg>
<span className="text-xs font-medium">Grid</span>
</button>
</div>
{/* Centered Hide/Show Members button — divider between focused tile and strip */}
{otherTiles.length > 0 && (
<div className="flex justify-center flex-shrink-0 py-1">
<button
onClick={(e) => {
e.stopPropagation();
setStripHidden(!stripHidden);
}}
className="px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs"
title={stripHidden ? 'Show Members' : 'Hide Members'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
{stripHidden
? <path d="M7 14l5-5 5 5z" />
: <path d="M7 10l5 5 5-5z" />
}
</svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
<span>{stripHidden ? 'Show Members' : 'Hide Members'}</span>
</button>
</div>
)}
{/* Bottom strip of other tiles */}
{!stripHidden && otherTiles.length > 0 && (
<div className="h-[120px] max-h-[20vh] min-h-[80px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-surface-base/50 overflow-x-auto no-scrollbar">
{otherTiles.map((t) => (
<div
key={t.key}
onClick={() => setFocusedParticipant(t.key)}
className="h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity"
>
{renderTile(t)}
</div>
))}
</div>
)}
</div>
);
}
// Default grid mode — container-aware layout via ResizeObserver
return (
<div ref={gridRef} className="flex-1 overflow-hidden min-h-0">
<div
className="grid h-full w-full"
style={{
gridTemplateColumns: `repeat(${cols}, ${tileWidth}px)`,
gridAutoRows: `${tileHeight}px`,
gap: '8px',
justifyContent: 'center',
alignContent: 'center',
}}
>
{tiles.map((t) => (
<div
key={t.key}
onClick={() => setFocusedParticipant(t.key)}
className="cursor-pointer hover:opacity-90 transition-opacity"
>
{renderTile(t)}
</div>
))}
</div>
</div>
);
}