Screen sharing #7

Merged
kibi merged 6 commits from screen-sharing into main 2026-10-01 20:27:09 +00:00
5 changed files with 96 additions and 36 deletions
Showing only changes of commit 63d77c2bce - Show all commits
+4 -3
View File
@@ -28,8 +28,8 @@
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow'); const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
// Arrangement (Appearance setting): side is the default, classic puts the channel list right // Arrangement (Appearance setting): side is the default, classic puts the channel list right
// of the chat, stacked above it. Phones always use drawers. // of the chat, stacked above it. Narrow windows use drawers, except stacked, which works at any width.
const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side'); const arrangement = $derived(store.settings.arrangement === 'stacked' ? 'stacked' : layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
// The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays // The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays
const panelInline = $derived(layout === 'wide' && arrangement === 'side'); const panelInline = $derived(layout === 'wide' && arrangement === 'side');
let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280)); let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280));
@@ -76,7 +76,8 @@
onsettings={() => (ui.settingsOpen = true)} /> onsettings={() => (ui.settingsOpen = true)} />
{/snippet} {/snippet}
{#snippet chat()} {#snippet chat()}
<Chat showMenu={layout === 'narrow'} showStage={!panelInline && arrangement === 'side'} onmenu={() => (navOpen = true)} onpanel={togglePanel} /> <Chat showMenu={layout === 'narrow' && arrangement === 'side'} showStage={!panelInline && arrangement === 'side'} videoOnly={arrangement === 'stacked'}
onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{/snippet} {/snippet}
<div class="app {layout} {arrangement}"> <div class="app {layout} {arrangement}">
+1 -1
View File
@@ -63,7 +63,7 @@
</button> </button>
{/each} {/each}
</div> </div>
<p class="help">Narrow windows always use the phone layout.</p> <p class="help">Narrow windows use the phone layout, except Stacked, which stays as it is.</p>
<style> <style>
.gap { margin-top: 22px; } .gap { margin-top: 22px; }
+3 -2
View File
@@ -7,7 +7,8 @@
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
// showStage: voice tiles above the chat when the window is too narrow for the member list // showStage: voice tiles above the chat when the window is too narrow for the member list
let { onmenu, onpanel, showMenu, showStage = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean } = $props(); // 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();
const isDm = $derived(session.view.startsWith('dm:')); const isDm = $derived(session.view.startsWith('dm:'));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5'); const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
@@ -27,7 +28,7 @@
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button> <button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header> </header>
{#if session.status === 'connected'}<VoiceStage always={showStage} />{/if} {#if session.status === 'connected'}<VoiceStage always={showStage} {videoOnly} />{/if}
{#if session.status === 'idle' && session.disconnectInfo} {#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo} {@const d = session.disconnectInfo}
+62 -30
View File
@@ -14,7 +14,9 @@
// 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 sharer's tile
// carries the picture, and clicking it puts it on a large stage with the tiles as a strip. // carries the picture, and clicking it puts it on a large stage with the tiles as a strip.
let { always = false }: { always?: boolean } = $props(); // 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();
const self = $derived(session.self); const self = $derived(session.self);
const channel = $derived(self ? session.channel(self.channelId) : undefined); const channel = $derived(self ? session.channel(self.channelId) : undefined);
@@ -24,7 +26,7 @@
const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null); const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null);
const mine = $derived(share.stream && here(share.host) ? share.stream : null); const mine = $derived(share.stream && here(share.host) ? share.stream : null);
const anySharing = $derived(inChannel.some(u => session.sharing[u.session])); const anySharing = $derived(inChannel.some(u => session.sharing[u.session]));
const visible = $derived(!!self && (always || anySharing || !!watching || !!mine)); const visible = $derived(!!self && (videoOnly ? !!watching || !!mine : always || anySharing || !!watching || !!mine));
// The picture a tile can show: our own stream, or the one we are watching // The picture a tile can show: our own stream, or the one we are watching
function streamOf(s: number): MediaStream | null { function streamOf(s: number): MediaStream | null {
@@ -51,6 +53,8 @@
} }
}); });
$effect(() => { if (focus === self?.session && !mine) focus = null; }); $effect(() => { if (focus === self?.session && !mine) focus = null; });
// 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; });
// The stage is as tall as the user drags it; the tiles grow and shrink to fill it. The // 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. // meeting view (one picture large) remembers its own height.
@@ -168,7 +172,53 @@
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if} {#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
{#if visible && self} {#snippet spot()}
{#if self && focus != null && focused}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
<div class="spot-head">
<Icon name="screen" size={16} />
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
{#if focus !== self.session && hasSound}
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
aria-label="Stream volume" title="Stream volume" />
{/if}
{#if popped}
<button class="btn small" onclick={popIn}>Bring back</button>
{:else}
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{/if}
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>
{:else if videoOnly}
<span class="status" role="status" title="People watching your screen">{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
{#if !videoOnly}<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>{/if}
</div>
<!-- svelte-ignore a11y_media_has_caption -->
{#if popped}
<p class="away">Shown in a separate window.</p>
{:else}
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
{/if}
</div>
{/if}
{/snippet}
{#if visible && self && videoOnly}
<section class="stage solo" aria-label="Voice channel">
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
{#if focused}
{@render spot()}
{:else if watching}
<p class="wait" class:bad={watching.state === 'failed'}>
<span>{watching.state === 'failed' ? 'Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice.' : 'Connecting to the stream...'}</span>
<button class="btn small" onclick={() => share.unwatch()}>{watching.state === 'failed' ? 'Close' : 'Cancel'}</button>
</p>
{/if}
</section>
{:else if visible && self}
<section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel" <section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}> style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
{#if !collapsed} {#if !collapsed}
@@ -205,33 +255,7 @@
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if} {#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
{#if focused && focus != null && !collapsed} {#if focused && focus != null && !collapsed}{@render spot()}{/if}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
<div class="spot-head">
<Icon name="screen" size={16} />
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
{#if focus !== self.session && hasSound}
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
aria-label="Stream volume" title="Stream volume" />
{/if}
{#if popped}
<button class="btn small" onclick={popIn}>Bring back</button>
{:else}
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{/if}
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>{/if}
<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>
</div>
<!-- svelte-ignore a11y_media_has_caption -->
{#if popped}
<p class="away">Shown in a separate window.</p>
{:else}
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
{/if}
</div>
{/if}
{#if !collapsed} {#if !collapsed}
<ul class="tiles" class:strip={!!focused} bind:clientWidth={gridW} bind:clientHeight={gridH}> <ul class="tiles" class:strip={!!focused} bind:clientWidth={gridW} bind:clientHeight={gridH}>
@@ -316,5 +340,13 @@
.spot:fullscreen video { height: 100%; } .spot:fullscreen video { height: 100%; }
.away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; } .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; } .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 .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; }
.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; } .tiles.strip { flex: none; flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; }
</style> </style>
+26
View File
@@ -83,6 +83,32 @@ try {
await ownView.waitFor({ state: 'detached' }); await ownView.waitFor({ state: 'detached' });
console.log('ok: sharing started from the dialog, own tile shows the stream'); 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 // Bob connects afterwards and still learns about the stream
await join(bob.page, bobName); await join(bob.page, bobName);
// Next to the name in the channel list, and on the tile above the chat // Next to the name in the channel list, and on the tile above the chat