Screen sharing #7
@@ -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
@@ -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
@@ -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}">
|
||||||
|
|||||||
@@ -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]],
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user