Merge pull request 'Screen sharing' (#7) from screen-sharing into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-10-01 20:27:09 +00:00
11 changed files with 312 additions and 86 deletions
+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 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
+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' };
}); });
+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}">
+8
View File
@@ -27,6 +27,10 @@ export const SOUND_EVENTS = [
{ id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] }, { id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] },
{ id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] }, { id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] },
{ id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] }, { id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] },
{ id: 'shareStarted', group: 'Screen sharing', name: 'A screen share started (yours or in your channel)', ts: 'MUMH5_SHARE_STARTED', keys: ['share_started', 'stream_started'] },
{ id: 'shareStopped', group: 'Screen sharing', name: 'A screen share stopped', ts: 'MUMH5_SHARE_STOPPED', keys: ['share_stopped', 'stream_stopped'] },
{ id: 'viewerJoined', group: 'Screen sharing', name: 'Someone started watching your screen', ts: 'MUMH5_VIEWER_JOINED', keys: ['viewer_joined'] },
{ id: 'viewerLeft', group: 'Screen sharing', name: 'Someone stopped watching your screen', ts: 'MUMH5_VIEWER_LEFT', keys: ['viewer_left'] },
{ id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] }, { id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] },
{ id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] }, { id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] },
{ id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true }, { id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true },
@@ -79,6 +83,10 @@ const TONES: Record<SoundEvent, [number, number, number][]> = {
userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]], userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]],
recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]], recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]],
recordingStopped: [[880, 0, 0.12]], recordingStopped: [[880, 0, 0.12]],
shareStarted: [[523, 0, 0.07], [784, 0.08, 0.07], [1047, 0.16, 0.12]],
shareStopped: [[1047, 0, 0.07], [784, 0.08, 0.07], [523, 0.16, 0.12]],
viewerJoined: [[880, 0, 0.05], [1109, 0.06, 0.08]],
viewerLeft: [[1109, 0, 0.05], [880, 0.06, 0.08]],
message: [[1047, 0, 0.06]], message: [[1047, 0, 0.06]],
privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]], privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]],
messageSent: [[1319, 0, 0.04]], messageSent: [[1319, 0, 0.04]],
+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',
+23 -3
View File
@@ -9,6 +9,7 @@ import { desktop, isWeb } from './native.ts';
import { proxyStun } from './web.svelte.ts'; import { proxyStun } from './web.svelte.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
import { ui } from './ui.svelte.ts'; import { ui } from './ui.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
// Every viewer is a separate upload from the sharer // Every viewer is a separate upload from the sharer
const MAX_VIEWERS = 8; const MAX_VIEWERS = 8;
@@ -44,6 +45,10 @@ class ScreenShare {
private watchPc: RTCPeerConnection | null = null; private watchPc: RTCPeerConnection | null = null;
private watchTimer: ReturnType<typeof setTimeout> | undefined; private watchTimer: ReturnType<typeof setTimeout> | undefined;
private assemblers = new WeakMap<Session, ShareAssembler>(); private assemblers = new WeakMap<Session, ShareAssembler>();
// Viewers whose connection came up, so leaving is only announced for those
private live = new Set<number>();
// When we entered the channel: streams already running there are not announced with a sound
private enteredAt = new WeakMap<Session, number>();
// Plugin data needs a 1.4 server; older ones would not pass the signals on // Plugin data needs a 1.4 server; older ones would not pass the signals on
supported(s: Session): boolean { supported(s: Session): boolean {
@@ -81,7 +86,7 @@ class ScreenShare {
let answered = false; let answered = false;
ui.prompt = { ui.prompt = {
title: 'Screen sharing', label: '', confirm: 'Continue', input: 'none', 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); }, onsubmit: () => { answered = true; store.settings.shareNoticeSeen = true; store.saveSettings(); resolve(true); },
oncancel: () => { if (!answered) resolve(false); } oncancel: () => { if (!answered) resolve(false); }
}; };
@@ -114,6 +119,7 @@ class ScreenShare {
this.stream = stream; this.stream = stream;
this.send(s, this.others(s), SHARE.on); this.send(s, this.others(s), SHARE.on);
s.setSharing(s.client.session!, true); s.setSharing(s.client.session!, true);
sounds.play('shareStarted');
} }
private release(): void { private release(): void {
@@ -127,6 +133,7 @@ class ScreenShare {
stop(): void { stop(): void {
const s = this.host; const s = this.host;
if (this.stream) sounds.play('shareStopped');
if (s?.client) { if (s?.client) {
this.send(s, this.others(s), SHARE.off); this.send(s, this.others(s), SHARE.off);
if (s.client.session != null) s.setSharing(s.client.session, false); if (s.client.session != null) s.setSharing(s.client.session, false);
@@ -137,12 +144,14 @@ class ScreenShare {
private dropPeers(): void { private dropPeers(): void {
for (const pc of this.peers.values()) pc.close(); for (const pc of this.peers.values()) pc.close();
this.peers.clear(); this.peers.clear();
this.live.clear();
this.viewers = 0; this.viewers = 0;
} }
private dropPeer(session: number): void { private dropPeer(session: number): void {
this.peers.get(session)?.close(); this.peers.get(session)?.close();
this.peers.delete(session); this.peers.delete(session);
if (this.live.delete(session)) sounds.play('viewerLeft');
this.viewers = this.peers.size; this.viewers = this.peers.size;
} }
@@ -195,7 +204,11 @@ class ScreenShare {
this.peers.set(viewer, pc); this.peers.set(viewer, pc);
this.viewers = this.peers.size; this.viewers = this.peers.size;
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
if ((pc.connectionState === 'failed' || pc.connectionState === 'closed') && this.peers.get(viewer) === pc) this.dropPeer(viewer); if (this.peers.get(viewer) !== pc) return;
if (pc.connectionState === 'connected' && !this.live.has(viewer)) {
this.live.add(viewer);
sounds.play('viewerJoined');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') this.dropPeer(viewer);
}); });
for (const track of stream.getTracks()) { for (const track of stream.getTracks()) {
const sender = pc.addTrack(track, stream); const sender = pc.addTrack(track, stream);
@@ -241,8 +254,12 @@ class ScreenShare {
// Only people in our channel take part // Only people in our channel take part
if (!msg || !s.client || !this.inMyChannel(s, sender)) return; if (!msg || !s.client || !this.inMyChannel(s, sender)) return;
switch (msg.type) { switch (msg.type) {
case SHARE.on: s.setSharing(sender, true); break; case SHARE.on:
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
s.setSharing(sender, true);
break;
case SHARE.off: case SHARE.off:
if (s.sharing[sender]) sounds.play('shareStopped');
s.setSharing(sender, false); s.setSharing(sender, false);
if (this.watching?.host === s && this.watching.session === sender) this.endWatch(); if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
break; break;
@@ -260,6 +277,7 @@ class ScreenShare {
entered(s: Session): void { entered(s: Session): void {
if (!this.supported(s)) return; if (!this.supported(s)) return;
const self = s.client!.session!; const self = s.client!.session!;
this.enteredAt.set(s, Date.now());
s.clearSharing(); s.clearSharing();
if (this.watching?.host === s) this.unwatch(); if (this.watching?.host === s) this.unwatch();
if (this.host === s) { if (this.host === s) {
@@ -277,6 +295,8 @@ class ScreenShare {
if (this.host === s) this.send(s, [user.session], SHARE.on); if (this.host === s) this.send(s, [user.session], SHARE.on);
return; return;
} }
// A sharer who left or disconnected: their stream is over for us
if (s.sharing[user.session]) sounds.play('shareStopped');
s.setSharing(user.session, false); s.setSharing(user.session, false);
if (this.host === s) this.dropPeer(user.session); if (this.host === s) this.dropPeer(user.session);
if (this.watching?.host === s && this.watching.session === user.session) this.endWatch(); if (this.watching?.host === s && this.watching.session === user.session) this.endWatch();
+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}
+193 -62
View File
@@ -9,21 +9,24 @@
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 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.
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);
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 && (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 {
@@ -31,6 +34,10 @@
return watching?.session === s ? watching.stream : null; return watching?.session === s ? watching.stream : null;
} }
// 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)));
// 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);
@@ -45,7 +52,42 @@
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
$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
// 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 {
@@ -84,12 +126,115 @@
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 spotVideo = $state<HTMLVideoElement>();
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) {
// Refused (a pop-up blocker, or an app build that predates this). The floating
// picture-in-picture window needs no permission where the browser has it.
if (document.pictureInPictureEnabled && spotVideo) {
spotVideo.requestPictureInPicture().catch(() => { share.error = 'The stream could not be opened in a separate window.'; });
} else {
share.error = 'The stream could not be opened in a separate window. If your browser blocked a pop-up, allow it for this page and try again.';
}
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} {#snippet spot()}
<section class="stage" class:meeting={!!focused} aria-label="Voice channel"> {#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"
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>
@@ -102,7 +247,7 @@
{/if} {/if}
</button> </button>
{#if mine} {#if mine}
<span class="status" role="status">You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''}</span> <span class="status" role="status" title="People watching your screen"><Icon name="screen" size={14} />{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button> <button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if} {/if}
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'} <button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
@@ -117,43 +262,21 @@
{#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}{@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}
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{#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 -->
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
</div>
{/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: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={44} 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>
@@ -176,8 +299,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 +308,46 @@
.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; } /* Name tag over the lower left corner */
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); } .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 { display: inline-flex; align-items: center; gap: 6px; 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; } /* Video only: the picture as wide as the chat, as tall as its shape asks */
.tiles.strip li { flex: 0 0 96px; } .stage.solo { background: #000; }
.tiles.strip li.wide { flex-basis: 150px; } .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; }
</style> </style>
+71 -11
View File
@@ -72,16 +72,44 @@ try {
}; };
await picker.getByLabel('Quality').selectOption('low'); await picker.getByLabel('Quality').selectOption('low');
await pickAndStart(); await pickAndStart();
await alice.page.getByText('You are sharing your screen').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
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);
@@ -92,27 +120,52 @@ 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' });
await view.getByText(aliceName).waitFor(); await view.getByText(aliceName).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 }); await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; }); const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
await alice.page.getByText('You are sharing your screen, 1 watching').waitFor(); await alice.page.locator('.stage .status', { hasText: '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`);
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again 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');
// 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();
await view.waitFor({ state: 'detached' }); await view.waitFor({ state: 'detached' });
await alice.page.getByText('You are sharing your screen', { exact: true }).waitFor(); await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
// The sharer's profile says so and offers to watch // The sharer's profile says so and offers to watch
await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click(); await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click();
const profile = bob.page.locator('.panel .sharing'); const profile = bob.page.locator('.panel .sharing');
@@ -127,7 +180,7 @@ try {
await view.waitFor({ state: 'detached' }); await view.waitFor({ state: 'detached' });
await watch.waitFor({ state: 'detached' }); await watch.waitFor({ state: 'detached' });
await profile.waitFor({ state: 'detached' }); await profile.waitFor({ state: 'detached' });
assert.equal(await alice.page.getByText('You are sharing your screen').count(), 0); assert.equal(await alice.page.locator('.stage .status').count(), 0);
console.log('ok: stopping ends the stream for viewers'); console.log('ok: stopping ends the stream for viewers');
// A sharer who disconnects takes the stream with them // A sharer who disconnects takes the stream with them
@@ -138,6 +191,13 @@ try {
await watch.waitFor({ state: 'detached' }); await watch.waitFor({ state: 'detached' });
console.log('ok: disconnecting ends the stream'); console.log('ok: disconnecting ends the stream');
// Sounds: the sharer hears the stream start and stop and viewers come and go; the channel hears streams start and stop
const sounds = (p: Page) => p.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
const heardA = await sounds(alice.page), heardB = await sounds(bob.page);
for (const ev of ['shareStarted', 'viewerJoined', 'viewerLeft', 'shareStopped']) assert.ok(heardA.includes(ev), `sharer hears ${ev}: ${heardA}`);
for (const ev of ['shareStarted', 'shareStopped']) assert.ok(heardB.includes(ev), `viewer hears ${ev}: ${heardB}`);
console.log('ok: sounds for streams and viewers');
console.log('SHARE E2E PASSED'); console.log('SHARE E2E PASSED');
} finally { } finally {
await alice.app.close().catch(() => {}); await alice.app.close().catch(() => {});
+2 -2
View File
@@ -183,11 +183,11 @@ try {
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click(); await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 }); await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
await dialog.getByRole('button', { name: 'Start sharing' }).click(); await dialog.getByRole('button', { name: 'Start sharing' }).click();
await page.getByText('You are sharing your screen').waitFor(); await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await page2.locator('.stage .tile', { hasText: name }).click(); await page2.locator('.stage .tile', { hasText: name }).click();
await page2.getByRole('button', { name: 'Continue' }).click(); await page2.getByRole('button', { name: 'Continue' }).click();
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 }); await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
await page.getByText('You are sharing your screen, 1 watching').waitFor(); await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
console.log('ok: screen sharing between two browsers'); console.log('ok: screen sharing between two browsers');
console.log('WEB E2E PASSED'); console.log('WEB E2E PASSED');