Merge pull request 'Screen sharing' (#8) from screen-sharing into main
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -244,6 +244,12 @@ class ScreenShare {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw WebRTC statistics for the overlay: of the stream we watch, or one per viewer of ours
|
||||||
|
async reports(own: boolean): Promise<{ viewer: number | null; report: RTCStatsReport }[]> {
|
||||||
|
if (!own) return this.watchPc ? [{ viewer: null, report: await this.watchPc.getStats() }] : [];
|
||||||
|
return Promise.all([...this.peers].map(async ([viewer, pc]) => ({ viewer, report: await pc.getStats() })));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Called by the session ────────────────────────────────────────────────
|
// ─── Called by the session ────────────────────────────────────────────────
|
||||||
|
|
||||||
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
|
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
|
||||||
|
|||||||
+24
-1
@@ -3,6 +3,8 @@
|
|||||||
import ChatLog from './ChatLog.svelte';
|
import ChatLog from './ChatLog.svelte';
|
||||||
import Composer from './Composer.svelte';
|
import Composer from './Composer.svelte';
|
||||||
import VoiceStage from './VoiceStage.svelte';
|
import VoiceStage from './VoiceStage.svelte';
|
||||||
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
import { ui } from '../lib/ui.svelte.ts';
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
import { session } from '../lib/session.svelte.ts';
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
@@ -10,6 +12,14 @@
|
|||||||
// videoOnly: streams as a plain picture without the tiles (stacked layout)
|
// 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();
|
let { onmenu, onpanel, showMenu, showStage = false, videoOnly = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean; videoOnly?: boolean } = $props();
|
||||||
|
|
||||||
|
// Stacked layout with a stream above the chat: the chat's height can be dragged larger than
|
||||||
|
// its minimum; the picture gets the rest. Unset until dragged, then the picture decides.
|
||||||
|
const LOG_MIN = 124;
|
||||||
|
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
|
||||||
|
const streamAbove = $derived(videoOnly && session.status === 'connected' && ((!!share.stream && here(share.host)) || (!!share.watching && here(share.watching.host))));
|
||||||
|
let logHeight = $state(Number((() => { try { return localStorage.getItem('mumh5.stackedChatHeight'); } catch { return null; } })()) || 0);
|
||||||
|
const logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
|
||||||
|
|
||||||
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');
|
||||||
const topic = $derived.by(() => {
|
const topic = $derived.by(() => {
|
||||||
@@ -18,7 +28,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="chat" aria-label="Chat">
|
<section class="chat" class:video-only={videoOnly} class:log-fixed={logFixed} aria-label="Chat">
|
||||||
<header>
|
<header>
|
||||||
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
|
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
|
||||||
<Icon name={isDm ? 'message' : 'hash'} size={20} />
|
<Icon name={isDm ? 'message' : 'hash'} size={20} />
|
||||||
@@ -48,6 +58,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="logbox" style:height={logFixed ? `${logHeight}px` : undefined}>
|
||||||
|
{#if streamAbove}
|
||||||
|
<ResizeHandle bind:width={logHeight} edge="top" min={LOG_MIN} max={() => Math.max(LOG_MIN, window.innerHeight - 320)} initial={220}
|
||||||
|
storageKey="mumh5.stackedChatHeight" label="Resize the chat" />
|
||||||
|
{/if}
|
||||||
<ChatLog key={session.view}>
|
<ChatLog key={session.view}>
|
||||||
{#snippet empty()}
|
{#snippet empty()}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
@@ -67,6 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChatLog>
|
</ChatLog>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if session.status === 'connected'}
|
{#if session.status === 'connected'}
|
||||||
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
|
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
|
||||||
@@ -74,6 +90,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Stacked layout: room for about five lines of chat whatever the stream above takes */
|
||||||
|
.chat.video-only .logbox { min-height: 124px; flex-shrink: 0; }
|
||||||
|
.logbox { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
/* Dragged to a height of its own: the stream above takes what is left */
|
||||||
|
.chat.log-fixed .logbox { flex: none; }
|
||||||
|
.chat.log-fixed :global(.stage.solo), .chat.log-fixed :global(.stage.solo .spot), .chat.log-fixed :global(.stage.solo .pic) { flex: 1 1 0; min-height: 0; }
|
||||||
|
.chat.log-fixed :global(.stage.solo .spot video) { flex: 1 1 0; max-height: none; }
|
||||||
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
|
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
|
||||||
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
||||||
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
const u = entry.authorSession != null ? session.user(entry.authorSession) : undefined;
|
const u = entry.authorSession != null ? session.user(entry.authorSession) : undefined;
|
||||||
if (u) menu.show(e, userMenu(u));
|
if (u) menu.show(e, userMenu(u));
|
||||||
}
|
}
|
||||||
|
// Full date and time, shown at the right edge while the pointer is on the message
|
||||||
|
const full = $derived(time.toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' }));
|
||||||
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -27,12 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<article class="msg" class:grouped>
|
<article class="msg" class:grouped>
|
||||||
|
<time class="stamp" datetime={time.toISOString()}>{full}</time>
|
||||||
<div class="gutter">
|
<div class="gutter">
|
||||||
{#if grouped}
|
{#if !grouped}<span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>{/if}
|
||||||
<span class="time hover">{hhmm}</span>
|
|
||||||
{:else}
|
|
||||||
<span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if !grouped}
|
{#if !grouped}
|
||||||
@@ -64,7 +63,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
|
.msg { position: relative; display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
|
||||||
|
/* Over the end of the line, with its own background so it stays readable on long messages */
|
||||||
|
.stamp { position: absolute; top: 2px; right: 12px; padding: 1px 6px; line-height: 18px; background: var(--bg-1); border: 1px solid var(--line); visibility: hidden; pointer-events: none; }
|
||||||
|
.msg:hover .stamp { visibility: visible; }
|
||||||
.msg.grouped { margin-top: 0; }
|
.msg.grouped { margin-top: 0; }
|
||||||
.msg:hover { background: rgb(255 255 255 / 0.02); }
|
.msg:hover { background: rgb(255 255 255 / 0.02); }
|
||||||
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
|
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
|
||||||
@@ -73,8 +75,6 @@
|
|||||||
header strong { font-weight: 600; }
|
header strong { font-weight: 600; }
|
||||||
header strong.self { color: var(--accent-hover); }
|
header strong.self { color: var(--accent-hover); }
|
||||||
time, .time { font-size: 11px; color: var(--text-faint); }
|
time, .time { font-size: 11px; color: var(--text-faint); }
|
||||||
.time.hover { visibility: hidden; line-height: 22px; }
|
|
||||||
.msg:hover .time.hover { visibility: visible; }
|
|
||||||
.html { overflow-wrap: anywhere; user-select: text; }
|
.html { overflow-wrap: anywhere; user-select: text; }
|
||||||
.html :global(p) { margin: 0; }
|
.html :global(p) { margin: 0; }
|
||||||
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
|
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
// "Stats for nerds" over the stream: codecs, picture, bitrate and the network path, read
|
||||||
|
// from the WebRTC connection once a second. own: our stream (one block per viewer).
|
||||||
|
let { own, onclose }: { own: boolean; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
type Row = [string, string];
|
||||||
|
let blocks = $state<{ title: string; rows: Row[] }[]>([]);
|
||||||
|
// Byte counters from the previous reading, for the bitrate
|
||||||
|
const last = new Map<string, { bytes: number; at: number }>();
|
||||||
|
|
||||||
|
const kbit = (key: string, bytes: number | undefined, at: number): string => {
|
||||||
|
if (bytes == null) return 'n/a';
|
||||||
|
const prev = last.get(key);
|
||||||
|
last.set(key, { bytes, at });
|
||||||
|
if (!prev || at <= prev.at) return '...';
|
||||||
|
return `${Math.round(((bytes - prev.bytes) * 8) / (at - prev.at))} kbit/s`;
|
||||||
|
};
|
||||||
|
const ms = (seconds: number | undefined) => seconds == null ? 'n/a' : `${Math.round(seconds * 1000)} ms`;
|
||||||
|
const codecName = (report: RTCStatsReport, id?: string): string => {
|
||||||
|
const c = id ? report.get(id) : null;
|
||||||
|
if (!c) return 'n/a';
|
||||||
|
const profile = /profile-level-id=([0-9a-f]+)/i.exec(c.sdpFmtpLine ?? '')?.[1];
|
||||||
|
const stereo = /stereo=1/.test(c.sdpFmtpLine ?? '') ? ', stereo' : '';
|
||||||
|
return `${String(c.mimeType).split('/')[1]}${profile ? ` (${profile})` : ''}${c.clockRate && c.mimeType.startsWith('audio') ? `, ${c.clockRate / 1000} kHz${stereo}` : ''}`;
|
||||||
|
};
|
||||||
|
const KIND: Record<string, string> = { host: 'direct', srflx: 'direct, through a router (STUN)', prflx: 'direct, through a router', relay: 'relayed (TURN)' };
|
||||||
|
|
||||||
|
function describe(report: RTCStatsReport, key: string): Row[] {
|
||||||
|
const all = [...report.values()];
|
||||||
|
const rows: Row[] = [];
|
||||||
|
const rtp = (type: string, kind: string) => all.find(s => s.type === type && s.kind === kind);
|
||||||
|
const video = rtp(own ? 'outbound-rtp' : 'inbound-rtp', 'video');
|
||||||
|
const audio = rtp(own ? 'outbound-rtp' : 'inbound-rtp', 'audio');
|
||||||
|
if (video) {
|
||||||
|
rows.push(['Video codec', codecName(report, video.codecId)]);
|
||||||
|
if (video.frameWidth) rows.push(['Picture', `${video.frameWidth}x${video.frameHeight}${video.framesPerSecond != null ? `, ${Math.round(video.framesPerSecond)} fps` : ''}`]);
|
||||||
|
rows.push(['Video bitrate', kbit(`${key}v`, own ? video.bytesSent : video.bytesReceived, video.timestamp)]);
|
||||||
|
if (own) {
|
||||||
|
if (video.encoderImplementation) rows.push(['Encoder', video.encoderImplementation]);
|
||||||
|
if (video.qualityLimitationReason && video.qualityLimitationReason !== 'none') rows.push(['Held back by', video.qualityLimitationReason]);
|
||||||
|
} else {
|
||||||
|
if (video.decoderImplementation) rows.push(['Decoder', video.decoderImplementation]);
|
||||||
|
rows.push(['Frames dropped', `${video.framesDropped ?? 0} of ${video.framesReceived ?? 0}`]);
|
||||||
|
rows.push(['Video packets lost', `${video.packetsLost ?? 0} of ${(video.packetsReceived ?? 0) + (video.packetsLost ?? 0)}`]);
|
||||||
|
rows.push(['Video jitter', ms(video.jitter)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audio) {
|
||||||
|
rows.push(['Audio codec', codecName(report, audio.codecId)]);
|
||||||
|
rows.push(['Audio bitrate', kbit(`${key}a`, own ? audio.bytesSent : audio.bytesReceived, audio.timestamp)]);
|
||||||
|
if (!own) rows.push(['Audio packets lost', String(audio.packetsLost ?? 0)]);
|
||||||
|
} else rows.push(['Audio', 'none']);
|
||||||
|
if (own) {
|
||||||
|
// What the viewer reports back about our packets
|
||||||
|
const back = all.find(s => s.type === 'remote-inbound-rtp' && s.kind === 'video');
|
||||||
|
if (back) rows.push(['Lost on the way', `${Math.round((back.fractionLost ?? 0) * 100)}%`]);
|
||||||
|
}
|
||||||
|
const transport = all.find(s => s.type === 'transport');
|
||||||
|
const pair = (transport?.selectedCandidatePairId && report.get(transport.selectedCandidatePairId)) || all.find(s => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
|
||||||
|
if (pair) {
|
||||||
|
const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
|
||||||
|
rows.push(['Connection', `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
|
||||||
|
if (remote?.candidateType) rows.push(['Other side', KIND[remote.candidateType] ?? remote.candidateType]);
|
||||||
|
rows.push(['Round trip', ms(pair.currentRoundTripTime)]);
|
||||||
|
if (pair.availableOutgoingBitrate) rows.push(['Estimated capacity', `${Math.round(pair.availableOutgoingBitrate / 1000)} kbit/s`]);
|
||||||
|
} else rows.push(['Connection', 'not established']);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read() {
|
||||||
|
const reports = await share.reports(own);
|
||||||
|
blocks = reports.map(r => {
|
||||||
|
const user = r.viewer != null ? session.user(r.viewer) : undefined;
|
||||||
|
return { title: r.viewer == null ? '' : `To ${user ? store.displayName(user) : `#${r.viewer}`}`, rows: describe(r.report, String(r.viewer)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
read();
|
||||||
|
const timer = setInterval(read, 1000);
|
||||||
|
onDestroy(() => clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="nerd" role="status" aria-label="Stream statistics">
|
||||||
|
<button class="close" onclick={onclose} aria-label="Close statistics">x</button>
|
||||||
|
{#each blocks as b (b.title)}
|
||||||
|
{#if b.title}<strong>{b.title}</strong>{/if}
|
||||||
|
<dl>{#each b.rows as [k, v] (k)}<dt>{k}</dt><dd>{v}</dd>{/each}</dl>
|
||||||
|
{:else}
|
||||||
|
<p>{own ? 'Nobody is watching yet.' : 'Not connected.'}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nerd { position: absolute; top: 8px; left: 8px; z-index: 2; max-width: calc(100% - 16px); max-height: calc(100% - 16px); overflow: auto; padding: 8px 28px 8px 10px;
|
||||||
|
background: rgb(0 0 0 / 0.78); color: #e8e8e8; font: 11px/1.5 var(--mono); user-select: text; }
|
||||||
|
dl { display: grid; grid-template-columns: auto 1fr; gap: 0 12px; margin: 0; }
|
||||||
|
dt { color: #9aa; white-space: nowrap; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
strong { display: block; margin: 6px 0 2px; color: #fff; }
|
||||||
|
strong:first-of-type { margin-top: 0; }
|
||||||
|
p { margin: 0; }
|
||||||
|
.close { position: absolute; top: 2px; right: 4px; padding: 2px 6px; color: #ccc; font: inherit; }
|
||||||
|
.close:hover { color: #fff; }
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
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';
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
|
import ShareStats from './ShareStats.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 stream is shown
|
// (always), and on any window while someone in the channel shares a screen. A stream is shown
|
||||||
@@ -121,6 +122,15 @@
|
|||||||
else { session.showUser(session_); ui.panelOpen = true; }
|
else { session.showUser(session_); ui.panelOpen = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right-click on the picture
|
||||||
|
let showStats = $state(false);
|
||||||
|
const streamMenu = (e: MouseEvent) => menu.show(e, [
|
||||||
|
{ label: 'Open in a separate window', action: popOut },
|
||||||
|
{ label: 'Full screen', action: fullscreen },
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{ label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) }
|
||||||
|
]);
|
||||||
|
|
||||||
let frame = $state<HTMLElement>();
|
let frame = $state<HTMLElement>();
|
||||||
function fullscreen() {
|
function fullscreen() {
|
||||||
if (document.fullscreenElement) document.exitFullscreen();
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
@@ -207,7 +217,10 @@
|
|||||||
{#if popped}
|
{#if popped}
|
||||||
<p class="away">Shown in a separate window.</p>
|
<p class="away">Shown in a separate window.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="pic" role="presentation" oncontextmenu={streamMenu}>
|
||||||
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||||
|
{#if showStats}<ShareStats own={focus === self.session} onclose={() => (showStats = false)} />{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -337,14 +350,16 @@
|
|||||||
.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; }
|
.pic { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||||
.spot:fullscreen video { height: 100%; }
|
.spot video { flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: block; }
|
||||||
.away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
.away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
||||||
.vol { width: 110px; flex: none; padding: 0; }
|
.vol { width: 110px; flex: none; padding: 0; }
|
||||||
/* Video only: the picture as wide as the chat, as tall as its shape asks */
|
/* Video only: the picture as wide as the chat, as tall as its shape asks */
|
||||||
.stage.solo { background: #000; }
|
/* It gives way when the window is short, so the chat below keeps its last lines in view */
|
||||||
.stage.solo .spot { flex: none; }
|
.stage.solo { flex: 0 1 auto; background: #000; }
|
||||||
.stage.solo .spot video { flex: none; height: auto; max-height: 62vh; }
|
.stage.solo .spot { flex: 0 1 auto; }
|
||||||
|
.stage.solo .pic { flex: 0 1 auto; flex-direction: column; }
|
||||||
|
.stage.solo .spot video { flex: 0 1 auto; height: auto; max-height: 62vh; }
|
||||||
.stage.solo .away { flex: none; padding: 24px; }
|
.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 { 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 span { flex: 1; min-width: 0; }
|
||||||
|
|||||||
@@ -226,6 +226,16 @@ try {
|
|||||||
assert.equal(await page.evaluate(() => (window as any).pwned), undefined);
|
assert.equal(await page.evaluate(() => (window as any).pwned), undefined);
|
||||||
assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1);
|
assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1);
|
||||||
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
|
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
|
||||||
|
// The full date appears at the right edge while the pointer is on a message
|
||||||
|
const anyMsg = page.locator('.chat .msg').last();
|
||||||
|
const stamp = anyMsg.locator('.stamp');
|
||||||
|
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'hidden');
|
||||||
|
await anyMsg.hover();
|
||||||
|
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'visible');
|
||||||
|
assert.match(await stamp.innerText(), /\d{4}.*\d{1,2}:\d{2}:\d{2}/);
|
||||||
|
const gap = await anyMsg.evaluate(el => el.getBoundingClientRect().right - el.querySelector('.stamp')!.getBoundingClientRect().right);
|
||||||
|
assert.ok(gap >= 0 && gap < 20, `stamp at the right edge: ${gap}`);
|
||||||
|
await page.mouse.move(5, 5);
|
||||||
console.log('ok: incoming text rendered and sanitized');
|
console.log('ok: incoming text rendered and sanitized');
|
||||||
|
|
||||||
// Right-click menus for text: composer editing, links, messages, authors
|
// Right-click menus for text: composer editing, links, messages, authors
|
||||||
|
|||||||
+39
-1
@@ -104,6 +104,26 @@ try {
|
|||||||
await alice.page.setViewportSize({ width: 600, height: 800 });
|
await alice.page.setViewportSize({ width: 600, height: 800 });
|
||||||
await alice.page.locator('.app.stacked .stage.solo video').waitFor();
|
await alice.page.locator('.app.stacked .stage.solo video').waitFor();
|
||||||
assert.ok(await fits(), 'still as wide as the chat on a narrow window');
|
assert.ok(await fits(), 'still as wide as the chat on a narrow window');
|
||||||
|
// On a short window the picture gives way and the chat keeps about five lines
|
||||||
|
await alice.page.setViewportSize({ width: 600, height: 700 });
|
||||||
|
const heights = () => alice.page.evaluate(() => [document.querySelector('.chat [role=log]')!.getBoundingClientRect().height, document.querySelector('.stage.solo video')!.getBoundingClientRect().height]);
|
||||||
|
await alice.page.waitForFunction(() => document.querySelector('.stage.solo video')!.getBoundingClientRect().height < 250);
|
||||||
|
const [logH, videoH] = await heights();
|
||||||
|
assert.ok(logH >= 120, `chat keeps its lines: ${logH}`);
|
||||||
|
assert.ok(videoH > 40, `the picture is still shown: ${videoH}`);
|
||||||
|
await alice.page.setViewportSize({ width: 600, height: 800 });
|
||||||
|
// The chat can be dragged taller, never below its minimum; the picture takes the rest
|
||||||
|
const chatHandle = alice.page.getByRole('separator', { name: 'Resize the chat' });
|
||||||
|
await chatHandle.focus();
|
||||||
|
for (let i = 0; i < 12; i++) await alice.page.keyboard.press('ArrowUp');
|
||||||
|
await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height > 200);
|
||||||
|
const grown = (await heights())[0];
|
||||||
|
for (let i = 0; i < 40; i++) await alice.page.keyboard.press('ArrowDown');
|
||||||
|
await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height < 130);
|
||||||
|
const [smallest, videoNow] = await heights();
|
||||||
|
assert.ok(smallest >= 124, `not below the minimum: ${smallest}`);
|
||||||
|
assert.ok(videoNow > 100, `the picture fills the rest: ${videoNow}`);
|
||||||
|
console.log(`ok: stacked chat resizable (${Math.round(grown)} px down to ${Math.round(smallest)} px)`);
|
||||||
if (process.env.SHOTS_DIR) await alice.page.screenshot({ path: `${process.env.SHOTS_DIR}/stacked.png` });
|
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.getByText('0 watching').waitFor();
|
||||||
await alice.page.setViewportSize({ width: 1280, height: 800 });
|
await alice.page.setViewportSize({ width: 1280, height: 800 });
|
||||||
@@ -143,9 +163,27 @@ try {
|
|||||||
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
|
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
|
||||||
|
|
||||||
if (process.env.SHOTS_DIR) { await bob.page.waitForTimeout(500); await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/meeting.png` }); }
|
if (process.env.SHOTS_DIR) { await bob.page.waitForTimeout(500); await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/meeting.png` }); }
|
||||||
|
// Right-click on the picture: statistics for both sides
|
||||||
|
const nerd = async (page: Page, expect: RegExp[]) => {
|
||||||
|
await page.locator('.spot video').click({ button: 'right' });
|
||||||
|
await page.getByRole('menuitem', { name: 'Stats for nerds' }).click();
|
||||||
|
const box = page.getByRole('status', { name: 'Stream statistics' });
|
||||||
|
for (const e of expect) await box.getByText(e).first().waitFor({ timeout: 8000 });
|
||||||
|
const text = (await box.innerText()).replace(/\n+/g, ' | ');
|
||||||
|
await box.getByRole('button', { name: 'Close statistics' }).click();
|
||||||
|
await box.waitFor({ state: 'detached' });
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
const viewerStats = await nerd(bob.page, [/^(VP8|VP9|H264|AV1)/, /^\d+x\d+/, /^\d+ kbit\/s$/, /^direct/]);
|
||||||
|
const sharerStats = await nerd(alice.page, [new RegExp(`To ${bobName}`), /^(VP8|VP9|H264|AV1)/, /^\d+ kbit\/s$/]);
|
||||||
|
console.log(`ok: stats for nerds (viewer: ${viewerStats.slice(0, 200)}...)`);
|
||||||
|
assert.ok(sharerStats.includes('Round trip'));
|
||||||
|
|
||||||
// The large picture can move to a window of its own
|
// The large picture can move to a window of its own
|
||||||
const opened = bob.app.waitForEvent('window');
|
const opened = bob.app.waitForEvent('window');
|
||||||
await view.getByRole('button', { name: 'Open in a separate window' }).click();
|
// From the right-click menu on the picture
|
||||||
|
await bob.page.locator('.spot video').click({ button: 'right' });
|
||||||
|
await bob.page.getByRole('menuitem', { name: 'Open in a separate window' }).click();
|
||||||
const streamWindow = await opened;
|
const streamWindow = await opened;
|
||||||
await streamWindow.waitForFunction(() => (document.querySelector('video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
await streamWindow.waitForFunction(() => (document.querySelector('video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||||
await view.getByText('Shown in a separate window.').waitFor();
|
await view.getByText('Shown in a separate window.').waitFor();
|
||||||
|
|||||||
Reference in New Issue
Block a user