feat(call): multi-share bento layout in stage + fullscreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-12 18:54:23 +02:00
parent c87d4e82d3
commit e56533918e
+122 -5
View File
@@ -557,7 +557,7 @@ export function InCallPanel({ conversation }: Props) {
<CallStage <CallStage
tiles={tiles} tiles={tiles}
speaker={bigTile} speaker={bigTile}
mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'} stageLayout={stageLayout}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
e2ee={isE2EEActive} e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
@@ -891,7 +891,12 @@ function ModeButton({
interface StageProps { interface StageProps {
tiles: Tile[]; tiles: Tile[];
speaker: Tile | undefined; speaker: Tile | undefined;
mode: CallMode; /** Discriminated layout decision driven by InCallPanel's StageLayout
* selector. Drives the bento-vs-grid-vs-focus render branch. */
stageLayout:
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
activeSpeakers: Set<string>; activeSpeakers: Set<string>;
e2ee: boolean; e2ee: boolean;
remoteScreenShares: { remoteScreenShares: {
@@ -981,7 +986,7 @@ function TileRender({
function CallStage({ function CallStage({
tiles, tiles,
speaker, speaker,
mode, stageLayout,
activeSpeakers, activeSpeakers,
e2ee, e2ee,
remoteScreenShares, remoteScreenShares,
@@ -990,7 +995,7 @@ function CallStage({
onTileContextMenu, onTileContextMenu,
compact = false, compact = false,
}: StageProps) { }: StageProps) {
if (mode === 'focus' && speaker) { if (stageLayout.kind === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id); const others = tiles.filter((p) => p.id !== speaker.id);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3"> <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
@@ -1034,7 +1039,66 @@ function CallStage({
); );
} }
// Grid if (stageLayout.kind === 'bento') {
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
const bentoCols = gridColsFor(shares.length);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<div
className={
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
}
>
{shares.map((s) => (
<div
key={s.id}
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{webcams.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{webcams.map((w) => (
<div
key={w.id}
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid (equal-grid fallthrough)
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')}>
@@ -1205,6 +1269,17 @@ function FullscreenCall({
const hasFocus = speaker !== undefined; const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : []; const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const fsShareIds = tiles
.filter((t) => t.kind === 'screen')
.map((t) => t.id);
const bentoMode = !hasFocus && fsShareIds.length >= 2;
const bentoShares = bentoMode
? tiles.filter((t) => fsShareIds.includes(t.id))
: [];
const bentoWebcams = bentoMode
? tiles.filter((t) => !fsShareIds.includes(t.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 and prioritize active speakers onto page 1. // exist, slice them into pages and prioritize active speakers onto page 1.
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle // Otherwise keep a stable order (Discord-style) so tiles don't shuffle
@@ -1273,6 +1348,48 @@ function FullscreenCall({
</div> </div>
)} )}
</> </>
) : bentoMode ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<div className="min-h-0 flex-1">
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
{bentoShares.map((s) => (
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{bentoWebcams.length > 0 && (
<div className="flex h-[180px] gap-2 overflow-x-auto">
{bentoWebcams.map((w) => (
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
) : ( ) : (
<div className="min-h-0 flex-1 p-4"> <div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}> <div className={'grid h-full gap-2 ' + gridClass}>