Streams: long-press menu on touch screens, show connecting and failure over the picture #9

Merged
kibi merged 1 commits from screen-sharing into main 2026-10-01 20:54:42 +00:00
3 changed files with 50 additions and 9 deletions
+8 -2
View File
@@ -230,9 +230,15 @@ class ScreenShare {
this.watchPc?.close(); this.watchPc?.close();
const pc = new RTCPeerConnection({ iceServers: this.iceServers() }); const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
this.watchPc = pc; this.watchPc = pc;
pc.addEventListener('track', e => { if (this.watchPc === pc) this.setWatchState('live', e.streams[0]); }); // The track is announced with the description, before any route exists: the stream only
// counts as live once the connection is up, otherwise the viewer stares at a black picture
pc.addEventListener('track', e => {
if (this.watchPc === pc) this.setWatchState(pc.connectionState === 'connected' ? 'live' : 'connecting', e.streams[0]);
});
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'failed' && this.watchPc === pc) this.setWatchState('failed'); if (this.watchPc !== pc) return;
if (pc.connectionState === 'connected') this.setWatchState('live');
else if (pc.connectionState === 'failed') this.setWatchState('failed');
}); });
try { try {
await pc.setRemoteDescription({ type: 'offer', sdp }); await pc.setRemoteDescription({ type: 'offer', sdp });
+31 -7
View File
@@ -101,10 +101,14 @@
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting); const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
// The same stream can sit in a tile and on the stage at once // Mobile browsers do not always honour autoplay for a stream attached later, so play is asked for too
function media(node: HTMLMediaElement, stream: MediaStream | null) { function media(node: HTMLMediaElement, stream: MediaStream | null) {
node.srcObject = stream; const attach = (s: MediaStream | null) => {
return { update(next: MediaStream | null) { if (node.srcObject !== next) node.srcObject = next; } }; node.srcObject = s;
if (s) node.play().catch(() => {});
};
attach(stream);
return { update(next: MediaStream | null) { if (node.srcObject !== next) attach(next); } };
} }
// Sound comes from one hidden player, so it plays once however many pictures are shown // Sound comes from one hidden player, so it plays once however many pictures are shown
@@ -124,12 +128,23 @@
// Right-click on the picture // Right-click on the picture
let showStats = $state(false); let showStats = $state(false);
const streamMenu = (e: MouseEvent) => menu.show(e, [ const streamItems = () => [
{ label: 'Open in a separate window', action: popOut }, { label: 'Open in a separate window', action: popOut },
{ label: 'Full screen', action: fullscreen }, { label: 'Full screen', action: fullscreen },
{ separator: true, label: '' }, { separator: true, label: '' },
{ label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) } { label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) }
]); ];
const streamMenu = (e: MouseEvent) => menu.show(e, streamItems());
// Touch screens have no right-click: holding a finger on the picture opens the same menu
let hold: ReturnType<typeof setTimeout> | undefined;
function touchStart(e: TouchEvent) {
const touch = e.touches[0];
clearTimeout(hold);
if (e.touches.length !== 1) return;
const { clientX, clientY } = touch;
hold = setTimeout(() => menu.showAt(clientX, clientY, streamItems()), 550);
}
const touchEnd = () => clearTimeout(hold);
let frame = $state<HTMLElement>(); let frame = $state<HTMLElement>();
function fullscreen() { function fullscreen() {
@@ -217,8 +232,14 @@
{#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}> <div class="pic" role="presentation" oncontextmenu={streamMenu}
ontouchstart={touchStart} ontouchend={touchEnd} ontouchmove={touchEnd} ontouchcancel={touchEnd}>
<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 focus !== self.session && watching && watching.state !== 'live'}
<p class="pending" class:bad={watching.state === 'failed'}>{watching.state === 'failed'
? 'Could not connect to the stream. Across the internet both of you need a STUN server; some mobile and company networks block direct connections altogether.'
: 'Connecting to the stream...'}</p>
{/if}
{#if showStats}<ShareStats own={focus === self.session} onclose={() => (showStats = false)} />{/if} {#if showStats}<ShareStats own={focus === self.session} onclose={() => (showStats = false)} />{/if}
</div> </div>
{/if} {/if}
@@ -350,7 +371,10 @@
.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; }
.pic { position: relative; flex: 1; min-height: 0; display: flex; } .pic { position: relative; flex: 1; min-height: 0; display: flex; -webkit-touch-callout: none; user-select: none; }
.pending { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px;
color: #ddd; font-size: 13px; background: rgb(0 0 0 / 0.6); }
.pending.bad { color: #ffb3ad; }
.spot video { flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: block; } .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; }
+11
View File
@@ -179,6 +179,17 @@ try {
console.log(`ok: stats for nerds (viewer: ${viewerStats.slice(0, 200)}...)`); console.log(`ok: stats for nerds (viewer: ${viewerStats.slice(0, 200)}...)`);
assert.ok(sharerStats.includes('Round trip')); assert.ok(sharerStats.includes('Round trip'));
// Touch screens: holding a finger on the picture opens the same menu
await bob.page.evaluate(() => {
const el = document.querySelector('.spot .pic')!;
const r = el.getBoundingClientRect();
const touch = new Touch({ identifier: 1, target: el, clientX: r.left + 40, clientY: r.top + 40 });
el.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
});
await bob.page.getByRole('menuitem', { name: 'Stats for nerds' }).waitFor({ timeout: 3000 });
await bob.page.keyboard.press('Escape');
console.log('ok: long press on the picture opens the menu');
// 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');
// From the right-click menu on the picture // From the right-click menu on the picture