Screen sharing #7

Merged
kibi merged 6 commits from screen-sharing into main 2026-10-01 20:27:09 +00:00
3 changed files with 25 additions and 22 deletions
Showing only changes of commit a402d1c035 - Show all commits
+1 -1
View File
@@ -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 - 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 - 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 - 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 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 - 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
+15 -14
View File
@@ -12,8 +12,8 @@
import ResizeHandle from './ResizeHandle.svelte'; import ResizeHandle from './ResizeHandle.svelte';
// People in your voice channel as tiles. Shown on windows too narrow for the member list // 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 // (always), and on any window while someone in the channel shares a screen. A stream is shown
// carries the picture, and clicking it puts it on a large stage with the tiles as a strip. // 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 // videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list
// above already shows who is here) // above already shows who is here)
let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props(); let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props();
@@ -34,7 +34,7 @@
return watching?.session === s ? watching.stream : null; 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 rank = (s: number) => streamOf(s) ? 0 : session.sharing[s] ? 1 : 2;
const people = $derived([...inChannel].sort((a, b) => rank(a.session) - rank(b.session))); const people = $derived([...inChannel].sort((a, b) => rank(a.session) - rank(b.session)));
@@ -52,7 +52,14 @@
lastWatch = now; 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 // 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; }); $effect(() => { if (videoOnly) focus = watching ? watching.session : mine ? self?.session ?? null : null; });
@@ -264,17 +271,12 @@
{@const sharing = !!session.sharing[u.session]} {@const sharing = !!session.sharing[u.session]}
{@const isSelf = u.session === self.session} {@const isSelf = u.session === self.session}
<li style:width="{shownW}px"> <li style:width="{shownW}px">
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:video={!!stream} class:focused={focus === u.session} <button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
onclick={() => tileClick(u.session)} onclick={() => tileClick(u.session)}
oncontextmenu={e => menu.show(e, userMenu(u))} oncontextmenu={e => menu.show(e, userMenu(u))}
title={stream ? (focus === u.session ? 'Back to tiles' : 'Show large') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)} title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)}
aria-label={stream ? `${focus === u.session ? 'Shrink' : 'Enlarge'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}> aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}>
{#if stream} <Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
<!-- svelte-ignore a11y_media_has_caption -->
<video use:media={stream} autoplay playsinline muted></video>
{:else}
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
{/if}
<span class="who">{store.displayName(u)}</span> <span class="who">{store.displayName(u)}</span>
{#if watching?.session === u.session && watching.state !== 'live'} {#if watching?.session === u.session && watching.state !== 'live'}
<span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span> <span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
@@ -315,7 +317,6 @@
.tile:hover { background: var(--bg-hover); } .tile:hover { background: var(--bg-hover); }
.tile.talking { border-color: var(--speaking); } .tile.talking { border-color: var(--speaking); }
.tile.focused { border-color: var(--accent); } .tile.focused { border-color: var(--accent); }
.tile video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
/* Name tag over the lower left corner */ /* 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); .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; } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+9 -7
View File
@@ -74,14 +74,16 @@ try {
await pickAndStart(); await pickAndStart();
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor(); await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor(); await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
// The sharer's own tile shows what is being sent; clicking it makes it large // The sharer sees what is being sent, large by default; the tiles themselves never carry the picture
await alice.page.waitForFunction(() => (document.querySelector('.stage .tile.self video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await alice.page.locator('.stage .tile.self').click();
const ownView = alice.page.getByRole('region', { name: 'Screen share' }); const ownView = alice.page.getByRole('region', { name: 'Screen share' });
await ownView.getByText('Your screen').waitFor(); await ownView.getByText('Your screen').waitFor();
await alice.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
assert.equal(await alice.page.locator('.stage .tile video').count(), 0);
await ownView.getByRole('button', { name: 'Back to tiles' }).click(); await ownView.getByRole('button', { name: 'Back to tiles' }).click();
await ownView.waitFor({ state: 'detached' }); await ownView.waitFor({ state: 'detached' });
console.log('ok: sharing started from the dialog, own tile shows the stream'); await alice.page.locator('.stage .tile.self').click();
await ownView.waitFor();
console.log('ok: sharing started from the dialog, own stream shown large');
// Stacked layout: only the picture, as wide as the chat, and the layout stays stacked on narrow windows // Stacked layout: only the picture, as wide as the chat, and the layout stays stacked on narrow windows
const setLayout = async (name: string) => { const setLayout = async (name: string) => {
@@ -152,13 +154,13 @@ try {
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0); await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0);
console.log('ok: stream in a separate window, back when it is closed'); console.log('ok: stream in a separate window, back when it is closed');
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again // Back to tiles hides the picture; the sharer's tile brings it back
await view.getByRole('button', { name: 'Back to tiles' }).click(); await view.getByRole('button', { name: 'Back to tiles' }).click();
await view.waitFor({ state: 'detached' }); await view.waitFor({ state: 'detached' });
await bob.page.waitForFunction(() => (document.querySelector('.stage .tile.video video') as HTMLVideoElement | null)?.videoWidth! > 0); assert.equal(await bob.page.locator('.stage .tile video').count(), 0);
await tile.click(); await tile.click();
await view.waitFor(); await view.waitFor();
console.log('ok: stream in the tile, large on click'); console.log('ok: large view hidden and shown again from the tile');
// Leaving and coming back // Leaving and coming back
await view.getByRole('button', { name: 'Stop watching' }).click(); await view.getByRole('button', { name: 'Stop watching' }).click();