Streams: long-press menu on touch screens, show connecting and failure over the picture
A stream counted as live as soon as its track was announced, before any route existed, so a viewer whose connection never came up saw a black picture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user