feat(call): multi-share bento layout in stage + fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user