From 68f68d1d7640235b73c2fc6f389ec11a5016a25e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 22:28:41 +0200 Subject: [PATCH] Screen sharing: stats for nerds on right-click of the stream Codecs, picture size and frame rate, bitrate, loss, jitter and the network path, per viewer for the sharer. Co-Authored-By: Claude Opus 5.5 --- src/lib/share.svelte.ts | 6 +++ src/ui/ShareStats.svelte | 108 +++++++++++++++++++++++++++++++++++++++ src/ui/VoiceStage.svelte | 17 ++++-- test/e2e/share.e2e.ts | 16 ++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/ui/ShareStats.svelte diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index 0414836..2d00b8b 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -244,6 +244,12 @@ class ScreenShare { } } + // 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() }] : []; + return Promise.all([...this.peers].map(async ([viewer, pc]) => ({ viewer, report: await pc.getStats() }))); + } + // ─── Called by the session ──────────────────────────────────────────────── async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise { diff --git a/src/ui/ShareStats.svelte b/src/ui/ShareStats.svelte new file mode 100644 index 0000000..bd38f7f --- /dev/null +++ b/src/ui/ShareStats.svelte @@ -0,0 +1,108 @@ + + +
+ + {#each blocks as b (b.title)} + {#if b.title}{b.title}{/if} +
{#each b.rows as [k, v] (k)}
{k}
{v}
{/each}
+ {:else} +

{own ? 'Nobody is watching yet.' : 'Not connected.'}

+ {/each} +
+ + diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index d96e5af..ced80b2 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -10,6 +10,7 @@ import { share } from '../lib/share.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; import ResizeHandle from './ResizeHandle.svelte'; + 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 @@ -121,6 +122,10 @@ else { session.showUser(session_); ui.panelOpen = true; } } + // Right-click on the picture + let showStats = $state(false); + const streamMenu = (e: MouseEvent) => menu.show(e, [{ label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) }]); + let frame = $state(); function fullscreen() { if (document.fullscreenElement) document.exitFullscreen(); @@ -207,7 +212,10 @@ {#if popped}

Shown in a separate window.

{:else} - + {/if} {/if} @@ -337,14 +345,15 @@ .spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; } .spot-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-0); color: var(--text-dim); font-size: 13px; } .spot-who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; } - .spot video { flex: 1; min-height: 0; width: 100%; object-fit: contain; background: #000; display: block; } - .spot:fullscreen video { height: 100%; } + .pic { position: relative; flex: 1; min-height: 0; display: flex; } + .spot video { flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: block; } .away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; } .vol { width: 110px; flex: none; padding: 0; } /* Video only: the picture as wide as the chat, as tall as its shape asks */ .stage.solo { background: #000; } .stage.solo .spot { flex: none; } - .stage.solo .spot video { flex: none; height: auto; max-height: 62vh; } + .stage.solo .pic { flex: none; display: block; } + .stage.solo .spot video { height: auto; max-height: 62vh; } .stage.solo .away { flex: none; padding: 24px; } .wait { margin: 0; display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: var(--text-dim); background: var(--bg-1); } .wait span { flex: 1; min-width: 0; } diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index 1827506..d631b98 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -143,6 +143,22 @@ try { console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`); if (process.env.SHOTS_DIR) { await bob.page.waitForTimeout(500); await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/meeting.png` }); } + // Right-click on the picture: statistics for both sides + const nerd = async (page: Page, expect: RegExp[]) => { + await page.locator('.spot video').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Stats for nerds' }).click(); + const box = page.getByRole('status', { name: 'Stream statistics' }); + for (const e of expect) await box.getByText(e).first().waitFor({ timeout: 8000 }); + const text = (await box.innerText()).replace(/\n+/g, ' | '); + await box.getByRole('button', { name: 'Close statistics' }).click(); + await box.waitFor({ state: 'detached' }); + return text; + }; + const viewerStats = await nerd(bob.page, [/^(VP8|VP9|H264|AV1)/, /^\d+x\d+/, /^\d+ kbit\/s$/, /^direct/]); + const sharerStats = await nerd(alice.page, [new RegExp(`To ${bobName}`), /^(VP8|VP9|H264|AV1)/, /^\d+ kbit\/s$/]); + console.log(`ok: stats for nerds (viewer: ${viewerStats.slice(0, 200)}...)`); + assert.ok(sharerStats.includes('Round trip')); + // The large picture can move to a window of its own const opened = bob.app.waitForEvent('window'); await view.getByRole('button', { name: 'Open in a separate window' }).click();