UNPKG

nostr-web-components

Version:

collection of web components that provide quick access to basic nostr things

267 lines (261 loc) 8.86 kB
import { decodeNostrURI } from "@nostr/tools/nip19"; import { normalizeURL } from "@nostr/tools/utils"; import { pool, getOutboxRelaysFor } from "./nostr.js"; import { debounce } from "./utils.js"; class NostrLivestream extends HTMLElement { static observedAttributes = ["ref", "autoplay", "muted"]; root; subc; 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")) : null; if (template) { this.root.appendChild(template.content.cloneNode(true)); } else { this.root.innerHTML = ` <style> header { display: flex; align-items: center; } header img { height: 2em; } video { width: 100%; } </style> <slot name="header"> <header part="header"> <img part="image" /> <h3 part="title"></h3> <p part="status"></p> </header > </slot> <p part="time"></p> <p part="summary"></p> <section part="stats"> <slot name="stats"> <span part="current" style="display: none;"> current participants: <span part="current-value" /> </span> <span part="total" style="display: none;"> total participants: <span part="total-value" /> </span> </slot> </section> <video part="video" controls style="display: none;"> <source type="application/x-mpegURL"> your browser does not support HLS video playback </video> <section part="participants" style="display: none;"> <slot name="participants"> <h4 part="participants-title"> <slot name="participants-title"></slot> </h4> <ul part="participants-list"> <template> <li part="participant-entry"> <nostr-name part="participant-pubkey" /> <span part="participant-role"></span> </li> </template> </ul> </slot> </section> `; } this.set(); }, 1); } *queryPart(name) { let slotted = this.querySelectorAll(`[part="${name}"]`); for (let i = 0; i < slotted.length; i++) { yield slotted[i]; } let templated = this.root.querySelectorAll(`[part="${name}"]`); for (let i = 0; i < templated.length; i++) { yield templated[i]; } } disconnectedCallback() { this.subc?.close?.(); } attributeChangedCallback() { this.set(); } set = debounce(async () => { if (this.subc) { this.subc.close(); } let input = this.getAttribute("ref"); if (!input) return; let { type, data } = decodeNostrURI(input); let relays = []; let author; let filter; if (type === "nevent") { let d = data; relays = d.relays || []; author = d.author; filter = { ids: [d.id] }; } else if (type === "naddr") { let d = data; 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 = '<p part="error"><slot name="error-pointer">not a valid livestream event pointer</slot></p>'; return; } relays = relays.map(normalizeURL); if (author) { let authorRelays = await getOutboxRelaysFor(author); relays.push(...authorRelays); } let found = false; let loadedHLS = false; let hls; this.subc = pool.subscribeMany(relays, filter, { label: "nostr-livestream", onevent: async (evt) => { found = true; if (evt.kind !== 30311) { this.root.innerHTML = '<p part="error"><slot name="error-invalid">event is not a livestream</slot></p>'; return; } const title = evt.tags.find((t) => t[0] === "title")?.[1]; if (title) { for (let node of this.queryPart("title")) { node.textContent = title; } } const summary = evt.tags.find((t) => t[0] === "summary")?.[1]; if (summary) { for (let node of this.queryPart("summary")) { node.textContent = summary; } } const image = evt.tags.find((t) => t[0] === "image")?.[1]; if (image) { for (let node of this.queryPart("image")) { ; node.src = image; } } const status = evt.tags.find((t) => t[0] === "status")?.[1] || "unknown"; if (status) { for (let node of this.queryPart("status")) { node.textContent = status; } } const current = evt.tags.find((t) => 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) => t[0] === "total_participants")?.[1]; if (total) { for (let node of this.queryPart("total")) { ; node.style.display = "block"; } for (let dataEl of this.queryPart("total-value")) { dataEl.textContent = total; } } const starts = evt.tags.find((t) => t[0] === "starts")?.[1]; const ends = evt.tags.find((t) => t[0] === "ends")?.[1]; let timeStr = ""; if (starts) { const startDate = new Date(parseInt(starts) * 1e3); timeStr = startDate.toLocaleString(); if (ends) { const endDate = new Date(parseInt(ends) * 1e3); timeStr += ` to ${endDate.toLocaleString()}`; } for (let node of this.queryPart("time")) { node.textContent = timeStr; } } let participantTags = evt.tags.filter((t) => t[0] === "p"); if (participantTags.length) { for (let container of this.queryPart("participants")) { let templateEl = container.querySelector("template"); if (templateEl) { let template = templateEl.content; participantTags.forEach((t) => { let entry = template.cloneNode(); 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) => 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.Hls.isSupported()) { hls = new window.Hls(); hls.loadSource(streamUrl); hls.attachMedia(videoEl); } }; document.head.appendChild(script); } else if (hls) { hls.loadSource(streamUrl); } } } }, oneose: () => { if (!found) { this.root.innerHTML = '<p part="error"><slot name="error-fetch">failed to fetch event</slot></p>'; this.subc?.close?.(); } } }); }, 200); } window.customElements.define("nostr-livestream", NostrLivestream); export { NostrLivestream as default };