Camera rounds: several people's cameras at once in the voice tiles

- A viewer holds one screen and any number of cameras; cameras play in the
  tiles (own one mirrored), a click enlarges one
- "Watch all cameras" joins the round: cameras in the channel are watched as
  they come on. Turning your own camera on joins it too
- Sound, statistics and stop work per stream
- Relay quotas and port range raised: a round uses a route per pair of people

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 00:14:22 +02:00
co-authored by Claude Opus 5.5
parent 817f822771
commit 1e4bda4e74
13 changed files with 252 additions and 115 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
// its minimum; the picture gets the rest. Unset until dragged, then the picture decides.
const LOG_MIN = 124;
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
const streamAbove = $derived(videoOnly && session.status === 'connected' && ((!!share.stream && here(share.host)) || (!!share.watching && here(share.watching.host))));
const streamAbove = $derived(videoOnly && session.status === 'connected' && ((!!share.stream && here(share.host)) || share.watches.some(w => here(w.host))));
let logHeight = $state(Number((() => { try { return localStorage.getItem('mumh5.stackedChatHeight'); } catch { return null; } })()) || 0);
const logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
+1 -1
View File
@@ -131,7 +131,7 @@
<Icon name={session.sharing[user.session]} size={16} />
<span>{isSelf ? `You are sharing your ${session.sharing[user.session]}` : `Sharing their ${session.sharing[user.session]}`}</span>
{#if isSelf}<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
{:else if share.watching?.session === user.session}<button class="btn" onclick={() => share.unwatch()}>Stop watching</button>
{:else if share.watchOf(user.session)}<button class="btn" onclick={() => share.unwatch(user.session)}>Stop watching</button>
{:else}<button class="btn primary" onclick={() => share.watch(sessions.active, user.session)}>Watch</button>{/if}
</div>
{/if}
+3 -2
View File
@@ -6,7 +6,8 @@
// "Stats for nerds" over the stream: codecs, picture, bitrate and the network path, read
// from the WebRTC connection once a second. own: our stream (one block per viewer).
let { own, onclose }: { own: boolean; onclose: () => void } = $props();
// session: whose stream, when it is one we watch
let { own, session: watched = null, onclose }: { own: boolean; session?: number | null; onclose: () => void } = $props();
type Row = [string, string];
let blocks = $state<{ title: string; rows: Row[] }[]>([]);
@@ -83,7 +84,7 @@
}
async function read() {
const reports = await share.reports(own);
const reports = await share.reports(own, watched ?? undefined);
blocks = reports.map(r => {
const user = r.viewer != null ? session.user(r.viewer) : undefined;
return { title: r.viewer == null ? '' : `To ${user ? store.displayName(user) : `#${r.viewer}`}`, rows: describe(r.report, String(r.viewer)) };
+103 -57
View File
@@ -13,8 +13,9 @@
import ShareStats from './ShareStats.svelte';
// People in your voice channel as tiles. Shown on windows too narrow for the member list
// (always), and on any window while someone in the channel shares a screen. A stream is shown
// large above the tiles, which become a strip; the tiles themselves never carry the picture.
// (always), and on any window while someone in the channel shares something. A screen is shown
// large above the tiles, which become a strip; cameras play in the tiles themselves, as many
// as are on, and a click enlarges one.
// videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list
// above already shows who is here)
let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props();
@@ -24,20 +25,28 @@
const inChannel = $derived(self ? session.usersIn(self.channelId) : []);
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null);
const watches = $derived(share.watches.filter(w => here(w.host)));
// The screen being watched; cameras are watched next to it
const watching = $derived(watches.find(w => w.kind === 'screen') ?? null);
const watchOf = (s: number) => watches.find(w => w.session === s);
const mine = $derived(share.stream && here(share.host) ? share.stream : null);
const anySharing = $derived(inChannel.some(u => session.sharing[u.session]));
const visible = $derived(!!self && (videoOnly ? !!watching || !!mine : always || anySharing || !!watching || !!mine));
const anyCamera = $derived(inChannel.some(u => session.sharing[u.session] === 'camera' && u.session !== self?.session));
const visible = $derived(!!self && (videoOnly ? watches.length > 0 || !!mine : always || anySharing || watches.length > 0 || !!mine));
// The picture a tile can show: our own stream, or the one we are watching
// The picture we have of someone: our own stream, or one we are watching
function streamOf(s: number): MediaStream | null {
if (s === self?.session) return mine;
return watching?.session === s ? watching.stream : null;
return watchOf(s)?.stream ?? null;
}
// A camera plays in the person's tile; a screen never does
const camOf = (s: number) => session.sharing[s] === 'camera' ? streamOf(s) : null;
// People with a stream we can show come first, then others who share
const rank = (s: number) => streamOf(s) ? 0 : session.sharing[s] ? 1 : 2;
const people = $derived([...inChannel].sort((a, b) => rank(a.session) - rank(b.session)));
// Who gets a tile: everyone, or in the stacked layout only the people whose camera we see
const shown = $derived(videoOnly ? people.filter(u => camOf(u.session)) : people);
// Tile on the large stage
let focus = $state<number | null>(null);
@@ -59,12 +68,17 @@
let hadMine = false;
$effect(() => {
const has = !!mine;
if (has && !hadMine && focus == null && self) focus = self.session;
if (has && !hadMine && focus == null && self && share.kind === 'screen') focus = self.session;
else if (!has && focus === self?.session) focus = null;
hadMine = has;
});
// Without tiles there is nothing to choose: the watched stream, else our own
$effect(() => { if (videoOnly) focus = watching ? watching.session : mine ? self?.session ?? null : null; });
let lastSolo: number | null = null;
$effect(() => {
if (!videoOnly) return;
const solo = watching ? watching.session : mine && share.kind === 'screen' ? self?.session ?? null : null;
if (solo !== lastSolo) { focus = solo; lastSolo = solo; }
});
// The stage is as tall as the user drags it; the tiles grow and shrink to fill it. The
// meeting view (one picture large) remembers its own height.
@@ -79,7 +93,7 @@
let gridW = $state(0), gridH = $state(0);
const GAP = 8, PAD = 8;
const cols = $derived.by(() => {
const n = people.length;
const n = shown.length;
const w = gridW - PAD * 2, h = gridH - PAD * 2;
if (n < 2 || w <= 0 || h <= 0) return 1;
let best = 1, bestSize = 0;
@@ -93,9 +107,9 @@
});
// In the meeting view everyone sits in one row under the picture
const rows = $derived.by(() => {
if (focused) return [people];
const out: (typeof people)[] = [];
for (let i = 0; i < people.length; i += cols) out.push(people.slice(i, i + cols));
if (focused) return [shown];
const out: (typeof shown)[] = [];
for (let i = 0; i < shown.length; i += cols) out.push(shown.slice(i, i + cols));
return out;
});
const avatar = $derived.by(() => {
@@ -126,14 +140,17 @@
return { update(next: MediaStream | null) { if (node.srcObject !== next) attach(next); } };
}
// Sound comes from one hidden player, so it plays once however many pictures are shown
let player = $state<HTMLAudioElement>();
const hasSound = $derived(!!watching?.stream?.getAudioTracks().length);
$effect(() => {
if (!player) return;
player.volume = store.settings.shareVolume;
player.muted = store.settings.shareVolume === 0;
});
// Sound comes from hidden players, one per stream that has any, so it plays once however
// many pictures are shown. They all follow the stream volume.
const withSound = $derived(watches.filter(w => w.stream?.getAudioTracks().length));
const hasSound = $derived(focus != null && withSound.some(w => w.session === focus));
function loud(node: HTMLMediaElement) {
$effect(() => {
node.volume = store.settings.shareVolume;
node.muted = store.settings.shareVolume === 0;
});
}
const focusWatch = $derived(focus != null ? watchOf(focus) : undefined);
function tileClick(session_: number) {
if (streamOf(session_)) focus = focus === session_ ? null : session_;
@@ -236,12 +253,12 @@
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{/if}
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>
{#if focus !== self.session}<button class="btn small" onclick={() => { if (focus != null) share.unwatch(focus); }}>Stop watching</button>
{:else if videoOnly}
<span class="status" role="status" title="People watching your {share.kind}">{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
{#if !videoOnly}<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>{/if}
{#if !videoOnly || shown.length}<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>{/if}
</div>
<!-- svelte-ignore a11y_media_has_caption -->
{#if popped}
@@ -250,29 +267,74 @@
<div class="pic" role="presentation" oncontextmenu={streamMenu}
ontouchstart={touchStart} ontouchend={touchEnd} ontouchmove={touchEnd} ontouchcancel={touchEnd}>
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
{#if focus !== self.session && watching && watching.state !== 'live'}
<p class="pending" class:bad={watching.state === 'failed'}>{watching.state === 'failed'
{#if focus !== self.session && focusWatch && focusWatch.state !== 'live'}
<p class="pending" class:bad={focusWatch.state === 'failed'}>{focusWatch.state === 'failed'
? 'Could not connect to the stream. Across the internet both of you need a STUN server; networks that block direct connections need a relay. Both are in Settings, Voice.'
: 'Connecting to the stream...'}</p>
{/if}
{#if showStats}<ShareStats own={focus === self.session} onclose={() => (showStats = false)} />{/if}
{#if showStats}<ShareStats own={focus === self.session} session={focus} onclose={() => (showStats = false)} />{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet tiles()}
<div class="tiles" class:strip={!!focused} role="group" aria-label="People in this channel" bind:clientWidth={gridW} bind:clientHeight={gridH}>
{#each rows as row, r (r)}
<div class="row" role="presentation">
{#each row as u (u.session)}
{@const stream = streamOf(u.session)}
{@const sharing = session.sharing[u.session]}
{@const isSelf = u.session === self?.session}
{@const cam = camOf(u.session)}
{@const w = watchOf(u.session)}
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
onclick={() => tileClick(u.session)}
oncontextmenu={e => menu.show(e, userMenu(u))}
title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? `Sharing their ${sharing}. Click to watch` : store.displayName(u)}
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
{#if cam}
<!-- svelte-ignore a11y_media_has_caption -->
<video class:mirror={isSelf} use:media={cam} autoplay playsinline muted></video>
{:else}
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
{/if}
<span class="who">{store.displayName(u)}</span>
{#if w && w.state !== 'live'}
<span class="note">{w.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
{/if}
{#if sharing}<span class="badge live"><Icon name={sharing} size={13} /></span>
{:else if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/each}
</div>
{/snippet}
{#if visible && self && videoOnly}
<section class="stage solo" aria-label="Voice channel">
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
{#each withSound as w (w.session)}<audio use:media={w.stream} use:loud autoplay></audio>{/each}
{#if focused}
{@render spot()}
{:else if watching}
<p class="wait" class:bad={watching.state === 'failed'}>
<span>{watching.state === 'failed' ? 'Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice.' : 'Connecting to the stream...'}</span>
<button class="btn small" onclick={() => share.unwatch()}>{watching.state === 'failed' ? 'Close' : 'Cancel'}</button>
<button class="btn small" onclick={() => share.unwatch(watching.session)}>{watching.state === 'failed' ? 'Close' : 'Cancel'}</button>
</p>
{/if}
<!-- Cameras: a grid of just the pictures -->
{#if shown.length && !focused}
<div class="camgrid" style:height="{tilesHeight}px">
<ResizeHandle bind:width={tilesHeight} edge="bottom" min={110} max={maxHeight} initial={210} storageKey="mumh5.stageHeight" label="Resize the tiles" />
{@render tiles()}
</div>
{/if}
</section>
{:else if visible && self}
<section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
@@ -295,6 +357,12 @@
</span>
{/if}
</button>
{#if anyCamera || share.cams}
<button class="icon-btn" class:live={share.cams} aria-pressed={share.cams} title={share.cams ? 'Stop watching the cameras' : 'Watch everyone\'s camera'}
aria-label={share.cams ? 'Stop watching the cameras' : 'Watch all cameras'} onclick={() => share.setCams(sessions.active, !share.cams)}>
<Icon name="camera" size={18} />
</button>
{/if}
{#if mine}
<span class="status" role="status" title="People watching your {share.kind}"><Icon name={share.kind} size={14} />{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
@@ -309,40 +377,13 @@
</button>
</header>
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
{#each withSound as w (w.session)}<audio use:media={w.stream} use:loud autoplay></audio>{/each}
{#if focused && focus != null && !collapsed}{@render spot()}{/if}
{#if !collapsed}
<div class="tiles" class:strip={!!focused} role="group" aria-label="People in this channel" bind:clientWidth={gridW} bind:clientHeight={gridH}>
{#each rows as row, r (r)}
<div class="row" role="presentation">
{#each row as u (u.session)}
{@const stream = streamOf(u.session)}
{@const sharing = session.sharing[u.session]}
{@const isSelf = u.session === self.session}
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
onclick={() => tileClick(u.session)}
oncontextmenu={e => menu.show(e, userMenu(u))}
title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? `Sharing their ${sharing}. Click to watch` : store.displayName(u)}
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
<span class="who">{store.displayName(u)}</span>
{#if watching?.session === u.session && watching.state !== 'live'}
<span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
{/if}
{#if sharing}<span class="badge live"><Icon name={sharing} size={13} /></span>
{:else if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/each}
</div>
{#if watching?.state === 'failed'}
{@render tiles()}
{#if watches.some(w => w.state === 'failed')}
<p class="failed">Could not connect to the stream. Across the internet both of you need a STUN server, chosen in Settings, Voice (the person sharing has to restart the stream afterwards).</p>
{/if}
{/if}
@@ -368,6 +409,11 @@
.tile:hover { background: var(--bg-hover); }
.tile.talking { border-color: var(--speaking); box-shadow: 0 0 0 1px var(--speaking), 0 0 18px -4px var(--speaking); }
.tile.focused { border-color: var(--accent); }
/* A camera fills its tile; your own is mirrored, the way a mirror shows you */
.tile video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; }
.tile video.mirror { transform: scaleX(-1); }
.icon-btn.live, .icon-btn.live:hover { color: var(--speaking); }
.camgrid { position: relative; display: flex; flex-direction: column; flex: none; }
/* Name tag over the lower left corner */
.who { position: absolute; left: 4px; bottom: 4px; max-width: calc(100% - 8px); padding: 1px 6px; font-size: 12px; color: #fff; background: rgb(0 0 0 / 0.6);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }