feat(call): uniform 16:9 grid cells, drop grid-rows constraint
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||||
import { RoomEvent, Track } from 'livekit-client';
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||||
}, [soundboardCount]);
|
}, [soundboardCount]);
|
||||||
|
|
||||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
|
||||||
// rather than "exactly one speaker" — matches Discord more closely and
|
|
||||||
// handles the case where two people talk briefly without the focus
|
|
||||||
// collapsing to nobody.
|
|
||||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
|
||||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
for (const id of activeSpeakers) {
|
|
||||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
|
||||||
setLastStartedSpeakerId(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
|
||||||
}, [activeSpeakers]);
|
|
||||||
|
|
||||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||||
@@ -385,24 +369,13 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (callMode === 'fullscreen') {
|
if (callMode === 'fullscreen') {
|
||||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
// Discord-style: without an explicit pin, fullscreen stays as a stable
|
||||||
// the person who most recently started speaking (tracked in
|
// equal-size grid. Focus only kicks in when the user pins a tile
|
||||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
// (focusedId !== null). Auto-promoting the latest speaker caused the
|
||||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
// whole layout to flip on every utterance, which is not what users
|
||||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
// expect from a "cinema" view.
|
||||||
// because two people briefly overlapping shouldn't kick us out of
|
const hasFocus = focusedId !== null;
|
||||||
// auto-focus.
|
const effectiveSpeaker = hasFocus ? speaker : undefined;
|
||||||
const autoSpeaker =
|
|
||||||
focusedId === null && lastStartedSpeakerId !== null
|
|
||||||
? tiles.find(
|
|
||||||
(t) =>
|
|
||||||
t.kind === 'user' &&
|
|
||||||
!t.self &&
|
|
||||||
t.userId === lastStartedSpeakerId,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
|
||||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{micError && (
|
{micError && (
|
||||||
@@ -1032,7 +1005,7 @@ function CallStage({
|
|||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||||
>
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
@@ -1058,9 +1031,16 @@ function CallStage({
|
|||||||
const gridClass = gridColsFor(tiles.length);
|
const gridClass = gridColsFor(tiles.length);
|
||||||
return (
|
return (
|
||||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
<div
|
||||||
|
className={
|
||||||
|
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||||
|
}
|
||||||
|
>
|
||||||
{tiles.map((p) => (
|
{tiles.map((p) => (
|
||||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||||
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
@@ -1112,17 +1092,16 @@ function FocusedTile({
|
|||||||
const GRID_PAGE_SIZE = 12;
|
const GRID_PAGE_SIZE = 12;
|
||||||
|
|
||||||
function gridColsFor(n: number): string {
|
function gridColsFor(n: number): string {
|
||||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||||
// space). Without this, implicit rows default to auto → they size to
|
// height follows from their width, and the container centers them
|
||||||
// content, and a video element's intrinsic size blows the tile past the
|
// vertically when the row stack is shorter than the available area.
|
||||||
// container bounds (overlapping the toolbar below).
|
if (n <= 1) return 'grid-cols-1';
|
||||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
if (n === 2) return 'grid-cols-2';
|
||||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
if (n === 3) return 'grid-cols-3';
|
||||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
if (n === 4) return 'grid-cols-2';
|
||||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
if (n <= 6) return 'grid-cols-3';
|
||||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
if (n <= 9) return 'grid-cols-3';
|
||||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
return 'grid-cols-4';
|
||||||
return 'grid-cols-4 grid-rows-3';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote self + active speakers to the front of the tile list. Stable
|
// Promote self + active speakers to the front of the tile list. Stable
|
||||||
@@ -1217,11 +1196,13 @@ function FullscreenCall({
|
|||||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||||
|
|
||||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
// exist, slice them into pages and prioritize active speakers onto page 1.
|
||||||
// below the current page (someone left).
|
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
|
||||||
|
// whenever someone speaks.
|
||||||
|
const needsPagination = tiles.length > GRID_PAGE_SIZE;
|
||||||
const sortedGridTiles = useMemo(
|
const sortedGridTiles = useMemo(
|
||||||
() => prioritizeTiles(tiles, activeSpeakers),
|
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
|
||||||
[tiles, activeSpeakers],
|
[tiles, activeSpeakers, needsPagination],
|
||||||
);
|
);
|
||||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user