Calls: screen share, mic/cam toggles, status badges, pin + fullscreen, call timer

Screen sharing swaps the outgoing video track on every peer's sender with
replaceTrack (same m-line, no renegotiation); the camera returns when the
share stops, including via the browser's own "Stop sharing" bar. Mic and
camera toggles flip track.enabled. A new Status signal kind broadcasts
mic/cam/screen state so tiles can show badges; newcomers get it directed
when their Join arrives.

Tiles carry a name label, click to pin as a stage (a peer's screen share
pins itself), double-click for fullscreen. The call bar has an elapsed
timer and a participant count. Controls are icon-only inline SVGs on
currentColor: plain with a strike when off, a soft glow and ticking LED
when live. Red is reserved for leave.

Also fixed along the way: the peer map was a non-reactive StoredValue, so
the tile list never re-rendered when someone joined; ICE candidates that
arrive before the remote description are now buffered instead of
rejected; a peer whose connection fails or closes is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3sD29ozDvqA7jozeZXZoB
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-02 17:26:00 +02:00
co-authored by Claude Fable 5.1
parent c1c337c2a2
commit 78e9c9ff94
6 changed files with 847 additions and 125 deletions
+141 -9
View File
@@ -534,8 +534,8 @@ body::before {
.call-join {
font-family: var(--mono);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.08em;
font-size: 1rem;
line-height: 1;
color: var(--ink-0);
background: var(--signal);
border: none;
@@ -554,26 +554,156 @@ body::before {
gap: 0.6rem;
}
.video-tile {
.tile {
position: relative;
width: 200px;
height: 150px;
background: var(--ink-0);
border: 1px solid var(--line-hot);
object-fit: cover;
cursor: pointer;
user-select: none;
transition: border-color 120ms;
}
.video-tile-local { border-color: var(--signal-dim); }
.tile:hover { border-color: var(--signal-dim); }
.tile.local { border-color: var(--signal-dim); }
.tile.pinned { border-color: var(--signal); box-shadow: 0 0 0 1px var(--signal); }
.video-tile {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--ink-0);
}
/* a shared screen is letterboxed, never cropped */
.tile.screen .video-tile { object-fit: contain; }
.tile.cam-off .video-tile { opacity: 0.25; }
/* stage mode: the pinned tile fills the row, everyone else in a strip */
.video-grid.staged .tile.pinned {
order: -1;
flex: 1 0 100%;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
max-height: 60vh;
}
.video-grid.staged .tile.pinned .video-tile { object-fit: contain; }
.video-grid.staged .tile:not(.pinned) { width: 140px; height: 105px; }
.tile-label,
.tile-flags {
position: absolute;
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.06em;
line-height: 1;
pointer-events: none;
}
.tile-label {
left: 0;
bottom: 0;
padding: 0.3rem 0.45rem;
color: var(--text);
background: color-mix(in srgb, var(--ink-0) 75%, transparent);
}
.tile-flags {
top: 0.3rem;
right: 0.3rem;
display: flex;
gap: 0.25rem;
}
.flag {
display: inline-flex;
padding: 0.22rem;
color: var(--text-dim);
background: color-mix(in srgb, var(--ink-0) 80%, transparent);
border: 1px solid var(--line-hot);
}
.flag .call-icon { width: 12px; height: 12px; }
.flag.on { color: var(--ink-0); background: var(--signal); border-color: var(--signal); }
/* control bar */
.call-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem 1rem;
font-family: var(--mono);
font-size: 0.78rem;
}
.call-meta {
display: inline-flex;
align-items: center;
gap: 0.6rem;
color: var(--text-dim);
letter-spacing: 0.06em;
}
.call-timer { color: var(--text); font-variant-numeric: tabular-nums; }
.call-count { display: inline-flex; align-items: center; gap: 0.3rem; font-variant-numeric: tabular-nums; }
.call-count .call-icon { width: 14px; height: 14px; }
.call-ctls { display: inline-flex; flex-wrap: wrap; gap: 0.4rem; }
.call-ctl {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--text);
background: none;
border: 1px solid var(--line-hot);
padding: 0.5rem 0.7rem;
cursor: pointer;
transition: background 120ms, border-color 120ms, color 120ms;
}
.call-ctl:hover { border-color: var(--signal-dim); }
/* capture controls: plain when off, and when live a soft glow with the
same ticking LED as the bus status - "this is recording", not an alarm */
.call-ctl .led { width: 6px; height: 6px; }
.call-ctl.live {
border-color: var(--signal-dim);
box-shadow: 0 0 10px color-mix(in srgb, var(--signal) 30%, transparent);
}
.call-ctl.live .led {
background: var(--signal);
box-shadow: 0 0 8px var(--signal);
animation: blink 1.4s steps(1) infinite;
}
.call-ctl.on { color: var(--ink-0); background: var(--signal); border-color: var(--signal); }
.call-icon { display: block; width: 18px; height: 18px; }
/* strike: a background stroke in the control's own fill colour first, so
the slash reads as cutting the glyph rather than overlapping it */
.call-icon-strike-bg { stroke: var(--ink-1); stroke-width: 5; }
.call-ctl.on .call-icon-strike-bg { stroke: var(--signal); }
.flag .call-icon-strike-bg { stroke: var(--ink-0); }
.call-leave {
align-self: flex-start;
font-family: var(--mono);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.08em;
font-size: 0.95rem;
line-height: 18px;
color: var(--text);
background: none;
border: 1px solid var(--alarm);
padding: 0.5rem 0.9rem;
padding: 0.5rem 0.8rem;
cursor: pointer;
transition: background 120ms;
}
@@ -618,6 +748,8 @@ body::before {
.room-link { border-left: 0; border-bottom: 2px solid transparent; }
.room-link.active { border-bottom-color: var(--signal); }
.msg { grid-template-columns: auto 1fr; grid-template-rows: auto auto; }
.tile, .video-grid.staged .tile:not(.pinned) { width: calc(50% - 0.3rem); height: auto; aspect-ratio: 4 / 3; }
.video-grid.staged .tile.pinned { width: 100%; }
.msg-time { grid-row: 1; order: 2; justify-self: end; }
.msg-text { grid-column: 1 / -1; }
}