import { decodeNostrURI } from '@nostr/tools/nip19' import { normalizeURL } from '@nostr/tools/utils' import { SubCloser } from '@nostr/tools/abstract-pool' import { pool, getOutboxRelaysFor } from './nostr.js' import { debounce } from './utils.js' export default class NostrLivestream extends HTMLElement { static observedAttributes = ['ref', 'autoplay', 'muted'] root: HTMLDivElement subc: SubCloser | undefined constructor() { super() this.attachShadow({ mode: 'open' }) const { shadowRoot } = this this.root = document.createElement('div') shadowRoot!.appendChild(this.root) } connectedCallback() { setTimeout(() => { let template = this.getAttribute('template') ? (document.getElementById(this.getAttribute('template')!) as HTMLTemplateElement) : null if (template) { this.root.appendChild(template.content.cloneNode(true)) } else { this.root.innerHTML = `

current participants: total participants:

` } this.set() }, 1) } *queryPart(name: string): Generator { let slotted = this.querySelectorAll(`[part="${name}"]`) for (let i = 0; i < slotted.length; i++) { yield slotted[i] as HTMLElement } let templated = this.root.querySelectorAll(`[part="${name}"]`) for (let i = 0; i < templated.length; i++) { yield templated[i] as HTMLElement } } disconnectedCallback() { this.subc?.close?.() } attributeChangedCallback() { this.set() } set: () => void = debounce(async () => { if (this.subc) { this.subc.close() } let input = this.getAttribute('ref') if (!input) return let { type, data } = decodeNostrURI(input) let relays: string[] = [] let author: string | undefined let filter: any if (type === 'nevent') { let d = data as any relays = d.relays || [] author = d.author filter = { ids: [d.id] } } else if (type === 'naddr') { let d = data as any relays = d.relays || [] author = d.pubkey filter = { authors: [d.pubkey], kinds: [d.kind], '#d': [d.identifier], } } else if (input.match(/[0-9a-f]{64}/)) { filter = { ids: [input] } } else { this.root.innerHTML = '

not a valid livestream event pointer

' return } relays = relays.map(normalizeURL) if (author) { let authorRelays = await getOutboxRelaysFor(author) relays.push(...authorRelays) } let found = false let loadedHLS = false let hls: any this.subc = pool.subscribeMany(relays, filter, { label: 'nostr-livestream', onevent: async evt => { found = true if (evt.kind !== 30311) { this.root.innerHTML = '

event is not a livestream

' return } // get metadata from tags and replace in the template const title = evt.tags.find((t: string[]) => t[0] === 'title')?.[1] if (title) { for (let node of this.queryPart('title')) { node.textContent = title } } const summary = evt.tags.find((t: string[]) => t[0] === 'summary')?.[1] if (summary) { for (let node of this.queryPart('summary')) { node.textContent = summary } } const image = evt.tags.find((t: string[]) => t[0] === 'image')?.[1] if (image) { for (let node of this.queryPart('image')) { ;(node as HTMLImageElement).src = image } } const status = evt.tags.find((t: string[]) => t[0] === 'status')?.[1] || 'unknown' if (status) { for (let node of this.queryPart('status')) { node.textContent = status } } const current = evt.tags.find((t: string[]) => t[0] === 'current_participants')?.[1] if (current) { for (let node of this.queryPart('current')) { node.style.display = 'block' } for (let dataEl of this.queryPart('current-value')) { dataEl.textContent = current } } const total = evt.tags.find((t: string[]) => t[0] === 'total_participants')?.[1] if (total) { for (let node of this.queryPart('total')) { ;(node as HTMLElement).style.display = 'block' } for (let dataEl of this.queryPart('total-value')) { dataEl.textContent = total } } // get start/end times const starts = evt.tags.find((t: string[]) => t[0] === 'starts')?.[1] const ends = evt.tags.find((t: string[]) => t[0] === 'ends')?.[1] let timeStr = '' if (starts) { const startDate = new Date(parseInt(starts) * 1000) timeStr = startDate.toLocaleString() if (ends) { const endDate = new Date(parseInt(ends) * 1000) timeStr += ` to ${endDate.toLocaleString()}` } for (let node of this.queryPart('time')) { node.textContent = timeStr } } // get participants let participantTags = evt.tags.filter((t: string[]) => t[0] === 'p') if (participantTags.length) { for (let container of this.queryPart('participants')) { let templateEl = container.querySelector('template') if (templateEl) { let template = (templateEl as HTMLTemplateElement).content participantTags.forEach((t: string[]) => { let entry = template.cloneNode() as HTMLElement let pubkeyEl = entry.querySelector('[part="participant-pubkey"]') if (pubkeyEl) { if (pubkeyEl.tagName === 'NOSTR-NAME') { pubkeyEl.setAttribute('pubkey', t[1]) } else { pubkeyEl.textContent = t[1] } } entry.querySelectorAll('[part="participant-role"]').forEach(roleEl => { roleEl.textContent = t[3] || 'participant' }) }) } } } const streamUrl = evt.tags.find((t: string[]) => t[0] === 'streaming')?.[1] if (status === 'live' && streamUrl?.endsWith('.m3u8')) { let videoEl = this.root.querySelector('video')! videoEl.autoplay = this.hasAttribute('autoplay') videoEl.muted = this.hasAttribute('muted') videoEl.style.display = 'block' let sourceEl = videoEl.querySelector('source')! if (sourceEl.src !== streamUrl) { sourceEl.src = streamUrl if (!videoEl.canPlayType('application/vnd.apple.mpegurl') && !loadedHLS) { loadedHLS = true const script = document.createElement('script') script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest' script.onload = () => { if ((window as any).Hls.isSupported()) { hls = new (window as any).Hls() hls.loadSource(streamUrl) hls.attachMedia(videoEl) } } document.head.appendChild(script) } else if (hls) { hls.loadSource(streamUrl) } } } }, oneose: () => { if (!found) { this.root.innerHTML = '

failed to fetch event

' this.subc?.close?.() } }, }) }, 200) } window.customElements.define('nostr-livestream', NostrLivestream)