aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 22:55:35 -0700
committerAdam Soltys <[email protected]>2026-10-18 22:55:41 -0700
commitf7dbd901043961ebbc96abe90adf4fba6a01dfb0 (patch)
treea6552b7f157105ac1d43eab04cca29cce62ce0fc
parent31ef5c01527e110ed8a4faa500c72a1773586b50 (diff)
downloadcoinosv3-f7dbd901043961ebbc96abe90adf4fba6a01dfb0.tar.gz
coinosv3-f7dbd901043961ebbc96abe90adf4fba6a01dfb0.zip
Streams: one our live server has ended reads ended, whatever its last announcement says (an app closed mid-stream never says so, and it read live for an hour with no way to stop it); its host gets End — on the stream and on their profile — which publishes the ended announcement with the recording. Each announcement is now newer than the last to the second (an ended stamped the same second as the live before it lost to it). A recording that is a web page (zap.stream's Rumble links) gets a Watch the recording link instead of a player that can't play it.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/features/messages.js75
-rw-r--r--src/i18n.js3
-rw-r--r--src/style.css3
-rw-r--r--tools/go-live-test.js30
-rw-r--r--tools/live-stream-test.js12
5 files changed, 116 insertions, 7 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index c82670b..b39bd02 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -54,7 +54,7 @@ import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js';
-import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus, isLiveNow, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -8514,6 +8514,50 @@ export function messagesFeature(ctx) {
// who is on now (host -> their stream), for the rings round faces: worked
// out again when a stream changes and every half minute (one goes stale)
let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ const serverOver = (ev) => { const st = liveServerState(ev); return st === 'ended' || st === 'removed'; };
+ const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; };
+ const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev);
+ // Your own stream announced live that this tab isn't sending (the app was
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]);
+ const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats'));
+ if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]);
+ const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ liveHostsAt = 0;
+ render();
+ await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt);
+ } catch (e) { toast(e.message || String(e)); }
+ }
function liveHostNow(pk) {
if (!liveSeen.size) return null;
if (Date.now() - liveHostsAt > 30_000) {
@@ -8635,7 +8679,14 @@ export function messagesFeature(ctx) {
function liveBanner(pk) {
askLiveOf(pk);
const ev = liveNowOf(pk);
- if (!ev) return null;
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
h('span', { class: 'live-badge live' }, t('liveBadge')),
h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
@@ -8705,7 +8756,12 @@ export function messagesFeature(ctx) {
stopLivePlayer(w);
}
// What to play: the stream while it's on, its recording once it's over.
- const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' ? liveRecording(ev) : '');
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // that gets a link out — a player can't play a page.
+ const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || '');
+ const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; };
+ const livePlayable = (ev) => (isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : '');
// The <video> is the page's own once it plays: Chrome takes the playlist as
// its src; anywhere without HLS of its own, hls.js feeds it. The morph
// leaves it alone from then on (it would strip the src hls.js set).
@@ -8818,6 +8874,9 @@ export function messagesFeature(ctx) {
mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
: backBtn(() => { ui.liveStream = null; }),
h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
liveBadge(ev)),
mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
@@ -8834,7 +8893,9 @@ export function messagesFeature(ctx) {
src && !w.err
? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
- : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) }, h('span', { class: 'live-say' }, say))),
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
avatar(host, 'chat-avatar mini'),
h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
@@ -9009,7 +9070,11 @@ export function messagesFeature(ctx) {
if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
- const partial = { kind: LIVE_KIND, content: '', created_at: Math.floor(Date.now() / 1000), tags };
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
takeLive([evt]);
if (bc === b) b.ev = evt;
diff --git a/src/i18n.js b/src/i18n.js
index 998d233..e0d7aa5 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -463,6 +463,9 @@ const STR = {
livePlanned: 'SOON',
liveEnded: 'ENDED',
livePast: 'Past streams',
+ liveWatchRecording: 'Watch the recording',
+ liveStaleMine: '“{title}” still says live, but nothing is going out.',
+ liveNotYours: 'Only the account that started this stream can end it.',
liveRecordingBadge: 'RECORDING',
liveUntitled: 'Live stream',
liveWatching: '{n} watching',
diff --git a/src/style.css b/src/style.css
index 95ce04c..e462ad3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -923,7 +923,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; }
.live-frame { width: 100%; height: 100%; }
.live-video { width: 100%; height: 100%; display: block; background: #000; }
-.live-still { border-radius: 0; height: 100%; display: flex; align-items: center; justify-content: center; }
+.live-still { border-radius: 0; height: 100%; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; }
+.live-rec-link { background: var(--paper); color: var(--ink); text-decoration: none; }
.live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; }
/* the top padding is headroom for a hat on the first line's face: a scroller clips what rises out of it */
.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; }
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
index 61e3c02..fcc0bbf 100644
--- a/tools/go-live-test.js
+++ b/tools/go-live-test.js
@@ -46,6 +46,8 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome
args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
const relayStub = (fx) => {
+ // what the relays held before a reload (a test can carry events across one)
+ try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {}
window.__published = [];
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
@@ -168,6 +170,34 @@ try {
await sleep(1500);
const rec = await (await fetch(tag(fin, 'recording'))).text();
check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[a stream the app lost]');
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' ')));
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000);
+ await page.type('.go-live-title', 'Lost stream');
+ const n0 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0);
+ const lost = (await lives(page)).at(-1);
+ // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it
+ await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost);
+ await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' });
+ await sleep(64000);
+ check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000));
+ const n1 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-end-stale').click());
+ check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1));
+ const endedEv = (await lives(page)).at(-1);
+ check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags));
+ check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000));
check('no page errors', !errs.length, errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
await browser.close(); server.stop(true); names.stop(true);
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index d8f2283..608ce76 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -50,7 +50,12 @@ const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '',
['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'],
] }, SERVICE.sk);
-const FIXTURES = [live, stale, past, chat, zap];
+// one kept on a video site: its "recording" is a web page
+const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [
+ ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
+ ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+const FIXTURES = [live, stale, past, pastPage, chat, zap];
const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
// the site as built (the split build: hls.js arrives as its own chunk)
@@ -192,6 +197,11 @@ try {
check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
check('the host\'s profile lists their past streams', await waitFor(() => [...document.querySelectorAll('.live-past-row .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click());
+ check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000)
+ && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video')));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page'), 5000);
check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click());