aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js4
-rw-r--r--bun.lock3
-rw-r--r--package.json1
-rw-r--r--src/app.js9
-rw-r--r--src/features/ark.js5
-rw-r--r--src/features/messages.js347
-rw-r--r--src/hls-player.js17
-rw-r--r--src/i18n.js18
-rw-r--r--src/live.js82
-rw-r--r--src/lnurl.js4
-rw-r--r--src/style.css34
-rw-r--r--tools/live-stream-test.js192
12 files changed, 694 insertions, 22 deletions
diff --git a/build.js b/build.js
index f97907f..163e9dc 100644
--- a/build.js
+++ b/build.js
@@ -350,7 +350,9 @@ async function bundleApp({ minify, features, staging, splitting = false }) {
// (Mediabunny, ~700 KB) would ride along in a file meant to be small
// and offline — there it uploads the original, as before
...(splitting ? [] : [{ name: 'no-video-compress', setup(build) {
- build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; } export async function streamVideo() { return null; } export const STREAM_MIN_S = 90;' }));
+ // likewise hls.js (~400 KB): Firefox's player for live streams
+ build.onLoad({ filter: /src\/hls-player\.js$/ }, () => ({ loader: 'js', contents: 'export async function attachHls() { return null; }' }));
} }])],
});
if (!result.success) {
diff --git a/bun.lock b/bun.lock
index 10e82e2..b3e6b15 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
@@ -68,6 +69,8 @@
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+ "hls.js": ["[email protected]", "", {}, "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="],
+
"is-fullwidth-code-point": ["[email protected]", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
diff --git a/package.json b/package.json
index f728400..d54fead 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
diff --git a/src/app.js b/src/app.js
index db1aad4..4f6a8d9 100644
--- a/src/app.js
+++ b/src/app.js
@@ -909,7 +909,7 @@ wallet.subscribe(scheduleRender);
// lightbox: a full-screen photo is a place too — the phone's Back must close
// it, not pop the screen under it (which once walked a viewer straight back
// to the start page, so the photo's × then looked like a logout).
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -917,6 +917,7 @@ function navSnapshot() {
// results streaming in) snapshot only what IDENTIFIES the screen — a
// keystroke must never mint a history entry.
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
+ if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
@@ -939,7 +940,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push
// copy it, send it, reload it — the same paths the deep links already open.
// Every other screen keeps whatever path it had (the till at /pos, the
// plain app at /); only a path we set ourselves is taken back to /.
-const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i;
+const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i;
function navUrl(snap) {
try {
const fp = featureHook('navPath', snap); // a feed with a public address
@@ -1920,7 +1921,7 @@ function identities() {
function clearFeatureNav() {
ui.arkCoinsPage = null; ui.arkExitPage = null;
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
- ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null;
+ ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.zapSetup = null;
}
// Sign out of the CURRENT identity only. "Log out" locks every account on
@@ -3220,7 +3221,7 @@ function goHome() {
ui.profilePk = null;
ui.profEdit = null;
ui.userSearch = null;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null;
ui.zapSetup = null;
ui.arkExitPage = null;
ui.settingsPage = null;
diff --git a/src/features/ark.js b/src/features/ark.js
index cbfd1b9..1619ce4 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -2,6 +2,7 @@
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import { zapAddrOf } from '../live.js';
import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
@@ -1914,7 +1915,7 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1957,7 +1958,7 @@ export function arkFeature(ctx) {
wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
}).catch(() => {});
const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
diff --git a/src/features/messages.js b/src/features/messages.js
index 079c22c..2e3736c 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -52,6 +52,7 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.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 { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -2276,6 +2277,22 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // /naddr1… of a live stream (kind 30311) opens the stream, public like a
+ // note: a visitor watches over the front door. A reload the history
+ // restores by itself needs nothing.
+ const urlLive = (() => {
+ if (typeof location === 'undefined' || urlInvite) return null;
+ const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i);
+ let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {}
+ return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null;
+ })();
+ if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) {
+ try { history.replaceState(null, '', '/'); } catch {}
+ ui.pubProf = true;
+ ui.pubNotePending = 'live';
+ setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0);
+ }
+
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
@@ -4017,7 +4034,7 @@ export function messagesFeature(ctx) {
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
- const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
@@ -4048,6 +4065,7 @@ export function messagesFeature(ctx) {
}
function openProfile(pk) {
rememberPlace('p:' + pk);
+ ui.liveStream = null;
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -5770,7 +5788,7 @@ export function messagesFeature(ctx) {
const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null;
const id = own ? own.id : 'list:' + packKey(p);
if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -5817,7 +5835,7 @@ export function messagesFeature(ctx) {
onClick: () => {
ui.listPick = null;
// the editor lives on the feed screen; the profile gives way to it
- ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null;
+ ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
@@ -6136,7 +6154,7 @@ export function messagesFeature(ctx) {
// Every kind of feed has a URL, and a visitor with no wallet can read it.
const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv;
const showFeed = (id) => {
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it
switchFeed(id);
@@ -6337,7 +6355,7 @@ export function messagesFeature(ctx) {
const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag);
const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : '');
if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -8370,6 +8388,7 @@ export function messagesFeature(ctx) {
if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c });
}
const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND;
+ if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev);
if (!c.ev && isList) {
// a list needs nothing fetched to open: the feed resolves its members
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } },
@@ -8439,6 +8458,295 @@ export function messagesFeature(ctx) {
return out;
}
+ // ---- live streams (NIP-53) -----------------------------------------------
+ // What's on now, newest copy of each stream by its address: asked of the
+ // relays streams are announced on every couple of minutes while a feed or
+ // profile wants it. A feed shows the ones its people are in (Following:
+ // your follows; Everything and Popular: all of them) in a row above its
+ // posts; a profile says when its person is on; a post linking a stream
+ // shows it as a card. Any of them opens the stream's own screen.
+ const liveSeen = new Map(); // addr -> newest 30311
+ let liveAt = 0, liveAsking = null;
+ const liveAskedOf = new Map(); // pk -> when their own streams were asked for
+ function takeLive(evs) {
+ let changed = false;
+ for (const [a, ev] of newestByAddr(evs)) {
+ const cur = liveSeen.get(a);
+ if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
+ }
+ return changed;
+ }
+ const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
+ function refreshLive() {
+ if (liveAsking || Date.now() - liveAt < 120_000) return;
+ liveAt = Date.now();
+ liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000)
+ .then((evs) => { if (takeLive(evs)) scheduleRepaint(); })
+ .catch(() => {}).finally(() => { liveAsking = null; });
+ }
+ // the streams someone is in, asked of their own relays as well (a
+ // streamer's on a relay we don't otherwise read)
+ function askLiveOf(pk) {
+ if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return;
+ liveAskedOf.set(pk, Date.now());
+ Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => {
+ const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])];
+ return Promise.all([
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: liveSince(), limit: 20 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: liveSince(), limit: 20 }, 4000),
+ ]);
+ }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
+ }
+ const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || '')));
+ const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey);
+ function liveNowOf(pk) {
+ const now = Date.now() / 1000;
+ return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+ }
+ function liveForFeed(def) {
+ const now = Date.now() / 1000;
+ const fol = followsNow().set;
+ const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]);
+ if (people && !people.size) return [];
+ return [...liveSeen.values()]
+ .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey)))
+ .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0)));
+ }
+ const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; };
+ function liveBadge(ev) {
+ const s = liveStatus(ev);
+ return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
+ }
+ // the row of streams above a feed's posts
+ function liveStrip(def) {
+ if (mediaMode === 'articles') return null;
+ refreshLive();
+ const evs = liveForFeed(def).slice(0, 12);
+ if (!evs.length) return null;
+ return h('div', { class: 'live-strip', 'data-key': 'live-strip' }, ...evs.map((ev) => {
+ const host = liveHost(ev), n = liveViewers(ev);
+ return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) },
+ liveBadge(ev), n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null),
+ h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host))));
+ }));
+ }
+ // a profile's "on now" line
+ function liveBanner(pk) {
+ askLiveOf(pk);
+ const ev = liveNowOf(pk);
+ if (!ev) return 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')),
+ h('span', { class: 'small muted' }, t('liveWatch')));
+ }
+ // a post linking a stream (nostr:naddr of a 30311)
+ function liveCard(ev) {
+ const host = liveHost(ev);
+ return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)),
+ h('div', { class: 'col', style: 'gap:4px;min-width:0' },
+ h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host)))));
+ }
+
+ function openLive(ev) {
+ rememberPlace('live');
+ const a = liveAddr(ev);
+ takeLive([ev]);
+ setZapAddr(ev.id, a);
+ ui.liveStream = { a, seed: ev };
+ ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null;
+ render();
+ }
+ // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show)
+ async function openLiveRef(ref) {
+ const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null);
+ if (ev) openLive(ev);
+ else toast(t('liveNotFound'));
+ }
+
+ // The open stream: its chat (1311) and zaps (9735, and coinos's Ark
+ // receipts) as they come, and the stream event itself as it's refreshed
+ // (the viewer count, `ended`).
+ let liveWatch = null;
+ function watchLive(s) {
+ if (liveWatch && liveWatch.a === s.a) return liveWatch;
+ stopLiveWatch();
+ const [, pk, ...rest] = s.a.split(':');
+ const d = rest.join(':');
+ takeLive([s.seed]);
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, draft: '' };
+ let mine = []; try { mine = wallet.nostrRelays(); } catch {}
+ const relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const add = (e) => {
+ if (liveWatch !== w || w.lines.has(e.id)) return;
+ w.lines.set(e.id, e);
+ scheduleRepaint();
+ };
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ return w;
+ }
+ function stopLivePlayer(w = liveWatch) {
+ if (!w) return;
+ if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ const v = w.video;
+ w.video = null;
+ if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ function stopLiveWatch() {
+ const w = liveWatch;
+ if (!w) return;
+ liveWatch = null;
+ for (const stop of w.stops) { try { stop(); } catch {} }
+ 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) : '');
+ // 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).
+ function mountLivePlayer() {
+ const w = liveWatch;
+ const el = document.querySelector('video.live-video');
+ if (!w || !el || el === w.video) return;
+ stopLivePlayer(w);
+ const ev = liveSeen.get(w.a);
+ const src = ev && livePlayable(ev);
+ if (!src) return;
+ w.video = el;
+ el._skipMorph = true;
+ w.err = null;
+ // a stream that drops (the streamer's link, a restart) is tried again
+ const fail = () => {
+ if (w.video !== el) return;
+ stopLivePlayer(w);
+ w.err = 'offline';
+ scheduleRepaint();
+ setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000);
+ };
+ el.addEventListener('error', fail);
+ 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) => {
+ if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
+ if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
+ w.hls = hls; go();
+ }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); });
+ }
+ // after every render: a stream screen gone takes its player and watch with
+ // it (a <video> taken out of the page plays on, unheard and unseen)
+ let liveShown = false;
+ function liveAfterRender() {
+ if (!ui.liveStream) { stopLiveWatch(); return; }
+ if (!liveShown) { stopLivePlayer(); return; }
+ mountLivePlayer();
+ const box = document.querySelector('.live-chat');
+ if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
+ }
+
+ async function sendLiveChat(w, text) {
+ const id = await requireIdentity();
+ const ev = liveSeen.get(w.a);
+ const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
+ const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [['a', w.a, relays[0] || '', 'root'], ...outboundEmojiTags(text), CLIENT_TAG] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ w.lines.set(evt.id, evt);
+ render();
+ if (!(await publishOn(relays, evt))) {
+ w.lines.delete(evt.id);
+ w.draft = text;
+ toast(t('msgSendFailed'));
+ render();
+ }
+ }
+
+ function liveLine(ev) {
+ if (ev.kind === LIVE_CHAT_KIND) {
+ if (hiddenPk(ev.pubkey)) return null;
+ return keyed(h('div', { class: 'live-line' },
+ avatar(ev.pubkey, 'chat-avatar mini'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, displayName(ev.pubkey)), ' ',
+ ...noteBody(ev.content.slice(0, 500), 1, noteEmojiTags(ev)))), ev.id);
+ }
+ const z = zapLine(ev, decodeBolt11);
+ if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
+ return keyed(h('div', { class: 'live-line live-zap' },
+ z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ',
+ h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })),
+ z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id);
+ }
+
+ function liveScreen() {
+ const s = ui.liveStream;
+ const w = watchLive(s);
+ liveShown = true;
+ const ev = liveSeen.get(s.a) || s.seed;
+ const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
+ const src = livePlayable(ev);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(liveLine).filter(Boolean);
+ const say = w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
+ const send = () => {
+ const text = (w.draft || '').trim();
+ if (!text) return;
+ w.draft = '';
+ const inp = document.querySelector('.live-input');
+ if (inp) inp.value = '';
+ sendLiveChat(w, text).catch(() => { w.draft = text; render(); });
+ };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { ui.liveStream = null; }),
+ h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ liveBadge(ev)),
+ h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a },
+ // the frame is keyed by what plays in it: the <video> itself is
+ // never morphed, so a stream that ends or drops swaps the frame
+ 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: '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)),
+ n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ h('span', { style: 'margin-left:auto' }),
+ canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), onClick: async () => {
+ const url = location.origin + '/' + packNaddr(s.a);
+ try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
+ } }, '↗')),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null),
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('liveChat')),
+ h('div', {
+ class: 'live-chat', 'data-key': 'live-chat:' + s.a,
+ onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
+ }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
+ isVisitor() ? null : h('div', { class: 'row gap6' },
+ h('input', {
+ type: 'text', class: 'live-input grow', placeholder: t('liveChatHint'), value: w.draft, maxlength: '500',
+ onInput: (e) => { w.draft = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } },
+ }),
+ h('button', { class: 'btn-sm', onClick: send }, t('liveSend')))),
+ ...noteOverlays());
+ }
+
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
@@ -8880,7 +9188,7 @@ export function messagesFeature(ctx) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref));
else if (ref && ref.type === 'addr') out.push(h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
+ href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
}, t('noteRefLink')));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:/i.test(part)) {
@@ -8904,7 +9212,7 @@ export function messagesFeature(ctx) {
// The header magnifier's search, opened with a name already typed.
function openPeopleSearch(q) {
- ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null;
+ ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null;
ui.userSearch = { q, rows: null };
warmSearch();
userSearcher.update(q);
@@ -9225,7 +9533,7 @@ export function messagesFeature(ctx) {
const next = (cur ? cur + '\n\n' : '') + ref;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'feed';
@@ -10601,6 +10909,7 @@ export function messagesFeature(ctx) {
}
function openNoteThread(ev) {
rememberPlace('thread');
+ ui.liveStream = null;
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -10942,7 +11251,7 @@ export function messagesFeature(ctx) {
// (or profile) and its scroll place. A thread opened straight from a
// link has nothing of ours under it, so no button that invents one.
!ctx.canGoBack || ctx.canGoBack()
- ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back'))
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; }) }, t('back'))
: null,
...noteOverlays());
}
@@ -11221,6 +11530,7 @@ export function messagesFeature(ctx) {
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
+ ui.profEdit ? null : liveBanner(pk),
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
@@ -11347,7 +11657,7 @@ export function messagesFeature(ctx) {
// screen router shows an open thread before the chat, so
// a reply half-typed under their post used to win here
ui.profilePk = null; ui.profOverThread = false;
- ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null;
+ ui.noteThread = null; ui.liveStream = null; ui.userSearch = null; ui.feedEdit = null;
ui.chatOpen = true;
ui.msgView = 'dm';
ui.msgPeer = peer;
@@ -11361,7 +11671,7 @@ export function messagesFeature(ctx) {
// and the Send tab would never appear
ui.profilePk = null;
ui.profOverThread = false;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null;
ui.userSearch = null;
ui.chatOpen = false;
ctx.showSend({ fresh: true });
@@ -11700,7 +12010,7 @@ export function messagesFeature(ctx) {
function toFrontDoor() {
rememberReturn();
ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null;
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.feedRelayEdit = null;
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -13943,6 +14253,7 @@ export function messagesFeature(ctx) {
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
mediaModes(),
+ liveStrip(def),
// floating bottom right, wherever the reader is in the feed: the
// composer opens at the top and takes the focus
visitor || ui.profCompose != null ? null : h('button', {
@@ -16107,12 +16418,14 @@ export function messagesFeature(ctx) {
bottomNav: appBottomNav,
notifySettingsCards() { return [notifyCard()]; },
screenView() {
+ liveShown = false;
+ queueMicrotask(liveAfterRender);
// A profile deep link mid-resolution holds the frame over EVERY screen
// — an auto-restored wallet would otherwise flash its home page for the
// beat the registrar lookup takes. The shell shows the name straight
// off the URL; no spinner, the empty beat reads calmer.
// Same for a note deep link while its thread is being found.
- if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' },
+ if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))));
@@ -16128,6 +16441,8 @@ export function messagesFeature(ctx) {
// threads reachable from it, are public nostr content — shown
// without an account. Back falls through to the app's own screens.
if (ui.pubProf) {
+ if (ui.zapSetup && ui.liveStream) return zapSetupScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -16137,6 +16452,7 @@ export function messagesFeature(ctx) {
return null;
}
if (ui.zapSetup) return zapSetupScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -16150,7 +16466,7 @@ export function messagesFeature(ctx) {
userSearchAvailable() { return true; },
// the desktop header's search field: typing there IS the search page's query
userSearchQuery(q) {
- if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
+ if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
ui.userSearch.q = q; userSearcher.update(q); render();
return true;
},
@@ -16259,7 +16575,7 @@ export function messagesFeature(ctx) {
visitorSignIn() { toFrontDoor(); return true; },
visitorSearch() {
rememberReturn();
- ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false;
+ ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.profOverThread = false;
ui.userSearch = { q: '', rows: null };
warmSearch();
render();
@@ -16272,6 +16588,7 @@ export function messagesFeature(ctx) {
return true;
},
navPath(snap) {
+ if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; }
if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null;
const def = feedDef(snap.feedId || curFeedId);
return def && adhocFeeds.has(def.id) ? feedPath(def) : null;
diff --git a/src/hls-player.js b/src/hls-player.js
new file mode 100644
index 0000000..d0878e2
--- /dev/null
+++ b/src/hls-player.js
@@ -0,0 +1,17 @@
+// HLS where the browser has none of its own (Firefox): hls.js feeds the
+// <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) {
+ const { default: Hls } = await import('hls.js');
+ if (!Hls.isSupported()) return null;
+ const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30 });
+ hls.loadSource(src);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.ERROR, (_, d) => {
+ if (!d.fatal) return;
+ if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; }
+ video.dispatchEvent(new Event('hlsfatal'));
+ });
+ return hls;
+}
diff --git a/src/i18n.js b/src/i18n.js
index ef56389..fd70b6c 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -453,6 +453,24 @@ const STR = {
feedEmpty: 'Nothing posted yet.',
feedLoadingMore: 'Loading more posts…',
feedEnd: 'You’re all caught up.',
+ liveBadge: 'LIVE',
+ livePlanned: 'SOON',
+ liveEnded: 'ENDED',
+ liveUntitled: 'Live stream',
+ liveWatching: '{n} watching',
+ liveWatch: 'Watch',
+ liveZap: 'Zap',
+ liveChat: 'Chat',
+ liveChatEmpty: 'No messages yet.',
+ liveChatHint: 'Say something',
+ liveSend: 'Send',
+ liveZapped: 'zapped {n} sats',
+ liveSomeone: 'Someone',
+ liveOffline: 'The stream isn’t playing right now.',
+ liveNoPlayer: 'This browser can’t play the stream.',
+ liveOver: 'This stream has ended.',
+ liveStartsAt: 'Starts {when}',
+ liveNotFound: 'Couldn’t find that stream.',
feedAttach: 'Attach a picture or video',
postLike: 'Like',
postBoost: 'Boost',
diff --git a/src/live.js b/src/live.js
new file mode 100644
index 0000000..da18f19
--- /dev/null
+++ b/src/live.js
@@ -0,0 +1,82 @@
+// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover,
+// where to watch (`streaming`, almost always an HLS playlist), whether it's
+// on (`status`), how many watch, and who's in it (p tags with a role). Its
+// chat is kind 1311, addressed to the stream with an `a` tag; zaps carry
+// that `a` too, which is how zap.stream and the rest count them.
+export const LIVE_KIND = 30311;
+export const LIVE_CHAT_KIND = 1311;
+// Where streams are announced: zap.stream, Shosho and the rest publish to
+// the big public relays (a 2026-10-18 survey found every live one on damus,
+// nos.lol and ditto, most on primal; relay.zap.stream held none).
+export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub'];
+// A stream still marked live that hasn't been refreshed in an hour is over:
+// plenty of publishers never say `ended` (NIP-53 allows reading it so).
+export const LIVE_STALE_S = 3600;
+
+// https, or a stream on this machine (a test's, a streamer's own preview)
+const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/;
+const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || '';
+export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd');
+export const liveTitle = (ev) => tag(ev, 'title').trim();
+export const liveSummary = (ev) => tag(ev, 'summary').trim();
+export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; };
+export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; };
+export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at;
+
+// The person streaming: the host named in its p tags (zap.stream signs the
+// event with its own key and names the streamer as Host), else its author.
+export function liveHost(ev) {
+ const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || ''));
+ return p ? p[1] : ev.pubkey;
+}
+// Where to watch: an HLS playlist first (what browsers can play), else any https address.
+export function liveStream(ev) {
+ const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]);
+ return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || '';
+}
+export function liveRecording(ev) {
+ const u = tag(ev, 'recording');
+ return WATCHABLE.test(u) ? u : '';
+}
+export function liveStatus(ev, now = Date.now() / 1000) {
+ const s = tag(ev, 'status').toLowerCase();
+ if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live';
+ return s === 'planned' ? 'planned' : 'ended';
+}
+export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev);
+// The relays the stream names for its chat, then the ones it's announced on.
+export function liveRelays(ev) {
+ const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r));
+ return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])];
+}
+// Newest copy of each stream (it's replaceable: the address is the stream).
+export function newestByAddr(evs) {
+ const out = new Map();
+ for (const ev of evs) {
+ if (!ev || ev.kind !== LIVE_KIND) continue;
+ const a = liveAddr(ev), cur = out.get(a);
+ if (!cur || cur.created_at < ev.created_at) out.set(a, ev);
+ }
+ return out;
+}
+
+// The zap on a chat line: a 9735 receipt's amount from its bolt11, its
+// sender and words from the zap request inside it; coinos's own Ark receipt
+// (9737) says them in tags. `decode` is the bolt11 decoder (millisats).
+export function zapLine(ev, decode) {
+ if (ev.kind === 9737) {
+ return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' };
+ }
+ let req = null;
+ try { req = JSON.parse(tag(ev, 'description')); } catch {}
+ let sats = 0;
+ try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {}
+ if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000);
+ return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' };
+}
+
+// Zaps aimed at a stream carry its address: the zap request (kind 9734) and
+// coinos's Ark receipt look the event they're zapping up here.
+const zapAddrs = new Map();
+export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); };
+export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;
diff --git a/src/lnurl.js b/src/lnurl.js
index bfebf0d..7b1a798 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -10,6 +10,7 @@
// (bolt11) → hand that to the swap engine. The recipient's server is the one
// that broadcasts the receipt; we only sign the request.
+import { zapAddrOf } from './live.js';
import { bech32 } from '@scure/base';
import { secp256k1 } from '@noble/curves/secp256k1';
import { hmac } from '@noble/hashes/hmac';
@@ -126,6 +127,9 @@ export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk,
const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]];
if (lnurlBech32) tags.push(['lnurl', lnurlBech32]);
if (eventId) tags.push(['e', eventId]);
+ // a live stream's address too, so its chat and zap.stream count the zap
+ const addr = zapAddrOf(eventId);
+ if (addr) tags.push(['a', addr]);
return signFn({ kind: 9734, content: comment || '', tags });
}
diff --git a/src/style.css b/src/style.css
index 9547ab0..fbd1858 100644
--- a/src/style.css
+++ b/src/style.css
@@ -886,6 +886,40 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); }
.addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; }
.addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; }
+/* Live streams (NIP-53): the row of what's on above a feed, a profile's
+ "on now", a post's card, and the stream's own page (player over chat). */
+.live-strip { display: flex; gap: 10px; overflow-x: auto; padding: 2px 0 4px; scrollbar-width: none; }
+.live-strip::-webkit-scrollbar { display: none; }
+.live-tile { flex: 0 0 168px; display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left;
+ background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; }
+.live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden;
+ background: var(--line) center / cover no-repeat; }
+.live-badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 700;
+ letter-spacing: .04em; color: #fff; background: #555; line-height: 1.5; flex-shrink: 0; }
+.live-badge.live { background: #e0245e; }
+.live-cover .live-badge { position: absolute; top: 6px; left: 6px; }
+.live-viewers { position: absolute; bottom: 6px; left: 6px; padding: 1px 6px; border-radius: 4px; font-size: 11px;
+ color: #fff; background: rgba(0, 0, 0, .6); }
+.live-tile-title { font-weight: 600; font-size: 14px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical; overflow: hidden; }
+.live-tile-host { align-items: center; min-width: 0; }
+.live-tile-host .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.live-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0245e;
+ border-radius: 10px; background: none; color: inherit; cursor: pointer; text-align: left; min-width: 0; }
+.live-banner-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
+.live-card { flex-direction: row; align-items: center; gap: 10px; }
+.live-card .live-cover { flex: 0 0 120px; width: 120px; }
+.live-title { margin: 0; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.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-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; }
+.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; }
+.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
+.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
+.live-zap .live-line-body b { color: var(--accent); }
+.live-input { min-width: 0; }
.article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; }
.article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; }
.article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; }
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
new file mode 100644
index 0000000..7bc0228
--- /dev/null
+++ b/tools/live-stream-test.js
@@ -0,0 +1,192 @@
+// Live streams (NIP-53): a feed shows its people's streams in a row above
+// the posts (a stream gone quiet for an hour is over), a tap opens the
+// stream's page — the player playing the HLS stream, the chat and zaps as
+// they come, a chat line sent as a kind 1311 — and the address bar names it
+// (/naddr1…). A post linking the stream shows it as a card, the host's
+// profile says they're on, Back stops the player, the browser without HLS
+// of its own plays it through hls.js, and a zap carries the stream's `a`.
+// Run: bun tools/live-stream-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { buildZapRequest } from '../src/lnurl.js';
+import { setZapAddr } from '../src/live.js';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5302;
+const now = Math.floor(Date.now() / 1000);
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();
+
+// a stream: 30 s of HLS, its playlist without an end (as a live one is)
+const dir = mkdtempSync(tmpdir() + '/live-stream-');
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
+writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
+const STREAM = `http://localhost:${PORT}/live/index.m3u8`;
+
+// zap.stream's shape: the service signs, the streamer is the Host
+const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
+ ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
+ ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
+ ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
+] }, SERVICE.sk);
+const A = `30311:${SERVICE.pk}:stream-1`;
+const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
+// still says live, but nobody has refreshed it in two hours
+const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
+ ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
+] }, STALE.sk);
+const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
+const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
+const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
+const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
+const FIXTURES = [live, stale, 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)
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: PORT, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+
+// the relays: they hold the fixtures, answer by the filter, and keep what's published
+const relayStub = (fx) => {
+ window.__published = []; window.__reqs = [];
+ const subs = [];
+ // a new event, to every open subscription it matches (a relay's live feed)
+ window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ window.__reqs.push([a[0], ...m.slice(2)]);
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]);
+ const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+
+const newPage = async (noHls = false) => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, FIXTURES);
+ // Firefox: no HLS of its own
+ if (noHls) await page.evaluateOnNewDocument(() => {
+ const real = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ page.errs = errs;
+ return page;
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };
+
+try {
+ console.log('[a zap on a stream names it]');
+ setZapAddr(live.id, A);
+ const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
+ check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));
+
+ const page = await newPage();
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive');
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Stream Host', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, HOST.pk, [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('\n[the feed]');
+ check('a followed host\'s stream shows above the posts', await waitFor(() => !!document.querySelector('.live-strip .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-strip .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
+ check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
+ check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
+
+ console.log('\n[the stream\'s page]');
+ await page.evaluate(() => document.querySelector('.live-strip .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));
+ 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));
+ await page.focus('.live-input');
+ await page.keyboard.type('first time here');
+ await page.keyboard.press('Enter');
+ check('a line typed in the chat shows at once', await waitText('first time here', 3000));
+ const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
+ check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('the player keeps playing through the renders', await waitFor(playing, 3000));
+
+ console.log('\n[leaving]');
+ await page.goBack();
+ check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
+ 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 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)));
+ check('no page errors', !page.errs.length, page.errs.join(' | '));
+ await page.close();
+
+ console.log('\n[a browser without HLS of its own, opening a link]');
+ const ff = await newPage(true);
+ const f = helpers(ff);
+ await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
+ check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
+ check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
+ check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
+ await ff.evaluate((e) => window.__push(e), ended);
+ check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
+ check('no page errors', !ff.errs.length, ff.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed');
+process.exit(ok ? 0 : 1);