diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 07:53:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 07:53:03 -0700 |
| commit | 8b64ecab1a289b055bad3cb4b4427dc5a8a0ff96 (patch) | |
| tree | de3047d6abd0ec15c865e103c4fc1f30f60659ef /src/live.js | |
| parent | 972f668f669976317ac4b92fadb49c7a085aac30 (diff) | |
| download | coinosv3-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/live.js')
| -rw-r--r-- | src/live.js | 82 |
1 files changed, 82 insertions, 0 deletions
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; |
