Screen sharing #10
@@ -82,7 +82,7 @@ mumh5 keeps the foundation and replaces the experience.
|
||||
- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers
|
||||
- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A 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
|
||||
- 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 choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), 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
|
||||
|
||||
### Chat
|
||||
|
||||
+14
-1
@@ -15,6 +15,7 @@
|
||||
import ChannelDialog from './ui/ChannelDialog.svelte';
|
||||
import PublicServers from './ui/PublicServers.svelte';
|
||||
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
|
||||
import ImageViewer from './ui/ImageViewer.svelte';
|
||||
import ShareDialog from './ui/ShareDialog.svelte';
|
||||
import { share } from './lib/share.svelte.ts';
|
||||
import ResizeHandle from './ui/ResizeHandle.svelte';
|
||||
@@ -63,13 +64,24 @@
|
||||
else ui.panelOpen = false;
|
||||
}
|
||||
|
||||
// Images in rendered text (messages, descriptions, the quick preview) and image embeds open
|
||||
// large on click. Not in editors, link preview cards or the YouTube card.
|
||||
function imageClick(e: MouseEvent) {
|
||||
if (e.button !== 0 || !(e.target instanceof HTMLImageElement)) return;
|
||||
const img = e.target;
|
||||
if (!img.closest('.html, .embed') || img.closest('[contenteditable], .rich, .card, .yt')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ui.image = img.currentSrc || img.src;
|
||||
}
|
||||
|
||||
const closeOverlays = () => {
|
||||
navOpen = false;
|
||||
if (!panelInline) ui.panelOpen = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={width} />
|
||||
<svelte:window bind:innerWidth={width} onclickcapture={imageClick} />
|
||||
|
||||
{#snippet rail()}
|
||||
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
|
||||
@@ -134,6 +146,7 @@
|
||||
{/if}
|
||||
{#if share.dialog}<ShareDialog onclose={() => (share.dialog = null)} />{/if}
|
||||
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
|
||||
<ImageViewer />
|
||||
<ContextMenu />
|
||||
|
||||
<style>
|
||||
|
||||
@@ -73,6 +73,10 @@ label { display: block; font-size: 12px; font-weight: 600; text-transform: upper
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.icon-btn.on { color: var(--danger); }
|
||||
|
||||
/* Images in rendered text open large on click (App.svelte) */
|
||||
.html img, .embed img { cursor: zoom-in; }
|
||||
.card img { cursor: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); }
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ export function channelMenu(c: Channel): MenuItem[] {
|
||||
return [
|
||||
{ header: true, label: c.name || 'Root' },
|
||||
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
|
||||
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
|
||||
// Opens the channel's chat in the side panel, ready to type, without joining it
|
||||
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
|
||||
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
|
||||
{ separator: true, label: '' },
|
||||
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
|
||||
|
||||
@@ -30,6 +30,8 @@ class Ui {
|
||||
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice');
|
||||
publicServers = $state(false);
|
||||
serverInfo = $state(false);
|
||||
// Image shown large (clicked in the chat or in a description)
|
||||
image = $state<string | null>(null);
|
||||
// Set by the toolbar to open the own profile with the description editor
|
||||
editDescription = $state(false);
|
||||
// Add-server dialog, optionally prefilled (from the public list)
|
||||
|
||||
@@ -49,8 +49,20 @@
|
||||
// Short grace period, so the pointer can move from the row into the preview
|
||||
function leaveRow() {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => (preview = null), 250);
|
||||
// An image opened from the preview covers it; the preview stays underneath
|
||||
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250);
|
||||
}
|
||||
// Back from the image: the preview stays while the pointer is on it, and goes when it is not
|
||||
let popEl = $state<HTMLElement>();
|
||||
let hadImage = false;
|
||||
$effect(() => {
|
||||
const open = !!ui.image;
|
||||
if (hadImage && !open && preview && !popEl?.matches(':hover')) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => { if (!popEl?.matches(':hover')) preview = null; }, 1500);
|
||||
}
|
||||
hadImage = open;
|
||||
});
|
||||
function showDescription() {
|
||||
clearTimeout(hoverTimer);
|
||||
preview = null;
|
||||
@@ -131,7 +143,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if preview}
|
||||
<div class="desc-pop" role="tooltip" style:left="{preview.x}px" style:top="{preview.y}px"
|
||||
<div class="desc-pop" role="tooltip" bind:this={popEl} style:left="{preview.x}px" style:top="{preview.y}px"
|
||||
onmouseenter={() => clearTimeout(hoverTimer)} onmouseleave={leaveRow}>
|
||||
<strong>{channel.name || 'Root'}</strong>
|
||||
{#if descriptionHtml}<div class="html">{@html descriptionHtml}</div>{:else}<p>Loading description...</p>{/if}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
</script>
|
||||
|
||||
<section class="chat" class:video-only={videoOnly} class:log-fixed={logFixed} aria-label="Chat">
|
||||
<!-- Stacked layout: no title bar; the channel list above already names the channel -->
|
||||
{#if !videoOnly}
|
||||
<header>
|
||||
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
|
||||
<Icon name={isDm ? 'message' : 'hash'} size={20} />
|
||||
@@ -37,6 +39,7 @@
|
||||
<span class="grow"></span>
|
||||
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{#if session.status === 'connected'}<VoiceStage always={showStage} {videoOnly} />{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
|
||||
// An image from the chat or a description, as large as the window allows. A click anywhere
|
||||
// or Escape closes it.
|
||||
const src = $derived(ui.image);
|
||||
const remote = $derived(!!src && /^https?:/i.test(src));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={e => { if (src && e.key === 'Escape') { e.stopPropagation(); ui.image = null; } }} />
|
||||
|
||||
{#if src}
|
||||
<div class="viewer" role="dialog" aria-modal="true" aria-label="Image">
|
||||
<button class="shade" aria-label="Close image" onclick={() => (ui.image = null)}></button>
|
||||
<img {src} alt="" />
|
||||
{#if remote}<a class="open" href={src} target="_blank" rel="noopener noreferrer">Open original</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.viewer { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.shade { position: absolute; inset: 0; background: rgb(0 0 0 / 0.85); cursor: zoom-out; }
|
||||
img { position: relative; max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: none; box-shadow: 0 16px 48px rgb(0 0 0 / 0.6); }
|
||||
.open { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); padding: 4px 10px; font-size: 13px; color: #fff; background: rgb(0 0 0 / 0.7); }
|
||||
</style>
|
||||
@@ -96,6 +96,8 @@
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* The server you are talking on leads the list, wherever it sits in your own order */
|
||||
.tile.voice { order: -1; }
|
||||
.rail {
|
||||
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
|
||||
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
const s = store.settings;
|
||||
let embedHosts = $state(s.embedHosts.join('\n'));
|
||||
let testResult = $state('');
|
||||
|
||||
// Public STUN servers to pick from; anything else is typed in
|
||||
const STUN_PRESETS = [
|
||||
{ name: 'Google', url: 'stun.l.google.com:19302' },
|
||||
{ name: 'Cloudflare', url: 'stun.cloudflare.com:3478' }
|
||||
];
|
||||
let stunCustom = $state(!!s.stunServer && !STUN_PRESETS.some(p => p.url === s.stunServer));
|
||||
const stunChoice = $derived(stunCustom ? 'custom' : s.stunServer);
|
||||
function chooseStun(value: string) {
|
||||
stunCustom = value === 'custom';
|
||||
// Keep what was typed when switching to "another server"
|
||||
if (!stunCustom) s.stunServer = value;
|
||||
else if (STUN_PRESETS.some(p => p.url === s.stunServer)) s.stunServer = '';
|
||||
store.saveSettings();
|
||||
}
|
||||
// Settings that only work through the upload host are disabled until it is set up
|
||||
const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim()));
|
||||
const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key');
|
||||
@@ -60,9 +75,16 @@
|
||||
{#if tab === 'voice'}
|
||||
<VoiceSettings />
|
||||
<h3>Screen sharing</h3>
|
||||
<label for="s-stun">STUN server <span class="opt">(optional)</span></label>
|
||||
<input id="s-stun" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" />
|
||||
<p class="help">Screen sharing connects you directly to the people watching. Across the internet both the person sharing and the people watching need a STUN server here to find a route; it learns your IP address but never sees the stream. Empty means no outside server is contacted, which only works on the same network.</p>
|
||||
<label for="s-stun">STUN server</label>
|
||||
<select id="s-stun" value={stunChoice} onchange={e => chooseStun(e.currentTarget.value)}>
|
||||
<option value="">{isWeb ? 'This site\'s own server' : 'None (same network only)'}</option>
|
||||
{#each STUN_PRESETS as p (p.url)}<option value={p.url}>{p.name} ({p.url})</option>{/each}
|
||||
<option value="custom">Another server...</option>
|
||||
</select>
|
||||
{#if stunChoice === 'custom'}
|
||||
<input class="stun-custom" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" aria-label="STUN server address" />
|
||||
{/if}
|
||||
<p class="help">Screen sharing connects you directly to the people watching. Across the internet both sides need a STUN server to find a route; it learns your IP address but never sees the stream. {isWeb ? 'By default the site you are on provides it.' : 'With none, no outside server is contacted and streams only work on the same network.'} Networks that block direct connections, like many mobile ones, need more than STUN.</p>
|
||||
{:else if tab === 'sounds'}
|
||||
<SoundSettings />
|
||||
{:else if tab === 'appearance'}
|
||||
@@ -123,7 +145,7 @@
|
||||
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
|
||||
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
h3 { margin: 22px 0 0; font-size: 14px; }
|
||||
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
|
||||
.stun-custom { margin-top: 6px; }
|
||||
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.row { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.row > div { flex: 1; }
|
||||
|
||||
+42
-25
@@ -71,24 +71,37 @@
|
||||
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
|
||||
// The tiles fill the stage completely, like a meeting's tile view: rows of equal tiles, with
|
||||
// the number of columns chosen so the tiles come closest to a 16:9 shape. A last row with
|
||||
// fewer people stretches, so no space is left over. Measured live, so it follows every resize.
|
||||
let gridW = $state(0), gridH = $state(0);
|
||||
const GAP = 8;
|
||||
const tileW = $derived.by(() => {
|
||||
const GAP = 8, PAD = 8;
|
||||
const cols = $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;
|
||||
const w = gridW - PAD * 2, h = gridH - PAD * 2;
|
||||
if (n < 2 || w <= 0 || h <= 0) return 1;
|
||||
let best = 1, bestSize = 0;
|
||||
for (let c = 1; c <= n; c++) {
|
||||
const r = Math.ceil(n / c);
|
||||
// Width of the largest 16:9 picture that fits one cell
|
||||
const size = Math.min((w - GAP * (c - 1)) / c, ((h - GAP * (r - 1)) / r) * 16 / 9);
|
||||
if (size > bestSize + 0.5) { best = c; bestSize = size; }
|
||||
}
|
||||
return Math.max(72, Math.floor(Math.min(best, 520)));
|
||||
return best;
|
||||
});
|
||||
// In the meeting view everyone sits in one row under the picture
|
||||
const rows = $derived.by(() => {
|
||||
if (focused) return [people];
|
||||
const out: (typeof people)[] = [];
|
||||
for (let i = 0; i < people.length; i += cols) out.push(people.slice(i, i + cols));
|
||||
return out;
|
||||
});
|
||||
const avatar = $derived.by(() => {
|
||||
if (focused) return 36;
|
||||
const cellW = (gridW - PAD * 2 - GAP * (cols - 1)) / cols;
|
||||
const cellH = (gridH - PAD * 2 - GAP * (rows.length - 1)) / Math.max(1, rows.length);
|
||||
return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34))));
|
||||
});
|
||||
// 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());
|
||||
function load(): boolean {
|
||||
@@ -299,12 +312,13 @@
|
||||
{#if focused && focus != null && !collapsed}{@render spot()}{/if}
|
||||
|
||||
{#if !collapsed}
|
||||
<ul class="tiles" class:strip={!!focused} bind:clientWidth={gridW} bind:clientHeight={gridH}>
|
||||
{#each people as u (u.session)}
|
||||
<div class="tiles" class:strip={!!focused} role="group" aria-label="People in this channel" bind:clientWidth={gridW} bind:clientHeight={gridH}>
|
||||
{#each rows as row, r (r)}
|
||||
<div class="row" role="presentation">
|
||||
{#each row as u (u.session)}
|
||||
{@const stream = streamOf(u.session)}
|
||||
{@const sharing = !!session.sharing[u.session]}
|
||||
{@const isSelf = u.session === self.session}
|
||||
<li style:width="{shownW}px">
|
||||
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
|
||||
onclick={() => tileClick(u.session)}
|
||||
oncontextmenu={e => menu.show(e, userMenu(u))}
|
||||
@@ -322,11 +336,12 @@
|
||||
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if watching?.state === 'failed'}
|
||||
<p class="failed">Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice (the person sharing has to restart the stream after setting it).</p>
|
||||
<p class="failed">Could not connect to the stream. Across the internet both of you need a STUN server, chosen in Settings, Voice (the person sharing has to restart the stream afterwards).</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -344,12 +359,12 @@
|
||||
.mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; }
|
||||
|
||||
/* Tile view: equal 16:9 tiles, centered, sized by the script to fill the stage */
|
||||
.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; }
|
||||
.tiles li { flex: none; }
|
||||
.tile { position: relative; width: 100%; aspect-ratio: 16 / 9; display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||
.tiles { flex: 1; min-height: 0; padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
|
||||
.row { flex: 1 1 0; min-height: 0; display: flex; gap: 8px; }
|
||||
.tile { position: relative; flex: 1 1 0; min-width: 0; min-height: 0; 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.talking { border-color: var(--speaking); }
|
||||
.tile.talking { border-color: var(--speaking); box-shadow: 0 0 0 1px var(--speaking), 0 0 18px -4px var(--speaking); }
|
||||
.tile.focused { border-color: var(--accent); }
|
||||
/* Name tag over the lower left corner */
|
||||
.who { position: absolute; left: 4px; bottom: 4px; max-width: calc(100% - 8px); padding: 1px 6px; font-size: 12px; color: #fff; background: rgb(0 0 0 / 0.6);
|
||||
@@ -388,5 +403,7 @@
|
||||
.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; }
|
||||
/* One row of a fixed height; tiles share the width and scroll sideways when there are many */
|
||||
.tiles.strip { flex: none; height: 92px; padding: 6px 8px; overflow-x: auto; overflow-y: hidden; }
|
||||
.tiles.strip .tile { flex: 1 0 120px; max-width: 220px; }
|
||||
</style>
|
||||
|
||||
+41
-1
@@ -334,6 +334,21 @@ try {
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: quick audio options on right-click');
|
||||
|
||||
// STUN server for screen sharing: none by default, presets, or a typed address
|
||||
await page.getByTitle('Settings').click();
|
||||
const stun = page.getByLabel('STUN server', { exact: true });
|
||||
assert.equal(await stun.inputValue(), '');
|
||||
await stun.selectOption({ label: 'Cloudflare (stun.cloudflare.com:3478)' });
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.cloudflare.com:3478');
|
||||
await stun.selectOption('custom');
|
||||
await page.getByLabel('STUN server address').fill('stun.example.org:3478');
|
||||
await page.getByLabel('STUN server address').blur();
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.example.org:3478');
|
||||
await stun.selectOption('');
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), '');
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: STUN server choices');
|
||||
|
||||
// Color schemes apply right away and are remembered
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||
@@ -716,7 +731,15 @@ try {
|
||||
await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Join channel' }).click();
|
||||
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
||||
console.log('ok: channel context menu joins');
|
||||
// Messaging a channel from its menu opens the side chat, ready to type, without joining
|
||||
await page.locator('.channel .name', { hasText: 'Games' }).click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Send message to channel' }).click();
|
||||
const sideInput = page.locator('.panel .composer [contenteditable]');
|
||||
await sideInput.waitFor();
|
||||
assert.equal(await sideInput.getAttribute('data-placeholder'), 'Message #Games');
|
||||
await page.waitForFunction(() => document.activeElement === document.querySelector('.panel .composer [contenteditable]'), null, { timeout: 3000 });
|
||||
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
||||
console.log('ok: channel context menu joins, and messages a channel from the side chat');
|
||||
|
||||
// Side panel can be resized by dragging its left edge
|
||||
const panelBox = (await page.locator('aside.panel').boundingBox())!;
|
||||
@@ -856,6 +879,19 @@ try {
|
||||
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
|
||||
console.log(`ok: inline image without upload host (${inline.length} chars)`);
|
||||
|
||||
// A click on an image in the chat shows it large; Escape or a click closes it
|
||||
await page.locator('.chat .msg .html img').last().click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Image' });
|
||||
await viewer.waitFor();
|
||||
const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]);
|
||||
assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await viewer.waitFor({ state: 'detached' });
|
||||
await page.locator('.chat .msg .html img').last().click();
|
||||
await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } });
|
||||
await viewer.waitFor({ state: 'detached' });
|
||||
console.log('ok: images open large on click');
|
||||
|
||||
// ─── Several servers at once ─────────────────────────────────────────────
|
||||
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
|
||||
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
|
||||
@@ -881,6 +917,10 @@ try {
|
||||
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||
await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf);
|
||||
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true);
|
||||
// The server with voice leads the rail, although it was added last
|
||||
const railTop = (title: string) => page.locator(`.rail .tile[title^="${title}"]`).boundingBox().then(b => b!.y);
|
||||
assert.ok(await railTop('Second') < await railTop('Test Server'), 'voice server on top');
|
||||
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Test Server/, 'the saved order is untouched');
|
||||
console.log('ok: voice follows the channel you join');
|
||||
|
||||
// Messages on the server not on screen show as unread on its tile
|
||||
|
||||
+18
-7
@@ -140,18 +140,29 @@ try {
|
||||
await tile.waitFor();
|
||||
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();
|
||||
// The tiles fill the stage exactly, whatever its size
|
||||
const fills = () => bob.page.evaluate(() => {
|
||||
const grid = document.querySelector('.stage .tiles')!.getBoundingClientRect();
|
||||
const tiles = [...document.querySelectorAll('.stage .tile')].map(e => e.getBoundingClientRect());
|
||||
const left = Math.min(...tiles.map(r => r.left)), right = Math.max(...tiles.map(r => r.right));
|
||||
const top = Math.min(...tiles.map(r => r.top)), bottom = Math.max(...tiles.map(r => r.bottom));
|
||||
return { ok: left - grid.left < 10 && grid.right - right < 10 && top - grid.top < 10 && grid.bottom - bottom < 10, height: Math.round(tiles[0].height), count: tiles.length };
|
||||
});
|
||||
const first = await fills();
|
||||
assert.ok(first.ok && first.count === 2, `tiles fill the stage: ${JSON.stringify(first)}`);
|
||||
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();
|
||||
await bob.page.waitForFunction(h => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().height) > h, first.height);
|
||||
const taller = await fills();
|
||||
assert.ok(taller.ok, 'still filled after making the stage taller');
|
||||
await bob.page.setViewportSize({ width: 820, height: 700 });
|
||||
await bob.page.waitForFunction(() => document.querySelector('.stage .tiles')!.getBoundingClientRect().width < 700);
|
||||
assert.ok((await fills()).ok, 'still filled after making the window narrower');
|
||||
await bob.page.setViewportSize({ width: 1280, height: 800 });
|
||||
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)`);
|
||||
console.log(`ok: tiles fill the stage at every size (${first.height} to ${taller.height} px tall)`);
|
||||
|
||||
await tile.click();
|
||||
await bob.page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@@ -188,6 +188,13 @@ try {
|
||||
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||
await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
|
||||
// Phone-sized window: the picture still has room and frames
|
||||
await page2.setViewportSize({ width: 390, height: 780 });
|
||||
await page2.waitForFunction(() => {
|
||||
const v = document.querySelector('.spot video') as HTMLVideoElement | null;
|
||||
return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300;
|
||||
}, null, { timeout: 15000 });
|
||||
if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` });
|
||||
console.log('ok: screen sharing between two browsers');
|
||||
|
||||
console.log('WEB E2E PASSED');
|
||||
|
||||
Reference in New Issue
Block a user