From 5a7f243c1df00589912c0cd94347b315207e1211 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 21:55:01 +0200 Subject: [PATCH 1/6] Reword the screen sharing notice Co-Authored-By: Claude Opus 5.5 --- src/lib/share.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index c32e893..09a1a76 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -81,7 +81,7 @@ class ScreenShare { let answered = false; ui.prompt = { title: 'Screen sharing', label: '', confirm: 'Continue', input: 'none', - text: 'Screen sharing connects you directly to the other person. You see each other\'s IP address, like in a video call. Only mumh5 users in your channel can watch.', + text: 'Screen sharing connects you directly to other participants in this channel. You see each other\'s IP address. Only mumh5 users in your channel can watch.', onsubmit: () => { answered = true; store.settings.shareNoticeSeen = true; store.saveSettings(); resolve(true); }, oncancel: () => { if (!answered) resolve(false); } }; -- 2.55.0 From 23833b1c8bfa5f8767b771694fb13a489f70b8ec Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 22:09:34 +0200 Subject: [PATCH 2/6] Screen sharing: resizable tile stage, streams first, pop-out window - The stage above the chat is as tall as the user drags it; tiles are sized to fill it, with a meeting-style look - Tiles with a stream come first, at the top left - The large picture can be moved to a separate window Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- electron/main.ts | 6 +- src/lib/icons.ts | 1 + src/ui/VoiceStage.svelte | 150 +++++++++++++++++++++++++++++++-------- test/e2e/share.e2e.ts | 25 +++++++ 5 files changed, 151 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 2886218..495f57d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience. - Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent - 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. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume +- 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 sharer's tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting, and it can move to a window of its own. 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 - 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 diff --git a/electron/main.ts b/electron/main.ts index 2c5465e..8c12706 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -201,7 +201,11 @@ function createWindow() { }); // Links from chat open in the system browser, never inside the app - win.webContents.setWindowOpenHandler(({ url }) => { + win.webContents.setWindowOpenHandler(({ url, frameName }) => { + // The stream window: an empty page the app fills with the video itself + if (frameName === 'mumh5-stream' && (url === 'about:blank' || url === '')) { + return { action: 'allow', overrideBrowserWindowOptions: { width: 960, height: 600, minWidth: 320, minHeight: 200, backgroundColor: '#000000', autoHideMenuBar: true, title: 'mumh5' } }; + } if (/^https?:\/\//i.test(url)) shell.openExternal(url); return { action: 'deny' }; }); diff --git a/src/lib/icons.ts b/src/lib/icons.ts index d9a0f6e..cffde88 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -7,6 +7,7 @@ export const ICON_PATHS: Record = { settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', plus: 'M12 5v14M5 12h14', screen: 'M3 4h18v12H3zM8 20h8M12 16v4', + popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6', expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', filter: 'M3 4h18l-7 8v7l-4 2v-9z', info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01', diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 440a25d..6d8f4f9 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -9,6 +9,7 @@ import { voice } from '../lib/audio/voice.svelte.ts'; import { share } from '../lib/share.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; + import ResizeHandle from './ResizeHandle.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 sharer's tile @@ -17,12 +18,12 @@ const self = $derived(session.self); const channel = $derived(self ? session.channel(self.channelId) : undefined); - const people = $derived(self ? session.usersIn(self.channelId) : []); + 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 mine = $derived(share.stream && here(share.host) ? share.stream : null); - const anySharing = $derived(people.some(u => session.sharing[u.session])); + const anySharing = $derived(inChannel.some(u => session.sharing[u.session])); const visible = $derived(!!self && (always || anySharing || !!watching || !!mine)); // The picture a tile can show: our own stream, or the one we are watching @@ -31,6 +32,10 @@ return watching?.session === s ? watching.stream : null; } + // Tiles with a picture come first, then other people who share, so streams sit at the top left + 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))); + // Tile on the large stage let focus = $state(null); const focused = $derived(focus != null ? streamOf(focus) : null); @@ -47,6 +52,32 @@ }); $effect(() => { if (focus === self?.session && !mine) focus = null; }); + // 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. + const stored = (key: string, fallback: number) => { try { return Number(localStorage.getItem(key)) || fallback; } catch { return fallback; } }; + let tilesHeight = $state(stored('mumh5.stageHeight', 210)); + 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 + let gridW = $state(0), gridH = $state(0); + const GAP = 8; + const tileW = $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; + } + return Math.max(72, Math.floor(Math.min(best, 520))); + }); + // 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 { try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } @@ -84,12 +115,59 @@ if (document.fullscreenElement) document.exitFullscreen(); else frame?.requestFullscreen().catch(() => {}); } + + // The large picture in a window of its own, to put on another screen. The window is an + // empty page that gets the same stream; sound keeps coming from the main window. + let popup: Window | null = null; + let popVideo: HTMLVideoElement | null = null; + let popped = $state(false); + function popOut() { + if (popup && !popup.closed) return popup.focus(); + const w = window.open('', 'mumh5-stream', 'popup,width=960,height=600'); + if (!w) { share.error = 'The window could not be opened. Allow pop-ups for this page.'; return; } + popup = w; + popped = true; + w.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden'; + const v = w.document.createElement('video'); + v.autoplay = true; + v.muted = true; + v.playsInline = true; + v.style.cssText = 'width:100vw;height:100vh;object-fit:contain;display:block'; + v.ondblclick = () => { if (w.document.fullscreenElement) w.document.exitFullscreen(); else v.requestFullscreen().catch(() => {}); }; + w.document.body.replaceChildren(v); + popVideo = v; + w.addEventListener('pagehide', () => { if (popup === w) { popup = null; popVideo = null; popped = false; } }); + } + function popIn() { + popup?.close(); + popup = null; + popVideo = null; + popped = false; + } + // The window follows the picture on the stage and closes with it + $effect(() => { + const stream = focused; + const title = focus === self?.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'; + if (!popped) return; + if (!stream) return popIn(); + if (popVideo && popVideo.srcObject !== stream) popVideo.srcObject = stream; + if (popup) popup.document.title = `${title} - mumh5`; + }); + $effect(() => () => popup?.close()); {#if share.error}{/if} {#if visible && self} -
+
+ {#if !collapsed} + {#if focused} + + {:else} + + {/if} + {/if}
+ {#if popped} + + {:else} + + + {/if} {#if focus !== self.session}{/if} - + {#if popped} +

Shown in a separate window.

+ {:else} + + {/if} {/if} {#if !collapsed} -
    +
      {#each people as u (u.session)} {@const stream = streamOf(u.session)} {@const sharing = !!session.sharing[u.session]} {@const isSelf = u.session === self.session} -
    • +
    • {#if mine} - You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''} + {share.viewers} watching {/if} {/each} -

      Narrow windows always use the phone layout.

      +

      Narrow windows use the phone layout, except Stacked, which stays as it is.

      diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index 78774f0..74179a2 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -83,6 +83,32 @@ try { await ownView.waitFor({ state: 'detached' }); console.log('ok: sharing started from the dialog, own tile shows the stream'); + // Stacked layout: only the picture, as wide as the chat, and the layout stays stacked on narrow windows + const setLayout = async (name: string) => { + await alice.page.getByTitle('Settings').click(); + await alice.page.getByRole('tab', { name: 'Appearance' }).click(); + await alice.page.getByRole('radio', { name }).click(); + await alice.page.getByRole('button', { name: 'Done', exact: true }).click(); + }; + await setLayout('Stacked'); + await alice.page.locator('.app.stacked .stage.solo').waitFor(); + assert.equal(await alice.page.locator('.stage .tiles').count(), 0, 'no participant tiles in the stacked layout'); + const fits = () => alice.page.evaluate(() => { + const v = document.querySelector('.stage.solo video') as HTMLVideoElement, c = document.querySelector('.chat')!; + return v.videoWidth > 0 && Math.abs(v.getBoundingClientRect().width - c.getBoundingClientRect().width) < 2; + }); + await alice.page.waitForFunction(() => (document.querySelector('.stage.solo video') as HTMLVideoElement | null)?.videoWidth! > 0); + assert.ok(await fits(), 'picture as wide as the chat'); + 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'); + 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 }); + await setLayout('Modern'); + await alice.page.locator('.stage .tiles').waitFor(); + console.log('ok: stacked layout shows only the picture and stays stacked when narrow'); + // Bob connects afterwards and still learns about the stream await join(bob.page, bobName); // Next to the name in the channel list, and on the tile above the chat -- 2.55.0 From a402d1c035d4721778bc0f7e7af07d0a2576b5a2 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 22:19:51 +0200 Subject: [PATCH 6/6] Streams open large by default; tiles never carry the picture Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/ui/VoiceStage.svelte | 29 +++++++++++++++-------------- test/e2e/share.e2e.ts | 16 +++++++++------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 495f57d..a4f13e7 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience. - Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent - 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 sharer's tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting, and it can move to a window of its own. Viewers set the stream's volume +- 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 - 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 diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 5885ae0..d96e5af 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -12,8 +12,8 @@ import ResizeHandle from './ResizeHandle.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 sharer's tile - // carries the picture, and clicking it puts it on a large stage with the tiles as a strip. + // (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. // 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(); @@ -34,7 +34,7 @@ return watching?.session === s ? watching.stream : null; } - // Tiles with a picture come first, then other people who share, so streams sit at the top left + // 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))); @@ -52,7 +52,14 @@ lastWatch = now; } }); - $effect(() => { if (focus === self?.session && !mine) focus = null; }); + // Our own stream opens large when it starts, unless another one is on the stage + let hadMine = false; + $effect(() => { + const has = !!mine; + if (has && !hadMine && focus == null && self) 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; }); @@ -264,17 +271,12 @@ {@const sharing = !!session.sharing[u.session]} {@const isSelf = u.session === self.session}
    • -