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
+89 -41
View File
@@ -13,6 +13,10 @@ import { sounds } from './audio/sounds.svelte.ts';
// Every viewer is a separate upload from the sharer
const MAX_VIEWERS = 8;
// Cameras watched at once; each is a download and a decoder
const MAX_CAMERAS = 12;
// A face needs far less than a screen full of text
const CAMERA_BITRATE = 1_200_000;
export const QUALITY = {
low: { label: 'Low (1 Mbit/s, 30 fps)', bitrate: 1_000_000, fps: 30 },
medium: { label: 'Medium (4 Mbit/s, 30 fps)', bitrate: 4_000_000, fps: 30 },
@@ -29,6 +33,7 @@ export type ShareKind = 'screen' | 'camera';
export interface Watching {
host: Session;
session: number;
kind: ShareKind;
stream: MediaStream | null;
state: 'connecting' | 'live' | 'failed';
}
@@ -39,15 +44,19 @@ class ScreenShare {
stream = $state.raw<MediaStream | null>(null);
kind = $state<ShareKind>('screen');
viewers = $state(0);
watching = $state.raw<Watching | null>(null);
// Streams we receive: one screen at a time, and any number of cameras next to it
watches = $state.raw<Watching[]>([]);
// In the camera round: everyone's camera in the channel is watched as it comes on
cams = $state(false);
private camHost: Session | null = null;
error = $state('');
// The server the start dialog is open for
dialog = $state.raw<Session | null>(null);
private usesVirtualMic = false;
private peers = new Map<number, RTCPeerConnection>();
private watchPc: RTCPeerConnection | null = null;
private watchTimer: ReturnType<typeof setTimeout> | undefined;
private watchPcs = new Map<number, RTCPeerConnection>();
private watchTimers = new Map<number, ReturnType<typeof setTimeout>>();
private assemblers = new WeakMap<Session, ShareAssembler>();
// Viewers whose connection came up, so leaving is only announced for those
private live = new Set<number>();
@@ -139,6 +148,8 @@ class ScreenShare {
this.send(s, this.others(s), SHARE.on, kind);
s.setSharing(s.client.session!, kind);
sounds.play('shareStarted');
// Turning the camera on joins the round
if (kind === 'camera') this.setCams(s, true);
}
private release(): void {
@@ -174,33 +185,63 @@ class ScreenShare {
this.viewers = this.peers.size;
}
// The screen being watched, if any (there is at most one)
get watching(): Watching | null {
return this.watches.find(w => w.kind === 'screen') ?? null;
}
watchOf(session: number): Watching | undefined {
return this.watches.find(w => w.session === session);
}
async watch(s: Session, session: number): Promise<void> {
if (!this.supported(s) || !s.sharing[session] || !(await this.notice())) return;
this.unwatch();
this.watching = { host: s, session, stream: null, state: 'connecting' };
const kind = s.sharing[session];
if (!this.supported(s) || !kind || this.watchOf(session) || !(await this.notice())) return;
// Streams from another server, and another screen, make way
for (const w of this.watches) if (w.host !== s || (kind === 'screen' && w.kind === 'screen')) this.unwatch(w.session);
if (kind === 'camera' && this.watches.filter(w => w.kind === 'camera').length >= MAX_CAMERAS) return;
if (!s.sharing[session] || this.watchOf(session)) return;
this.watches = [...this.watches, { host: s, session, kind, stream: null, state: 'connecting' }];
this.send(s, [session], SHARE.watch);
this.watchTimer = setTimeout(() => this.setWatchState('failed'), 20000);
this.watchTimers.set(session, setTimeout(() => this.setWatchState(session, 'failed'), 20000));
}
unwatch(): void {
const w = this.watching;
if (!w) return;
if (w.host.client) this.send(w.host, [w.session], SHARE.leave);
this.endWatch();
// One stream, or all of them
unwatch(session?: number): void {
for (const w of this.watches) {
if (session != null && w.session !== session) continue;
if (w.host.client) this.send(w.host, [w.session], SHARE.leave);
this.endWatch(w.session);
}
}
private endWatch(): void {
clearTimeout(this.watchTimer);
this.watchPc?.close();
this.watchPc = null;
this.watching = null;
private endWatch(session: number): void {
clearTimeout(this.watchTimers.get(session));
this.watchTimers.delete(session);
this.watchPcs.get(session)?.close();
this.watchPcs.delete(session);
this.watches = this.watches.filter(w => w.session !== session);
}
private setWatchState(state: Watching['state'], stream?: MediaStream): void {
const w = this.watching;
if (!w) return;
if (state !== 'connecting') clearTimeout(this.watchTimer);
this.watching = { ...w, state, stream: stream ?? w.stream };
private setWatchState(session: number, state: Watching['state'], stream?: MediaStream): void {
if (!this.watchOf(session)) return;
if (state !== 'connecting') { clearTimeout(this.watchTimers.get(session)); this.watchTimers.delete(session); }
this.watches = this.watches.map(w => w.session === session ? { ...w, state, stream: stream ?? w.stream } : w);
}
// The camera round: watch every camera in the channel, now and as they come on
async setCams(s: Session, on: boolean): Promise<void> {
if (!on) {
this.cams = false;
this.camHost = null;
for (const w of this.watches) if (w.kind === 'camera') this.unwatch(w.session);
return;
}
if (!this.supported(s) || !(await this.notice())) return;
this.cams = true;
this.camHost = s;
for (const [session, kind] of Object.entries(s.sharing)) {
if (kind === 'camera' && Number(session) !== s.client?.session) this.watch(s, Number(session));
}
}
// Candidates travel inside the description, so wait for them instead of sending each one
@@ -237,7 +278,8 @@ class ScreenShare {
const params = sender.getParameters();
// Keep the picture sharp and let the frame rate drop when the connection is tight
params.degradationPreference = this.kind === 'camera' ? 'maintain-framerate' : 'maintain-resolution';
for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate;
const wanted = QUALITY[store.settings.shareQuality].bitrate;
for (const enc of params.encodings ?? []) enc.maxBitrate = this.kind === 'camera' ? Math.min(wanted, CAMERA_BITRATE) : wanted;
await sender.setParameters(params).catch(() => {});
}
await pc.setLocalDescription({ type: 'offer', sdp: stereo((await pc.createOffer()).sdp ?? '') });
@@ -246,36 +288,40 @@ class ScreenShare {
}
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
const w = this.watching;
if (!w || w.host !== s || w.session !== sharer) return;
const mine = () => { const w = this.watchOf(sharer); return !!w && w.host === s; };
if (!mine()) return;
const config = await this.rtcConfig();
if (this.watching?.host !== s || this.watching.session !== sharer) return;
this.watchPc?.close();
if (!mine()) return;
this.watchPcs.get(sharer)?.close();
const pc = new RTCPeerConnection(config);
this.watchPc = pc;
this.watchPcs.set(sharer, pc);
const current = () => this.watchPcs.get(sharer) === pc;
// The track is announced with the description, before any route exists: the stream only
// counts as live once the connection is up, otherwise the viewer stares at a black picture
pc.addEventListener('track', e => {
if (this.watchPc === pc) this.setWatchState(pc.connectionState === 'connected' ? 'live' : 'connecting', e.streams[0]);
if (current()) this.setWatchState(sharer, pc.connectionState === 'connected' ? 'live' : 'connecting', e.streams[0]);
});
pc.addEventListener('connectionstatechange', () => {
if (this.watchPc !== pc) return;
if (pc.connectionState === 'connected') this.setWatchState('live');
else if (pc.connectionState === 'failed') this.setWatchState('failed');
if (!current()) return;
if (pc.connectionState === 'connected') this.setWatchState(sharer, 'live');
else if (pc.connectionState === 'failed') this.setWatchState(sharer, 'failed');
});
try {
await pc.setRemoteDescription({ type: 'offer', sdp });
await pc.setLocalDescription({ type: 'answer', sdp: stereo((await pc.createAnswer()).sdp ?? '') });
await this.gathered(pc);
if (this.watchPc === pc && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
if (current() && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
} catch {
if (this.watchPc === pc) this.setWatchState('failed');
if (current()) this.setWatchState(sharer, 'failed');
}
}
// Raw WebRTC statistics for the overlay: of the stream we watch, or one per viewer of ours
async reports(own: boolean): Promise<{ viewer: number | null; report: RTCStatsReport }[]> {
if (!own) return this.watchPc ? [{ viewer: null, report: await this.watchPc.getStats() }] : [];
// Raw WebRTC statistics for the overlay: of a stream we watch, or one per viewer of ours
async reports(own: boolean, session?: number): Promise<{ viewer: number | null; report: RTCStatsReport }[]> {
if (!own) {
const pc = session != null ? this.watchPcs.get(session) : undefined;
return pc ? [{ viewer: null, report: await pc.getStats() }] : [];
}
return Promise.all([...this.peers].map(async ([viewer, pc]) => ({ viewer, report: await pc.getStats() })));
}
@@ -292,11 +338,12 @@ class ScreenShare {
case SHARE.on:
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
s.setSharing(sender, msg.text === 'camera' ? 'camera' : 'screen');
if (msg.text === 'camera' && this.cams && this.camHost === s) this.watch(s, sender);
break;
case SHARE.off:
if (s.sharing[sender]) sounds.play('shareStopped');
s.setSharing(sender, false);
if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
if (this.watchOf(sender)?.host === s) this.endWatch(sender);
break;
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on, this.kind); break;
case SHARE.watch: if (this.host === s) this.offerTo(s, sender).catch(() => this.dropPeer(sender)); break;
@@ -314,7 +361,7 @@ class ScreenShare {
const self = s.client!.session!;
this.enteredAt.set(s, Date.now());
s.clearSharing();
if (this.watching?.host === s) this.unwatch();
for (const w of this.watches) if (w.host === s) this.unwatch(w.session);
if (this.host === s) {
this.dropPeers();
s.setSharing(self, this.kind);
@@ -334,13 +381,14 @@ class ScreenShare {
if (s.sharing[user.session]) sounds.play('shareStopped');
s.setSharing(user.session, false);
if (this.host === s) this.dropPeer(user.session);
if (this.watching?.host === s && this.watching.session === user.session) this.endWatch();
if (this.watchOf(user.session)?.host === s) this.endWatch(user.session);
}
closed(s: Session): void {
if (this.host === s) this.release();
if (this.dialog === s) this.dialog = null;
if (this.watching?.host === s) this.endWatch();
for (const w of this.watches) if (w.host === s) this.endWatch(w.session);
if (this.camHost === s) { this.cams = false; this.camHost = null; }
}
}
+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; }