aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-19 00:44:32 -0700
committerAdam Soltys <[email protected]>2026-10-19 00:44:32 -0700
commitc39c565630f3a09b76da7a3f0a4d7a37945e0325 (patch)
tree2b6b197bd3488b5739f641ba4b0f2bd207653af3
parente571187361ec9af1db8fba9050c23ec10db677f4 (diff)
downloadcoinosv3-c39c565630f3a09b76da7a3f0a4d7a37945e0325.tar.gz
coinosv3-c39c565630f3a09b76da7a3f0a4d7a37945e0325.zip
Watching a stream: a stream that's on plays through hls.js, held near the live edge (1.5 s back, up to 1.1× when it drifts past 5 s) — Chrome's own HLS player starts three segments back, never catches up, and fails outright (DEMUXER_ERROR_COULD_NOT_PARSE) if a page changes its speed. With the live server's 1 s segments a viewer is 3.5 s behind the broadcaster, from 8.6 s (go-live-test LATENCY=1 measures it: the broadcaster's camera is a clock in black and white blocks, read off the viewer's frame). Recordings, and streams where hls.js can't run (iOS), stay on the browser's player.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/features/messages.js12
-rw-r--r--src/hls-player.js7
-rw-r--r--tools/go-live-test.js47
-rw-r--r--tools/live-stream-test.js3
4 files changed, 62 insertions, 7 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 0b6d8f0..13e328a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -8903,8 +8903,16 @@ export function messagesFeature(ctx) {
el.addEventListener('hlsfatal', fail);
el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
- if (!isHls(src) || canHls()) { el.src = src; go(); return; }
- import('../hls-player.js').then((m) => m.attachHls(el, src)).then((hls) => {
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
w.hls = hls; go();
diff --git a/src/hls-player.js b/src/hls-player.js
index d0878e2..18292dd 100644
--- a/src/hls-player.js
+++ b/src/hls-player.js
@@ -2,10 +2,13 @@
// <video> through Media Source. Chrome and Safari play the playlist as a
// plain src, so this file is fetched only when needed. The single-file
// build replaces it with a stub (attachHls → null): no stream there.
-export async function attachHls(video, src) {
+// A stream that's on (`live`) is held near its live edge: started 1.5 s
+// back, played up to 1.1× when it drifts past 5 s.
+export async function attachHls(video, src, { live = false } = {}) {
const { default: Hls } = await import('hls.js');
if (!Hls.isSupported()) return null;
- const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 });
+ const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30,
+ ...(live ? { liveSyncDuration: 1.5, liveMaxLatencyDuration: 5, maxLiveSyncPlaybackRate: 1.1 } : {}) });
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (_, d) => {
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
index 6c20a7a..244ec93 100644
--- a/tools/go-live-test.js
+++ b/tools/go-live-test.js
@@ -45,7 +45,38 @@ const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => {
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
- '--auto-select-desktop-capture-source=Entire screen'] });
+ '--auto-select-desktop-capture-source=Entire screen',
+ // LATENCY=1: both pages keep full speed (a background tab's timers crawl)
+ ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] });
+// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and
+// white blocks — and the viewer reads it off the frame it shows: how far
+// behind the broadcaster a watcher sees, end to end
+const clockCam = () => {
+ const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ if (!c || !c.video) return real(c);
+ const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720;
+ const x = cv.getContext('2d');
+ setInterval(() => {
+ const v = Date.now() % 16777216;
+ x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720);
+ for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); }
+ }, 1000 / 30);
+ const s = cv.captureStream(30);
+ if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); }
+ return s;
+ };
+};
+const readClock = () => {
+ const v = document.querySelector('video.live-video');
+ if (!v || !v.videoWidth) return null;
+ const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight;
+ const x = cv.getContext('2d'); x.drawImage(v, 0, 0);
+ const k = v.videoWidth / 1280;
+ let n = 0;
+ for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; }
+ return ((Date.now() % 16777216) - n + 16777216) % 16777216;
+};
const relayStub = (fx) => {
// what the relays held before a reload (a test can carry events across one)
@@ -85,6 +116,7 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument(relayStub, []);
+ if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam);
// the registrar on the open internet is not asked: the name below is what the wallet holds
await page.setRequestInterception(true);
page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue()));
@@ -154,13 +186,24 @@ try {
console.log('\n[someone watching]');
const viewer = await browser.newPage();
+ if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); });
await viewer.setViewport({ width: 390, height: 844 });
await viewer.evaluateOnNewDocument(relayStub, [ann]);
const v = helpers(viewer);
await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' });
check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000));
- check('...and it plays', await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
+ const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000);
+ if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30))));
+ check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; })));
+ if (process.env.LATENCY) {
+ await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); });
+ const lags = [];
+ for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); }
+ if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; })));
+ lags.sort((a, b) => a - b);
+ console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`);
+ }
const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json();
check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st));
await viewer.close();
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 0deb48c..440be36 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -183,7 +183,8 @@ try {
await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click());
check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
- check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
+ // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player
+ check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
check('the chat shows what was said', await waitText('hello from the chat', 8000));
check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
const rowSel = '.live-message[data-key="' + chat.id + '"]';