From b80eeafcc2249df2819deb74fa4c010ee7f02f82 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 22:55:28 +0200 Subject: [PATCH 1/7] Web E2E: check the stream view at a phone-sized window Co-Authored-By: Claude Opus 5.5 --- test/e2e/web.e2e.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/e2e/web.e2e.ts b/test/e2e/web.e2e.ts index 8dddc65..91a1acb 100644 --- a/test/e2e/web.e2e.ts +++ b/test/e2e/web.e2e.ts @@ -188,6 +188,13 @@ try { await page2.getByRole('button', { name: 'Continue' }).click(); await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 }); await page.locator('.stage .status', { hasText: '1 watching' }).waitFor(); + // Phone-sized window: the picture still has room and frames + await page2.setViewportSize({ width: 390, height: 780 }); + await page2.waitForFunction(() => { + const v = document.querySelector('.spot video') as HTMLVideoElement | null; + return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300; + }, null, { timeout: 15000 }); + if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` }); console.log('ok: screen sharing between two browsers'); console.log('WEB E2E PASSED'); From 7d1ad5423fe47488d0744bb6c58e3f93be7ab0c9 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:00:37 +0200 Subject: [PATCH 2/7] Voice tiles fill the stage exactly at every size Rows of equal tiles with the column count chosen for the best 16:9 fit; a shorter last row stretches. In the meeting view the strip shares the width. Co-Authored-By: Claude Opus 5.5 --- src/ui/VoiceStage.svelte | 99 +++++++++++++++++++++++----------------- test/e2e/share.e2e.ts | 25 +++++++--- 2 files changed, 76 insertions(+), 48 deletions(-) diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index f17d0d4..4c866ab 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -71,24 +71,37 @@ let meetingHeight = $state(stored('mumh5.stageHeightMeeting', 460)); const maxHeight = () => Math.max(160, window.innerHeight - 220); - // Largest 16:9 tiles that fit everyone into the space, like a meeting's tile view + // The tiles fill the stage completely, like a meeting's tile view: rows of equal tiles, with + // the number of columns chosen so the tiles come closest to a 16:9 shape. A last row with + // fewer people stretches, so no space is left over. Measured live, so it follows every resize. let gridW = $state(0), gridH = $state(0); - const GAP = 8; - const tileW = $derived.by(() => { + const GAP = 8, PAD = 8; + const cols = $derived.by(() => { const n = people.length; - if (!n || !gridW || !gridH) return 160; - let best = 0; - for (let cols = 1; cols <= n; cols++) { - const rows = Math.ceil(n / cols); - const w = Math.min((gridW - GAP * (cols - 1)) / cols, ((gridH - GAP * (rows - 1)) / rows) * 16 / 9); - if (w > best) best = w; + const w = gridW - PAD * 2, h = gridH - PAD * 2; + if (n < 2 || w <= 0 || h <= 0) return 1; + let best = 1, bestSize = 0; + for (let c = 1; c <= n; c++) { + const r = Math.ceil(n / c); + // Width of the largest 16:9 picture that fits one cell + const size = Math.min((w - GAP * (c - 1)) / c, ((h - GAP * (r - 1)) / r) * 16 / 9); + if (size > bestSize + 0.5) { best = c; bestSize = size; } } - return Math.max(72, Math.floor(Math.min(best, 520))); + return best; + }); + // 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)); + return out; + }); + const avatar = $derived.by(() => { + if (focused) return 36; + const cellW = (gridW - PAD * 2 - GAP * (cols - 1)) / cols; + const cellH = (gridH - PAD * 2 - GAP * (rows.length - 1)) / Math.max(1, rows.length); + return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34)))); }); - // In the meeting view the tiles sit in one row of fixed height - const STRIP_W = 150; - const shownW = $derived(focused ? STRIP_W : tileW); - const avatar = $derived(Math.max(24, Math.min(96, Math.round(shownW * 9 / 16 * 0.45)))); let collapsed = $state(load()); function load(): boolean { @@ -299,32 +312,34 @@ {#if focused && focus != null && !collapsed}{@render spot()}{/if} {#if !collapsed} -
    - {#each people as u (u.session)} +
    + {#each rows as row, r (r)} +
+ + {/each} + {#if watching?.state === 'failed'}

Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice (the person sharing has to restart the stream after setting it).

{/if} @@ -344,12 +359,12 @@ .mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; } /* Tile view: equal 16:9 tiles, centered, sized by the script to fill the stage */ - .tiles { flex: 1; min-height: 0; list-style: none; margin: 0; padding: 8px; display: flex; flex-wrap: wrap; justify-content: flex-start; align-content: flex-start; gap: 8px; overflow: hidden; } - .tiles li { flex: none; } - .tile { position: relative; width: 100%; aspect-ratio: 16 / 9; display: flex; align-items: center; justify-content: center; overflow: hidden; + .tiles { flex: 1; min-height: 0; padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; } + .row { flex: 1 1 0; min-height: 0; display: flex; gap: 8px; } + .tile { position: relative; flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--bg-2); border: 2px solid transparent; transition: border-color 80ms; } .tile:hover { background: var(--bg-hover); } - .tile.talking { border-color: var(--speaking); } + .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); } /* 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); @@ -388,5 +403,7 @@ .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; } .wait.bad { color: var(--danger); } - .tiles.strip { flex: none; flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; } + /* One row of a fixed height; tiles share the width and scroll sideways when there are many */ + .tiles.strip { flex: none; height: 92px; padding: 6px 8px; overflow-x: auto; overflow-y: hidden; } + .tiles.strip .tile { flex: 1 0 120px; max-width: 220px; } diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index f33c134..05a3043 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -140,18 +140,29 @@ try { await tile.waitFor(); console.log('ok: a late joiner sees who is sharing'); - // The tiles fill the stage, and the stage can be made taller or shorter - const tileWidth = async () => Math.round((await tile.boundingBox())!.width); - const before = await tileWidth(); + // The tiles fill the stage exactly, whatever its size + const fills = () => bob.page.evaluate(() => { + const grid = document.querySelector('.stage .tiles')!.getBoundingClientRect(); + const tiles = [...document.querySelectorAll('.stage .tile')].map(e => e.getBoundingClientRect()); + const left = Math.min(...tiles.map(r => r.left)), right = Math.max(...tiles.map(r => r.right)); + const top = Math.min(...tiles.map(r => r.top)), bottom = Math.max(...tiles.map(r => r.bottom)); + return { ok: left - grid.left < 10 && grid.right - right < 10 && top - grid.top < 10 && grid.bottom - bottom < 10, height: Math.round(tiles[0].height), count: tiles.length }; + }); + const first = await fills(); + assert.ok(first.ok && first.count === 2, `tiles fill the stage: ${JSON.stringify(first)}`); const handle = bob.page.getByRole('separator', { name: 'Resize the tiles' }); await handle.focus(); for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowDown'); - await bob.page.waitForFunction(w => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().width) > w, before); - const taller = await tileWidth(); + await bob.page.waitForFunction(h => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().height) > h, first.height); + const taller = await fills(); + assert.ok(taller.ok, 'still filled after making the stage taller'); + await bob.page.setViewportSize({ width: 820, height: 700 }); + await bob.page.waitForFunction(() => document.querySelector('.stage .tiles')!.getBoundingClientRect().width < 700); + assert.ok((await fills()).ok, 'still filled after making the window narrower'); + await bob.page.setViewportSize({ width: 1280, height: 800 }); for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowUp'); - await bob.page.waitForFunction(w => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().width) < w, taller); if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/tiles.png` }); - console.log(`ok: tiles scale with the stage height (${before} to ${taller} px wide)`); + console.log(`ok: tiles fill the stage at every size (${first.height} to ${taller.height} px tall)`); await tile.click(); await bob.page.getByRole('button', { name: 'Continue' }).click(); From f9e7042a73de6d04f4afb69634d10d040126f89e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:03:22 +0200 Subject: [PATCH 3/7] Stacked layout: no chat title bar Co-Authored-By: Claude Opus 5.5 --- src/ui/Chat.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/Chat.svelte b/src/ui/Chat.svelte index 7293b63..661f1a1 100644 --- a/src/ui/Chat.svelte +++ b/src/ui/Chat.svelte @@ -29,6 +29,8 @@
+ + {#if !videoOnly}
{#if showMenu}{/if} @@ -37,6 +39,7 @@
+ {/if} {#if session.status === 'connected'}{/if} From e7b317313a422f16d7944236c73a264f19283f47 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:04:53 +0200 Subject: [PATCH 4/7] Server rail: the server with voice is shown on top Co-Authored-By: Claude Opus 5.5 --- src/ui/ServerRail.svelte | 2 ++ test/e2e/app.e2e.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/ui/ServerRail.svelte b/src/ui/ServerRail.svelte index 6e541d2..bca505a 100644 --- a/src/ui/ServerRail.svelte +++ b/src/ui/ServerRail.svelte @@ -96,6 +96,8 @@ diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 1eee29b..7413c5c 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -856,6 +856,19 @@ try { assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`); console.log(`ok: inline image without upload host (${inline.length} chars)`); + // A click on an image in the chat shows it large; Escape or a click closes it + await page.locator('.chat .msg .html img').last().click(); + const viewer = page.getByRole('dialog', { name: 'Image' }); + await viewer.waitFor(); + const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]); + assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`); + await page.keyboard.press('Escape'); + await viewer.waitFor({ state: 'detached' }); + await page.locator('.chat .msg .html img').last().click(); + await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } }); + await viewer.waitFor({ state: 'detached' }); + console.log('ok: images open large on click'); + // ─── Several servers at once ───────────────────────────────────────────── const userState = (name: string) => [...bob.users.values()].find(u => u.name === name); const until2 = (label: string, cond: () => boolean) => within(new Promise(res => { From 0a290de2296f02108f0882aaa56a0815ff46a6a2 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:12:10 +0200 Subject: [PATCH 6/7] Channel menu: Send message to channel opens the side chat ready to type Co-Authored-By: Claude Opus 5.5 --- src/lib/actions.ts | 3 ++- test/e2e/app.e2e.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 9139946..443a70d 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -39,7 +39,8 @@ export function channelMenu(c: Channel): MenuItem[] { return [ { header: true, label: c.name || 'Root' }, { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, - { label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }, + // Opens the channel's chat in the side panel, ready to type, without joining it + { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { separator: true, label: '' }, { label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 }, diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 7413c5c..1ac4beb 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -716,7 +716,15 @@ try { await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Join channel' }).click(); await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor(); - console.log('ok: channel context menu joins'); + // Messaging a channel from its menu opens the side chat, ready to type, without joining + await page.locator('.channel .name', { hasText: 'Games' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Send message to channel' }).click(); + const sideInput = page.locator('.panel .composer [contenteditable]'); + await sideInput.waitFor(); + assert.equal(await sideInput.getAttribute('data-placeholder'), 'Message #Games'); + await page.waitForFunction(() => document.activeElement === document.querySelector('.panel .composer [contenteditable]'), null, { timeout: 3000 }); + await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor(); + console.log('ok: channel context menu joins, and messages a channel from the side chat'); // Side panel can be resized by dragging its left edge const panelBox = (await page.locator('aside.panel').boundingBox())!; From 98e44d70827178a2aaaf6937201217385f033e16 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:13:40 +0200 Subject: [PATCH 7/7] Settings: choose a STUN server from Google, Cloudflare or a typed address Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/ui/SettingsDialog.svelte | 30 ++++++++++++++++++++++++++---- src/ui/VoiceStage.svelte | 2 +- test/e2e/app.e2e.ts | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4f13e7..997af12 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ mumh5 keeps the foundation and replaces the experience. - Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers - People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. Viewers set the stream's volume - No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams -- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you enter one in Settings, Voice (the proxy's address works there too), and none is contacted unless you do +- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), and none is contacted unless you do - Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested ### Chat diff --git a/src/ui/SettingsDialog.svelte b/src/ui/SettingsDialog.svelte index 5c25220..1e797bc 100644 --- a/src/ui/SettingsDialog.svelte +++ b/src/ui/SettingsDialog.svelte @@ -16,6 +16,21 @@ const s = store.settings; let embedHosts = $state(s.embedHosts.join('\n')); let testResult = $state(''); + + // Public STUN servers to pick from; anything else is typed in + const STUN_PRESETS = [ + { name: 'Google', url: 'stun.l.google.com:19302' }, + { name: 'Cloudflare', url: 'stun.cloudflare.com:3478' } + ]; + let stunCustom = $state(!!s.stunServer && !STUN_PRESETS.some(p => p.url === s.stunServer)); + const stunChoice = $derived(stunCustom ? 'custom' : s.stunServer); + function chooseStun(value: string) { + stunCustom = value === 'custom'; + // Keep what was typed when switching to "another server" + if (!stunCustom) s.stunServer = value; + else if (STUN_PRESETS.some(p => p.url === s.stunServer)) s.stunServer = ''; + store.saveSettings(); + } // Settings that only work through the upload host are disabled until it is set up const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim())); const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key'); @@ -60,9 +75,16 @@ {#if tab === 'voice'}

Screen sharing

- - store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" /> -

Screen sharing connects you directly to the people watching. Across the internet both the person sharing and the people watching need a STUN server here to find a route; it learns your IP address but never sees the stream. Empty means no outside server is contacted, which only works on the same network.

+ + + {#if stunChoice === 'custom'} + store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" aria-label="STUN server address" /> + {/if} +

Screen sharing connects you directly to the people watching. Across the internet both sides need a STUN server to find a route; it learns your IP address but never sees the stream. {isWeb ? 'By default the site you are on provides it.' : 'With none, no outside server is contacted and streams only work on the same network.'} Networks that block direct connections, like many mobile ones, need more than STUN.

{:else if tab === 'sounds'} {:else if tab === 'appearance'} @@ -123,7 +145,7 @@ .tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; } .tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); } h3 { margin: 22px 0 0; font-size: 14px; } - .opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); } + .stun-custom { margin-top: 6px; } .help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); } .row { display: flex; align-items: flex-end; gap: 8px; } .row > div { flex: 1; } diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 4c866ab..52706a4 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -341,7 +341,7 @@ {/each} {#if watching?.state === 'failed'} -

Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice (the person sharing has to restart the stream after setting it).

+

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).

{/if} {/if}
diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 1ac4beb..8872d76 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -334,6 +334,21 @@ try { await page.getByRole('button', { name: 'Done', exact: true }).click(); console.log('ok: quick audio options on right-click'); + // STUN server for screen sharing: none by default, presets, or a typed address + await page.getByTitle('Settings').click(); + const stun = page.getByLabel('STUN server', { exact: true }); + assert.equal(await stun.inputValue(), ''); + await stun.selectOption({ label: 'Cloudflare (stun.cloudflare.com:3478)' }); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.cloudflare.com:3478'); + await stun.selectOption('custom'); + await page.getByLabel('STUN server address').fill('stun.example.org:3478'); + await page.getByLabel('STUN server address').blur(); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.example.org:3478'); + await stun.selectOption(''); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), ''); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + console.log('ok: STUN server choices'); + // Color schemes apply right away and are remembered await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Appearance' }).click();