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/Chat.svelte b/src/ui/Chat.svelte index e081a8d..7293b63 100644 --- a/src/ui/Chat.svelte +++ b/src/ui/Chat.svelte @@ -3,6 +3,8 @@ import ChatLog from './ChatLog.svelte'; import Composer from './Composer.svelte'; import VoiceStage from './VoiceStage.svelte'; + import ResizeHandle from './ResizeHandle.svelte'; + import { share } from '../lib/share.svelte.ts'; import { ui } from '../lib/ui.svelte.ts'; import { session } from '../lib/session.svelte.ts'; @@ -10,6 +12,14 @@ // videoOnly: streams as a plain picture without the tiles (stacked layout) let { onmenu, onpanel, showMenu, showStage = false, videoOnly = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean; videoOnly?: boolean } = $props(); + // Stacked layout with a stream above the chat: the chat's height can be dragged larger than + // 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)))); + let logHeight = $state(Number((() => { try { return localStorage.getItem('mumh5.stackedChatHeight'); } catch { return null; } })()) || 0); + const logFixed = $derived(streamAbove && logHeight >= LOG_MIN); + const isDm = $derived(session.view.startsWith('dm:')); const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5'); const topic = $derived.by(() => { @@ -18,7 +28,7 @@ }); -
+
{#if showMenu}{/if} @@ -48,6 +58,11 @@ {/if} +
+ {#if streamAbove} + Math.max(LOG_MIN, window.innerHeight - 320)} initial={220} + storageKey="mumh5.stackedChatHeight" label="Resize the chat" /> + {/if} {#snippet empty()}
@@ -67,6 +82,7 @@
{/snippet}
+
{#if session.status === 'connected'} @@ -74,6 +90,13 @@
diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index d96e5af..893672e 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,15 @@ else { session.showUser(session_); ui.panelOpen = true; } } + // Right-click on the picture + let showStats = $state(false); + const streamMenu = (e: MouseEvent) => menu.show(e, [ + { label: 'Open in a separate window', action: popOut }, + { label: 'Full screen', action: fullscreen }, + { separator: true, label: '' }, + { label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) } + ]); + let frame = $state(); function fullscreen() { if (document.fullscreenElement) document.exitFullscreen(); @@ -207,7 +217,10 @@ {#if popped}

Shown in a separate window.

{:else} - + {/if} {/if} @@ -337,14 +350,16 @@ .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; } + /* It gives way when the window is short, so the chat below keeps its last lines in view */ + .stage.solo { flex: 0 1 auto; background: #000; } + .stage.solo .spot { flex: 0 1 auto; } + .stage.solo .pic { flex: 0 1 auto; flex-direction: column; } + .stage.solo .spot video { flex: 0 1 auto; 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/app.e2e.ts b/test/e2e/app.e2e.ts index 0889fa3..3c02482 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -226,6 +226,16 @@ try { assert.equal(await page.evaluate(() => (window as any).pwned), undefined); assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1); assert.equal(await page.locator('img[src^="https://evil"]').count(), 0); + // The full date appears at the right edge while the pointer is on a message + const anyMsg = page.locator('.chat .msg').last(); + const stamp = anyMsg.locator('.stamp'); + assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'hidden'); + await anyMsg.hover(); + assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'visible'); + assert.match(await stamp.innerText(), /\d{4}.*\d{1,2}:\d{2}:\d{2}/); + const gap = await anyMsg.evaluate(el => el.getBoundingClientRect().right - el.querySelector('.stamp')!.getBoundingClientRect().right); + assert.ok(gap >= 0 && gap < 20, `stamp at the right edge: ${gap}`); + await page.mouse.move(5, 5); console.log('ok: incoming text rendered and sanitized'); // Right-click menus for text: composer editing, links, messages, authors diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index 1827506..bbfa7dc 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -104,6 +104,26 @@ try { await alice.page.setViewportSize({ width: 600, height: 800 }); await alice.page.locator('.app.stacked .stage.solo video').waitFor(); assert.ok(await fits(), 'still as wide as the chat on a narrow window'); + // On a short window the picture gives way and the chat keeps about five lines + await alice.page.setViewportSize({ width: 600, height: 700 }); + const heights = () => alice.page.evaluate(() => [document.querySelector('.chat [role=log]')!.getBoundingClientRect().height, document.querySelector('.stage.solo video')!.getBoundingClientRect().height]); + await alice.page.waitForFunction(() => document.querySelector('.stage.solo video')!.getBoundingClientRect().height < 250); + const [logH, videoH] = await heights(); + assert.ok(logH >= 120, `chat keeps its lines: ${logH}`); + assert.ok(videoH > 40, `the picture is still shown: ${videoH}`); + await alice.page.setViewportSize({ width: 600, height: 800 }); + // The chat can be dragged taller, never below its minimum; the picture takes the rest + const chatHandle = alice.page.getByRole('separator', { name: 'Resize the chat' }); + await chatHandle.focus(); + for (let i = 0; i < 12; i++) await alice.page.keyboard.press('ArrowUp'); + await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height > 200); + const grown = (await heights())[0]; + for (let i = 0; i < 40; i++) await alice.page.keyboard.press('ArrowDown'); + await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height < 130); + const [smallest, videoNow] = await heights(); + assert.ok(smallest >= 124, `not below the minimum: ${smallest}`); + assert.ok(videoNow > 100, `the picture fills the rest: ${videoNow}`); + console.log(`ok: stacked chat resizable (${Math.round(grown)} px down to ${Math.round(smallest)} px)`); if (process.env.SHOTS_DIR) await alice.page.screenshot({ path: `${process.env.SHOTS_DIR}/stacked.png` }); await alice.page.getByText('0 watching').waitFor(); await alice.page.setViewportSize({ width: 1280, height: 800 }); @@ -143,9 +163,27 @@ 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(); + // From the right-click menu on the picture + await bob.page.locator('.spot video').click({ button: 'right' }); + await bob.page.getByRole('menuitem', { name: 'Open in a separate window' }).click(); const streamWindow = await opened; await streamWindow.waitForFunction(() => (document.querySelector('video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 }); await view.getByText('Shown in a separate window.').waitFor();