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)