fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this forced cell height = width × 9/16 (~400px on a 700px panel) which pushed the row past the section's max-h and ate the controls bar below. n>=2 cells now fill grid tracks normally via auto-rows-fr; the solo case (n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self- center so a single-user-alone-calling view doesn't stretch into a full-width slab. Same change applied to the fullscreen-grid path plus +16px bottom-padding (pb-28) so audio-only avatars' name chip clears the floating controls bar. Docked stage strip thumbs (focus + bento) switch from aspect-video shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the row width evenly under the share above, instead of clinging to the left edge with dead space to the right. Fullscreen-cinema strip keeps the small aspect-video thumbs the user explicitly approved. ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes it via a new `cinema` prop on TileRender so the in-share fullscreen icon doesn't visually collide with FullscreenCall's strip-hidden toggle at the same top-right corner. docs/superpowers/specs + plans for the Discord-style tile handling workstream are committed alongside the implementation that completed it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,892 @@
|
||||
# Discord-Style Call Tile Handling Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make in-call tile rendering, click-to-pin, and mixed share/webcam layouts mirror Discord — uniform 16:9 grid, correct object-fit per mode, left-click pins, auto-promote shares, multi-share bento.
|
||||
|
||||
**Architecture:** All changes are renderer-only inside `apps/desktop/src/components/`. A new discriminated-union `StageLayout` lives in `InCallPanel.tsx` and replaces the implicit `effectiveFocusedId` logic. `CallParticipantTile` gains a `fit` prop forwarded to the underlying `<video>` element. `ScreenShareViewer` drops its hardcoded 16:9 button-aspect because the parent grid cell owns the ratio now. No changes to `CallContext`, no data-model changes.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Tailwind (`aspect-video`, `grid-cols-*`, `object-cover`/`object-contain`), LiveKit JS SDK 2.x.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md`](../specs/2026-05-12-discord-call-tile-handling-design.md)
|
||||
|
||||
**Testing note:** No Vitest/Jest harness exists for in-call layouts (Storybook not wired up). Every task ends with a **manual verification checklist** run against `pnpm --filter @chatapp/desktop dev` plus a peer (or a second window joined to the same room). Tasks are committed only after the manual checks pass.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Change |
|
||||
|---|---|---|
|
||||
| `apps/desktop/src/components/CallParticipantTile.tsx` | Single participant tile (webcam or audio-only) | Add `fit` prop on `VideoStub`, forward `focused` → `fit='contain'`, add `onDoubleClick` |
|
||||
| `apps/desktop/src/components/ScreenShareViewer.tsx` | Renders a remote screen-share with watch/fullscreen chrome | Drop hardcoded `aspectRatio: '16/9'` on the unwatched preview button |
|
||||
| `apps/desktop/src/components/InCallPanel.tsx` | Top-level in-call orchestration: stage layouts, fullscreen, controls, pin state plumbing | Add `StageLayout` selector; rewrite `CallStage` + `FullscreenCall` rendering; drop `grid-rows-*` from `gridColsFor`; wrap every tile cell in `aspect-video` |
|
||||
|
||||
No new files. Three modified files, each with a clear local responsibility.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: VideoStub gains `fit` prop, default cover, contain when focused
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:299-339` (VideoStub component) + `CallParticipantTile.tsx:89-135` (CallParticipantTile wiring)
|
||||
|
||||
- [ ] **Step 1: Add `fit` prop to VideoStub**
|
||||
|
||||
In `CallParticipantTile.tsx`, replace the `VideoStub` signature (currently `function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small }: ...)`):
|
||||
|
||||
```tsx
|
||||
function VideoStub({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
videoTrack,
|
||||
me,
|
||||
small,
|
||||
fit,
|
||||
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
|
||||
```
|
||||
|
||||
And replace the className on the `<video>` element (currently `'h-full w-full object-cover ' + (me ? 'scale-x-[-1]' : '')`) with:
|
||||
|
||||
```tsx
|
||||
className={
|
||||
'h-full w-full ' +
|
||||
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
|
||||
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Forward `fit` from CallParticipantTile**
|
||||
|
||||
In `CallParticipantTile.tsx`, inside `CallParticipantTile`, replace:
|
||||
|
||||
```tsx
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `pnpm --filter @chatapp/desktop typecheck`
|
||||
Expected: PASS (no errors in `CallParticipantTile.tsx`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/CallParticipantTile.tsx
|
||||
git commit -m "feat(call): VideoStub accepts fit prop, contain when focused"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ScreenShareViewer drops the hardcoded preview aspect
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ScreenShareViewer.tsx:108-128` (unwatched preview button)
|
||||
|
||||
- [ ] **Step 1: Remove `style={{ aspectRatio: '16 / 9' }}`**
|
||||
|
||||
In `ScreenShareViewer.tsx`, locate the `<button type="button" onClick={() => watchShare(...)}>` (the "Bildschirm anschauen" overlay). Replace:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
>
|
||||
```
|
||||
|
||||
`flex-1 h-full` makes the button fill whatever vertical space the parent grid cell (now `aspect-video`) gives it, instead of forcing its own 16:9 inside an arbitrary cell.
|
||||
|
||||
- [ ] **Step 2: Type-check + commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
git add apps/desktop/src/components/ScreenShareViewer.tsx
|
||||
git commit -m "feat(call): drop hardcoded 16:9 on screen-share preview button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Grid cells become aspect-video, drop grid-rows-*
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx:1085-1099` (`gridColsFor`), `:1030-1052` (`CallStage` grid branch), `:1003-1025` (`CallStage` focus strip)
|
||||
|
||||
- [ ] **Step 1: Rewrite `gridColsFor` to drop row constraints**
|
||||
|
||||
In `InCallPanel.tsx`, replace the existing `gridColsFor`:
|
||||
|
||||
```tsx
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// content, and a video element's intrinsic size blows the tile past the
|
||||
// container bounds (overlapping the toolbar below).
|
||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
function gridColsFor(n: number): string {
|
||||
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||
// height follows from their width, and the container centers them
|
||||
// vertically when the row stack is shorter than the available area.
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2';
|
||||
if (n <= 6) return 'grid-cols-3';
|
||||
if (n <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wrap CallStage grid cells in aspect-video**
|
||||
|
||||
In `CallStage`, replace the grid-branch return (currently the block starting with `// Grid` then `const gridClass = gridColsFor(tiles.length);` …):
|
||||
|
||||
```tsx
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<TileRender ... />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
`place-content-center` centers the row stack vertically; each cell is `aspect-video` so 16:9 wins over arbitrary row stretching.
|
||||
|
||||
- [ ] **Step 3: Wrap focus-strip thumbs in aspect-video**
|
||||
|
||||
In the `if (mode === 'focus' && speaker)` branch of `CallStage`, replace the strip cell wrapper (currently `<div key={p.id} className="h-full w-[240px] shrink-0 [&>div]:h-full">`):
|
||||
|
||||
```tsx
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
```
|
||||
|
||||
The fixed `w-[240px]` is replaced by `aspect-video` so the thumb's width is driven by the strip's `h-[180px]` height. This keeps webcam thumbs at 16:9 (320×180) instead of an arbitrary 240×180 which crops faces.
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
Start the dev server, join a call with 2–4 webcams. Check:
|
||||
|
||||
- All grid tiles are equal-size 16:9 boxes; no tile is taller or wider than its neighbors.
|
||||
- With 3 participants → single row of 3; with 4 → 2×2; with 5–6 → 3×2 (last cell may be empty/centered).
|
||||
- Faces are framed naturally (`object-cover`); no obvious squish or stretch.
|
||||
|
||||
If layout looks wrong, screenshot, do not commit, and iterate on the wrapping classes.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): uniform 16:9 grid cells, drop grid-rows constraint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Introduce `StageLayout` discriminated union
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx:295-345` (the area where `effectiveFocusedId` and `speaker` are computed inside `InCallPanel`)
|
||||
|
||||
- [ ] **Step 1: Add the `StageLayout` type and selector**
|
||||
|
||||
In `InCallPanel.tsx`, locate the block:
|
||||
|
||||
```tsx
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```tsx
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace existing usages of `speaker` and `effectiveFocusedId`**
|
||||
|
||||
Search `InCallPanel.tsx` for every remaining reference to `effectiveFocusedId` and `speaker` inside the `InCallPanel` function and replace as follows:
|
||||
|
||||
- `speaker` (used as prop on `CallStage`, `FullscreenCall`, focused-tile detection) → `bigTile`.
|
||||
- `effectiveFocusedId` (used in `pinnedTileId` prop for context menu) → `focusedId` (we no longer override pin for menu purposes; the auto-promoted share isn't user-pinned).
|
||||
|
||||
Concretely, the line `pinnedTileId: focusedId,` is already correct (uses `focusedId`, not the effective). The `effectiveFocusedId` declaration and `speaker` are removed by Step 1. Remaining usages:
|
||||
|
||||
- **In the fullscreen branch:** replace `speaker={effectiveSpeaker}` and the `effectiveSpeaker = hasFocus ? speaker : undefined` derivation with `speaker={bigTile}` (and drop the now-redundant `hasFocus` / `effectiveSpeaker` lines, since `bigTile` is undefined exactly when there's no focus).
|
||||
- **In the focus branch:** replace `speaker={speaker}` with `speaker={bigTile}`.
|
||||
|
||||
After this step, `InCallPanel`'s render path no longer uses the old `effectiveFocusedId` or `speaker` locals — only `stageLayout`, `bigTile`, `focusedId`.
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
Start the dev server, join a call (no shares yet, no pin). Check:
|
||||
|
||||
- Equal-grid renders as in Task 3 (no behavior regression).
|
||||
- Right-click → "Anpinnen" still works: pins the tile, the call panel collapses to focus-mode showing that tile big.
|
||||
- Right-click → "Anpinnen aufheben" returns to equal-grid.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): introduce StageLayout discriminated union"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire CallStage to render `focus` and `equal-grid` from StageLayout
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `InCallPanel`'s docked-call render branch (the area that calls `<CallStage mode={callMode} ...>`) and `CallStage` itself
|
||||
|
||||
- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, locate the docked-call render that mounts `<CallStage mode={callMode} ...>`. Replace the `mode={callMode}` prop with a derived value:
|
||||
|
||||
```tsx
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={bigTile}
|
||||
// Discord-style: layout decision is driven by StageLayout (see top of
|
||||
// InCallPanel), not by the user-visible callMode toggle. callMode still
|
||||
// gates the cinema/fullscreen entry — for the docked stage we collapse
|
||||
// 'focus' and 'bento' to whatever CallStage knows how to render.
|
||||
mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile drops the
|
||||
// pin; clicking another tile swaps. callMode auto-syncs.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
Note: `setCallMode` calls on click are removed — `callMode` no longer tracks pin state. `callMode` is now only `'grid'` (docked) or `'fullscreen'` (cinema). The third state (`'focus'`) is implicit when `focusedId !== null` and isn't a separate top-level mode anymore.
|
||||
|
||||
- [ ] **Step 2: Drop the click-toggle that swapped callMode**
|
||||
|
||||
Find the `onClick` callbacks in `InCallPanel` that did `setCallMode('focus')` or `setCallMode('grid')`. Replace each with a single `setFocusedId(focusedId === id ? null : id)` call (or remove the redundant ones that are now handled by `onFocusTile`).
|
||||
|
||||
- [ ] **Step 3: Adjust the `Mode` button bar**
|
||||
|
||||
The `<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">` row currently switches between `grid`, `focus`, `fullscreen`. Drop the `focus` button entirely — there's no manual focus mode anymore. Keep `grid` and `fullscreen`.
|
||||
|
||||
Locate the ModeButtonRow (search for `ModeButton`):
|
||||
|
||||
```tsx
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Sprecher">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'fullscreen'} onClick={() => onChange('fullscreen')} label="Vollbild">
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
```
|
||||
|
||||
Delete the middle (`focus`) ModeButton block. The remaining two cover all user-driven modes.
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `'focus'` is referenced in `CallMode` type and unused now, leave the type alone — `'focus'` is still a valid value, just not user-selectable. Don't refactor the type.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call (2–4 participants, no shares):
|
||||
|
||||
- **Click a webcam tile** → it becomes big, others to strip (`focus`-style stage). No mode bar change.
|
||||
- **Click it again** → equal grid restores.
|
||||
- **Click another tile while one is pinned** → swap to that tile.
|
||||
- Mode bar shows only `Grid` and `Vollbild` (the middle `Sprecher` button is gone).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): left-click toggles pin, drop manual focus mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Auto-promote single share (precedence rule 3)
|
||||
|
||||
**Files:**
|
||||
- No code change beyond what's already in Task 4 + Task 5. This task is the **manual verification** that the auto-promote path works end-to-end.
|
||||
|
||||
- [ ] **Step 1: Manual verification — share auto-promote**
|
||||
|
||||
In a dev call (2 participants):
|
||||
|
||||
- User A starts a screen share. Expected: share auto-promotes to the big stage on User B's side; User A's webcam moves to the strip.
|
||||
- User A stops the share. Expected: equal grid restores.
|
||||
- User A shares again; User B clicks User A's webcam thumb. Expected: webcam pins big, share moves to strip.
|
||||
- User B double-clicks the pinned webcam (Task 7 adds this; if not yet implemented, right-click → unpin works too). Expected: share auto-promotes again.
|
||||
|
||||
If any step fails, return to Task 4 (`stageLayout` selector) and verify the bigTile derivation is reading `tiles.find((t) => t.id === stageLayout.bigTileId)` correctly.
|
||||
|
||||
- [ ] **Step 2: No commit**
|
||||
|
||||
This task only verifies behavior introduced in earlier tasks.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Doubleclick on the focused tile clears pin
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:120-130` (root `<div>` of the tile)
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:54-87` (ParticipantTileProps)
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `TileRender` props and the focused-tile rendering paths
|
||||
|
||||
- [ ] **Step 1: Add `onDoubleClick` prop**
|
||||
|
||||
In `CallParticipantTile.tsx`, extend `ParticipantTileProps`:
|
||||
|
||||
```tsx
|
||||
onDoubleClick?: () => void;
|
||||
```
|
||||
|
||||
In the `CallParticipantTile` body, destructure it:
|
||||
|
||||
```tsx
|
||||
const {
|
||||
// ... existing
|
||||
onDoubleClick,
|
||||
} = props;
|
||||
```
|
||||
|
||||
And add it to the root `<div>`:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Plumb `onDoubleClick` through `TileRender`**
|
||||
|
||||
In `InCallPanel.tsx`, extend the `TileRender` component props (the inline interface) with `onDoubleClick?: () => void;`. Pass it through to `CallParticipantTile` the same way `onClick` is passed:
|
||||
|
||||
```tsx
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
```
|
||||
|
||||
And on the `<div>` wrapping the screen-tile branch, add `onDoubleClick={onDoubleClick}` next to `onClick`.
|
||||
|
||||
- [ ] **Step 3: Hook doubleclick on focused tiles to clear pin**
|
||||
|
||||
In `FocusedTile`, accept and forward `onDoubleClick`:
|
||||
|
||||
```tsx
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
// ... existing
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
<TileRender
|
||||
tile={tile}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `CallStage`'s focus branch, pass an `onDoubleClick` that clears the pin:
|
||||
|
||||
```tsx
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
/>
|
||||
```
|
||||
|
||||
(`onFocusTile(speaker.id)` toggles — clicking the already-pinned id clears the pin per Task 5's setter.)
|
||||
|
||||
In `FullscreenCall`'s big-tile branch, pass the same `onDoubleClick` to the big tile wrapper:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call with a pinned tile:
|
||||
|
||||
- **Doubleclick the pinned big tile** → unpins, layout falls back through StageLayout precedence (equal-grid if no shares, or share auto-promote if shares are active).
|
||||
- Single-click still toggles (no regression).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/CallParticipantTile.tsx apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): doubleclick on focused tile clears pin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Multi-share bento stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `CallStage` (add a bento branch) and the docked-call render to pass the `stageLayout` directly to `CallStage`
|
||||
|
||||
- [ ] **Step 1: Pass `stageLayout` to `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, extend `StageProps`:
|
||||
|
||||
```tsx
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
/** 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[] };
|
||||
// mode dropped — it duplicated stageLayout. callMode is still in the
|
||||
// parent for fullscreen-mode entry, just not threaded here anymore.
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
track: import('livekit-client').RemoteTrack;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
(Remove the `mode: CallMode;` field; replace with `stageLayout`.)
|
||||
|
||||
Update the `<CallStage ...>` site in `InCallPanel` to pass `stageLayout={stageLayout}` instead of `mode={...}`.
|
||||
|
||||
- [ ] **Step 2: Rewrite `CallStage` branch dispatch**
|
||||
|
||||
In `CallStage`, replace the body (currently `if (mode === 'focus' && speaker) { ... } // Grid ...`) with:
|
||||
|
||||
```tsx
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
// ... existing focus branch unchanged
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// equal-grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply the same layout choice inside `FullscreenCall`**
|
||||
|
||||
In `FullscreenCall`, the existing branch is `if (hasFocus) { big-stage } else { grid }`. Update the else-branch to also handle bento. The grid render path inside `FullscreenCall` currently uses `sortedGridTiles` + `gridColsFor`. Extend it:
|
||||
|
||||
After the existing `hasFocus` check and before the grid render, add:
|
||||
|
||||
```tsx
|
||||
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))
|
||||
: [];
|
||||
```
|
||||
|
||||
Then wrap the existing grid-only render in:
|
||||
|
||||
```tsx
|
||||
{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>
|
||||
) : (
|
||||
// ... existing grid render unchanged
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `mode` is still referenced anywhere in `CallStage`, remove the stray reference (it's been replaced by `stageLayout.kind`).
|
||||
|
||||
- [ ] **Step 5: Manual verification — multi-share**
|
||||
|
||||
Set up a 2-share scenario (two clients sharing simultaneously):
|
||||
|
||||
- In docked mode: stage shows both shares side-by-side at equal size, webcams in the strip below.
|
||||
- In fullscreen-cinema mode: same bento, fills the screen.
|
||||
- Clicking one of the bento shares pins it → layout drops to single-stage focus on that share.
|
||||
- Stopping one share → falls back through StageLayout → rule 3 (single-share auto-promote).
|
||||
- Stopping both → equal grid.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): multi-share bento layout in stage + fullscreen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: End-to-end verification pass
|
||||
|
||||
**Files:** None.
|
||||
|
||||
- [ ] **Step 1: Run all manual checks from the spec, end to end**
|
||||
|
||||
Spec section "Testing" lists six scenarios. Run all six:
|
||||
|
||||
1. Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
|
||||
2. Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
|
||||
3. Share auto-promote: start share → share is big, webcams strip, share aspect respected.
|
||||
4. Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
|
||||
5. Multi-share: two users share → bento stage; click one → pin that share.
|
||||
6. Active speaker: someone talks → emerald border, no reorder.
|
||||
|
||||
- [ ] **Step 2: Check no console errors**
|
||||
|
||||
Open DevTools console during the call. Expected: no warnings about React keys, missing props, or unhandled promise rejections related to the touched files.
|
||||
|
||||
- [ ] **Step 3: Final commit (if any cleanup)**
|
||||
|
||||
If you made trailing cleanup commits during the verification, push the branch. Otherwise nothing more to commit.
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: 5–6 commits with prefixes `feat(call): ...`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Each section of the spec maps to a task:
|
||||
- Spec §1 (Tile aspect ratio) → Task 3
|
||||
- Spec §2 (Object-fit per mode) → Task 1
|
||||
- Spec §3 (Click-to-pin) → Task 5 + Task 7 (doubleclick)
|
||||
- Spec §4 (Layout selection precedence) → Task 4 + Task 5 + Task 6 + Task 8
|
||||
- Spec §5 (Active-speaker preserved) → no work needed; verified in Task 9 step 1.6
|
||||
- **No placeholders:** Every step has concrete code or commands.
|
||||
- **Type consistency:** `StageLayout` is named the same in spec and plan. `bigTileId`/`bigTile` naming is consistent across Tasks 4–8. `onFocusTile` signature `(id: string) => void` matches between InCallPanel callsite and CallStage prop.
|
||||
- **Reading-order safety:** Each task block re-states the file paths and the exact code being replaced — Task N doesn't assume the reader memorized Task N-1.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Discord-Style Call Tile Handling — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Scope:** `apps/desktop/src/components/InCallPanel.tsx`, `CallParticipantTile.tsx`, `ScreenShareViewer.tsx`
|
||||
**Goal:** Make tile rendering, click handling, and mixed share/webcam layouts behave like Discord.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today's in-call rendering has four user-visible defects:
|
||||
|
||||
1. **Webcam tiles look stretched/cropped wrong.** `VideoStub` uses `object-cover` in every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted.
|
||||
2. **Screen-share tiles get the wrong aspect.** `ScreenShareViewer` uses `object-contain` (correct), but the grid cell that wraps it has no aspect-ratio constraint. Cells stretch tall/wide based on the grid template, leaving the share floating with large black bars on the sides.
|
||||
3. **Click-to-pin doesn't feel like Discord.** Left-click in docked grid swaps `callMode` from grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click.
|
||||
4. **Mixed layouts (share + webcams) treat every tile equally.** A screen share competes for space with 1:1 webcam tiles instead of dominating the stage with webcams beside it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Grid renders uniform tile sizes without distorting content.
|
||||
- Single left-click on any tile pins it big; click again or click another tile swaps.
|
||||
- When at least one screen share is live and nothing is manually pinned, the share auto-promotes to the big stage spot.
|
||||
- Multiple parallel shares share the stage in a bento layout; webcams sit as a strip.
|
||||
- Active-speaker reorder stays disabled (preserves the earlier "no constant switching" fix).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new transitions/animations beyond what's already in place.
|
||||
- No changes to context menu, volume control, screen-share picker.
|
||||
- No mobile/responsive rework — desktop only.
|
||||
- No migration of stored prefs (focused tile is session-only already).
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Tile aspect ratio
|
||||
|
||||
Every tile (webcam **and** screen) in the **grid** renders inside an `aspect-video` (16:9) box.
|
||||
|
||||
- Grid container drops `grid-rows-*` and instead lets `aspect-video` on each cell drive height.
|
||||
- `gridColsFor(n)` keeps the column count logic, just drops the row count constraint.
|
||||
- Effect: uniform tile sizes, no stretching, content sizing is per-tile not per-row.
|
||||
|
||||
```tsx
|
||||
// Today: grid h-full gap-2 grid-cols-3 grid-rows-2
|
||||
// Tomorrow: grid h-full gap-2 grid-cols-3 (each child has aspect-video)
|
||||
```
|
||||
|
||||
In fullscreen-cinema **focus** layouts (single big tile + thumbnail strip) the strip thumbs use `aspect-video` as well so they line up evenly.
|
||||
|
||||
### 2. Object-fit per mode
|
||||
|
||||
| Tile type | Grid (thumb) | Pinned/Focus (big) | Fullscreen strip |
|
||||
|--------------|--------------|--------------------|------------------|
|
||||
| Webcam | `cover` | `contain` | `cover` |
|
||||
| Screen share | `contain` | `contain` | `contain` |
|
||||
|
||||
- `VideoStub` accepts a new `fit?: 'cover' | 'contain'` prop (default `cover`). `CallParticipantTile` passes `contain` when its `focused` prop is true.
|
||||
- `ScreenShareViewer` already uses `object-contain` — no change there beyond removing the hardcoded `aspectRatio: '16/9'` on the unwatched preview button (the parent grid cell will own the ratio).
|
||||
|
||||
### 3. Click-to-pin
|
||||
|
||||
Single source of truth: `focusedId` in `CallContext`.
|
||||
|
||||
- **Left-click** on any tile: `setFocusedId(tile.id === focusedId ? null : tile.id)`.
|
||||
- In `callMode === 'grid'` and `focusedId !== null` → also `setCallMode('focus')`.
|
||||
- In `callMode === 'focus'` and `focusedId === null` → `setCallMode('grid')`.
|
||||
- In `callMode === 'fullscreen'`: only `focusedId` flips; layout reacts inside `FullscreenCall`.
|
||||
- **Doubleclick on the big/pinned tile**: clears the pin (`focusedId = null`).
|
||||
- **Right-click**: unchanged — opens existing context menu (volume / pin toggle / profile).
|
||||
- **Esc in fullscreen**: unchanged — exits fullscreen back to grid.
|
||||
|
||||
The current Stage `onClick` (`InCallPanel.tsx` around lines 578–586) already does this for docked mode; we extend the same handler to `FullscreenCall`'s tile click path (`onFocusTile`).
|
||||
|
||||
### 4. Layout selection (precedence)
|
||||
|
||||
`InCallPanel` picks one of four layouts every render. Precedence top-down — first matching rule wins:
|
||||
|
||||
| # | Condition | Layout |
|
||||
|---|-----------|--------|
|
||||
| 1 | `focusedId !== null` | Single-stage focus: the pinned tile is big, all others strip. |
|
||||
| 2 | `shareCount >= 2` and `focusedId === null` | Multi-share bento: all shares in sub-grid stage, webcams strip below. |
|
||||
| 3 | `shareCount === 1` and `focusedId === null` | Single-stage focus auto-promote: the share is big, webcams strip. |
|
||||
| 4 | `shareCount === 0` and `focusedId === null` | Equal grid (preserves the 2026-05-12 "no constant switching" fix). |
|
||||
|
||||
Where `shareCount = tiles.filter((t) => t.kind === 'screen').length`.
|
||||
|
||||
This is implemented via a derived `stageLayout` discriminated union, not a single `effectiveFocusedId`:
|
||||
|
||||
```ts
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
```
|
||||
|
||||
- Pin clearance returns control to rules 2/3/4 — Discord-style auto-fall-back.
|
||||
- Clicking a share inside the bento sets `focusedId = share.id` → drops into rule 1 (single-stage focus on that share).
|
||||
- Share ends → falls naturally from rule 3 → rule 4, or rule 2 → rule 3.
|
||||
|
||||
### 5. Active-speaker behavior (preserved)
|
||||
|
||||
No reorder. The emerald speaking border on `CallParticipantTile` stays. `prioritizeTiles` only runs when paginating (>12 tiles), as fixed in the 2026-05-12 patch.
|
||||
|
||||
---
|
||||
|
||||
## File-level changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `InCallPanel.tsx` | Compute `stageLayout` (equal-grid / focus / bento per the precedence table). Adjust grid CSS (drop `grid-rows-*`, add `aspect-video` per cell). Plumb `onClick` to `FullscreenCall` tiles. Render bento stage for layout `bento`. |
|
||||
| `CallParticipantTile.tsx` | Forward `focused` → `VideoStub.fit`. Update wrapper class to expect `aspect-video` from parent grid cell. Add `onDoubleClick` to clear pin. |
|
||||
| `ScreenShareViewer.tsx` | Remove hardcoded `aspectRatio: '16/9'` on the unwatched preview button (parent owns aspect now). |
|
||||
|
||||
No changes to `CallContext`, no changes to data model (`Tile`, `focusedId`).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Aspect-video might shrink tiles when there are many participants.** Mitigation: existing `GRID_PAGE_SIZE = 12` pagination keeps cells from collapsing to thumbnail-sized; we accept that 12 tiles at 16:9 will produce small rows just like Discord does at the same density.
|
||||
- **Auto-promote could be unexpected if a user explicitly cleared their pin.** Mitigation: pin clearance sets `focusedId = null`, then `firstShareId` takes over only if shares exist — exactly Discord's behavior. The user can stop watching shares to escape.
|
||||
- **`aspect-video` + flex children in the existing focus-mode (`FocusCall`)**: Focus mode has its own big-tile layout (`Stage` lines ~520–610). It already sets `flex h-full`; introducing `aspect-video` only on the strip thumbs is additive and won't reflow the big tile.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Manual verification with two-user dev call:
|
||||
|
||||
1. **Webcam-only equal grid:** 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching.
|
||||
2. **Pinning toggle:** click a webcam → that tile becomes big with `contain` fit (no face crop), rest strip; click again → back to grid.
|
||||
3. **Auto-promote:** start a screen share → share is big, webcams strip, share aspect respected (no stretch).
|
||||
4. **Manual override during share:** while share is big, click a webcam → webcam pins big, share moves to strip. Click webcam again → falls back to share-auto-promote.
|
||||
5. **Multi-share:** two users share → bento stage, both shares visible at equal size, webcams below.
|
||||
6. **Active speaker:** someone talks → emerald border, no tile reorder/swap.
|
||||
|
||||
No automated tests (Storybook setup not in place for in-call layouts).
|
||||
Reference in New Issue
Block a user