aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 07:53:03 -0700
committerAdam Soltys <[email protected]>2026-10-18 07:53:03 -0700
commit8b64ecab1a289b055bad3cb4b4427dc5a8a0ff96 (patch)
treede3047d6abd0ec15c865e103c4fc1f30f60659ef /src
parent972f668f669976317ac4b92fadb49c7a085aac30 (diff)
downloadcoinosv3-8b64ecab1a289b055bad3cb4b4427dc5a8a0ff96.tar.gz
coinosv3-8b64ecab1a289b055bad3cb4b4427dc5a8a0ff96.zip
Live streams (NIP-53), to watch: a row of what's on above a feed (Following: your follows' streams; Everything and Popular: all), a stream's own page at /naddr1… (the HLS stream playing — hls.js where the browser has no HLS — its chat and zaps as they come, a chat line sent as kind 1311, ⚡ to the host), a card for a post linking a stream, and a LIVE line on a streamer's profile. A stream quiet for an hour is over; one that drops is retried; zaps on a stream carry its a tag. Also: the single-file build's video stub gains streamVideo (its uploads threw since the HLS uploads).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src')
-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
8 files changed, 495 insertions, 21 deletions
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; }