aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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());