nostr-web-components
Version:
collection of web components that provide quick access to basic nostr things
267 lines (261 loc) • 8.86 kB
JavaScript
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
};