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 151 additions and 33 deletions
Showing only changes of commit 23833b1c8b - 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. 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 - 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
+5 -1
View File
@@ -201,7 +201,11 @@ function createWindow() {
}); });
// Links from chat open in the system browser, never inside the app // 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); if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };
}); });
+1
View File
@@ -7,6 +7,7 @@ export const ICON_PATHS: Record<string, string> = {
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14', plus: 'M12 5v14M5 12h14',
screen: 'M3 4h18v12H3zM8 20h8M12 16v4', screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
filter: 'M3 4h18l-7 8v7l-4 2v-9z', 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', info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
+119 -31
View File
@@ -9,6 +9,7 @@
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { share } from '../lib/share.svelte.ts'; import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.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 // 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
@@ -17,12 +18,12 @@
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);
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 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 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(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)); const visible = $derived(!!self && (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
@@ -31,6 +32,10 @@
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
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 // Tile on the large stage
let focus = $state<number | null>(null); let focus = $state<number | null>(null);
const focused = $derived(focus != null ? streamOf(focus) : null); const focused = $derived(focus != null ? streamOf(focus) : null);
@@ -47,6 +52,32 @@
}); });
$effect(() => { if (focus === self?.session && !mine) focus = null; }); $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()); let collapsed = $state(load());
function load(): boolean { function load(): boolean {
try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; }
@@ -84,12 +115,59 @@
if (document.fullscreenElement) document.exitFullscreen(); if (document.fullscreenElement) document.exitFullscreen();
else frame?.requestFullscreen().catch(() => {}); 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());
</script> </script>
{#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} {#if visible && self}
<section class="stage" class:meeting={!!focused} aria-label="Voice channel"> <section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
{#if !collapsed}
{#if focused}
<ResizeHandle bind:width={meetingHeight} edge="bottom" min={240} max={maxHeight} initial={460} storageKey="mumh5.stageHeightMeeting" label="Resize the meeting view" />
{:else}
<ResizeHandle bind:width={tilesHeight} edge="bottom" min={110} max={maxHeight} initial={210} storageKey="mumh5.stageHeight" label="Resize the tiles" />
{/if}
{/if}
<header> <header>
<button class="title" onclick={toggle} aria-expanded={!collapsed}> <button class="title" onclick={toggle} aria-expanded={!collapsed}>
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg> <svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
@@ -117,7 +195,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} {#if focused && focus != null && !collapsed}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}> <div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
<div class="spot-head"> <div class="spot-head">
<Icon name="screen" size={16} /> <Icon name="screen" size={16} />
@@ -127,22 +205,31 @@
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()} <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" /> aria-label="Stream volume" title="Stream volume" />
{/if} {/if}
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button> {#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} {#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> <button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>
</div> </div>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video> {#if popped}
<p class="away">Shown in a separate window.</p>
{:else}
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
{/if}
</div> </div>
{/if} {/if}
{#if !collapsed} {#if !collapsed}
<ul class="tiles" class:strip={!!focused}> <ul class="tiles" class:strip={!!focused} bind:clientWidth={gridW} bind:clientHeight={gridH}>
{#each people as u (u.session)} {#each people as u (u.session)}
{@const stream = streamOf(u.session)} {@const stream = streamOf(u.session)}
{@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 class:wide={!!stream}> <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:video={!!stream} 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))}
@@ -152,7 +239,7 @@
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video use:media={stream} autoplay playsinline muted></video> <video use:media={stream} autoplay playsinline muted></video>
{:else} {:else}
<Avatar name={u.name} size={44} speaking={talking(u.session)} /> <Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
{/if} {/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'}
@@ -176,8 +263,8 @@
{/if} {/if}
<style> <style>
.stage { flex: none; border-bottom: 1px solid var(--line); background: var(--bg-1); } .stage { position: relative; flex: none; display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--line); background: var(--bg-0); }
header { display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; } header { flex: none; display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; background: var(--bg-1); }
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; } .title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
.title:hover { background: var(--bg-hover); } .title:hover { background: var(--bg-hover); }
.caret { flex: none; color: var(--text-faint); } .caret { flex: none; color: var(--text-faint); }
@@ -185,38 +272,39 @@
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.count { font-size: 12px; color: var(--text-faint); } .count { font-size: 12px; color: var(--text-faint); }
.mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; } .mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; }
.tiles { list-style: none; margin: 0; padding: 0 10px 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 6px;
max-height: 40vh; overflow-y: auto; } /* Tile view: equal 16:9 tiles, centered, sized by the script to fill the stage */
.tile { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px 8px; .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; }
background: var(--bg-2); border: 1px solid var(--line); transition: border-color 80ms, box-shadow 80ms; } .tiles li { flex: none; }
.tile { position: relative; width: 100%; aspect-ratio: 16 / 9; 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:hover { background: var(--bg-hover); }
.tile.talking { border-color: var(--speaking); box-shadow: inset 0 0 0 1px var(--speaking); } .tile.talking { border-color: var(--speaking); }
.who { max-width: 100%; font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tile.focused { border-color: var(--accent); }
.tile.self .who { color: var(--text); font-weight: 600; } .tile video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); } /* 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);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile.self .who { font-weight: 600; }
.note { position: absolute; left: 0; right: 0; top: 4px; text-align: center; font-size: 11px; color: var(--text-dim); }
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; padding: 2px; color: #fff; background: rgb(0 0 0 / 0.6); }
.badge.admin { color: var(--danger); } .badge.admin { color: var(--danger); }
.badge.live { color: var(--speaking); } .badge.live { color: var(--speaking); }
.failed { flex: none; margin: 0; padding: 0 12px 8px; font-size: 12px; color: var(--danger); }
/* Streams */
.bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); } .bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
.bar span { flex: 1; min-width: 0; } .bar span { flex: 1; min-width: 0; }
.bar.bad { color: var(--danger); } .bar.bad { color: var(--danger); }
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; } .btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
.status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; } .status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
.tiles li.wide { grid-column: span 2; }
.tile.video { padding: 0 0 6px; overflow: hidden; } /* Meeting view: the chosen picture large, everyone in one row below */
.tile.video video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
.tile.focused { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.note { font-size: 11px; color: var(--text-faint); }
.failed { margin: 0; padding: 0 12px 10px; font-size: 12px; color: var(--danger); }
/* Meeting view: the chosen picture large, everyone else in one row below */
.stage.meeting { display: flex; flex-direction: column; max-height: 72%; min-height: 220px; }
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; } .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-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-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 video { flex: 1; min-height: 0; width: 100%; object-fit: contain; background: #000; display: block; }
.spot:fullscreen video { height: 100%; }
.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; }
.tiles.strip { flex: none; display: flex; overflow-x: auto; overflow-y: hidden; padding-top: 8px; max-height: none; } .tiles.strip { flex: none; flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; overflow-y: hidden; padding: 6px 8px; }
.tiles.strip li { flex: 0 0 96px; }
.tiles.strip li.wide { flex-basis: 150px; }
</style> </style>
+25
View File
@@ -92,6 +92,19 @@ try {
await tile.waitFor(); await tile.waitFor();
console.log('ok: a late joiner sees who is sharing'); 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();
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();
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)`);
await tile.click(); await tile.click();
await bob.page.getByRole('button', { name: 'Continue' }).click(); await bob.page.getByRole('button', { name: 'Continue' }).click();
const view = bob.page.getByRole('region', { name: 'Screen share' }); const view = bob.page.getByRole('region', { name: 'Screen share' });
@@ -101,6 +114,18 @@ try {
await alice.page.getByText('You are sharing your screen, 1 watching').waitFor(); await alice.page.getByText('You are sharing your screen, 1 watching').waitFor();
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`); 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` }); }
// 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();
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();
await streamWindow.close();
await view.getByRole('button', { name: 'Open in a separate window' }).waitFor();
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');
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again // Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again
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' });