diff options
| -rw-r--r-- | src/features/messages.js | 75 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/style.css | 3 | ||||
| -rw-r--r-- | tools/go-live-test.js | 30 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 12 |
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()); |
