520 lines
15 KiB
JavaScript
520 lines
15 KiB
JavaScript
// every.channel web watcher
|
|
//
|
|
// This uses the upstream moq watch web component (WebTransport + WebCodecs).
|
|
// It is intentionally dependency-light: no framework, no bundler.
|
|
|
|
const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon";
|
|
const MOQ_WATCH_MODULE_URLS = [
|
|
"https://esm.sh/@moq/watch@0.1.1/element",
|
|
"https://cdn.jsdelivr.net/npm/@moq/watch@0.1.1/element/+esm",
|
|
"https://unpkg.com/@moq/watch@0.1.1/element.js?module",
|
|
];
|
|
const HLS_MODULE_URLS = [
|
|
"https://esm.sh/hls.js@1.6.2",
|
|
"https://cdn.jsdelivr.net/npm/hls.js@1.6.2/+esm",
|
|
"https://unpkg.com/hls.js@1.6.2/dist/hls.mjs",
|
|
];
|
|
const PUBLIC_STREAMS_PATH = "/api/public-streams";
|
|
let moqWatchModulePromise = null;
|
|
let hlsModulePromise = null;
|
|
let disposePlayerSignals = null;
|
|
let activeHlsPlayer = null;
|
|
|
|
function $(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) throw new Error(`missing element: ${id}`);
|
|
return el;
|
|
}
|
|
|
|
function normalizeRelayUrl(s) {
|
|
const trimmed = (s || "").trim();
|
|
if (!trimmed) return DEFAULT_RELAY_URL;
|
|
// Ensure trailing slash so relative fetches behave consistently.
|
|
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
|
|
}
|
|
|
|
function normalizeName(s) {
|
|
return (s || "").trim();
|
|
}
|
|
|
|
function currentShareLink(relayUrl, name, mode) {
|
|
const u = new URL(window.location.href);
|
|
u.pathname = "/watch";
|
|
u.searchParams.set("url", relayUrl);
|
|
u.searchParams.set("name", name);
|
|
if (mode === "archive") u.searchParams.set("mode", "archive");
|
|
else u.searchParams.delete("mode");
|
|
// Avoid leaking other params.
|
|
for (const k of [...u.searchParams.keys()]) {
|
|
if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
|
|
}
|
|
return u.toString();
|
|
}
|
|
|
|
function setHint(text, kind) {
|
|
const el = $("hint");
|
|
el.textContent = text || "";
|
|
el.dataset.kind = kind || "";
|
|
}
|
|
|
|
function setShareLink(text) {
|
|
const el = $("shareLink");
|
|
el.textContent = text || "";
|
|
}
|
|
|
|
function setListHint(text, kind) {
|
|
const el = $("listHint");
|
|
el.textContent = text || "";
|
|
el.dataset.kind = kind || "";
|
|
}
|
|
|
|
function clearPlayerSignals() {
|
|
if (typeof disposePlayerSignals === "function") {
|
|
disposePlayerSignals();
|
|
}
|
|
disposePlayerSignals = null;
|
|
}
|
|
|
|
function destroyArchivePlayer() {
|
|
if (activeHlsPlayer && typeof activeHlsPlayer.destroy === "function") {
|
|
try {
|
|
activeHlsPlayer.destroy();
|
|
} catch (_) {
|
|
// Ignore teardown errors.
|
|
}
|
|
}
|
|
activeHlsPlayer = null;
|
|
}
|
|
|
|
function bindPlayerSignals(watch, name) {
|
|
const cleanup = [];
|
|
let offlineTimer = null;
|
|
|
|
const clearOfflineTimer = () => {
|
|
if (offlineTimer) {
|
|
window.clearTimeout(offlineTimer);
|
|
offlineTimer = null;
|
|
}
|
|
};
|
|
|
|
const maybeWatch = (signal, onValue) => {
|
|
if (!signal || typeof signal.watch !== "function") return;
|
|
const dispose = signal.watch(onValue);
|
|
if (typeof dispose === "function") cleanup.push(dispose);
|
|
};
|
|
|
|
let sawLoading = false;
|
|
maybeWatch(watch?.broadcast?.status, (status) => {
|
|
if (status === "loading") {
|
|
clearOfflineTimer();
|
|
sawLoading = true;
|
|
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
|
|
return;
|
|
}
|
|
if (status === "live") {
|
|
clearOfflineTimer();
|
|
setHint(`Live: subscribed to ${name}`, "ok");
|
|
return;
|
|
}
|
|
if (status === "offline" && sawLoading) {
|
|
clearOfflineTimer();
|
|
// Avoid flashing a false negative during short reconnect windows.
|
|
offlineTimer = window.setTimeout(() => {
|
|
setHint(`Connection interrupted, retrying: ${name}`, "warn");
|
|
offlineTimer = null;
|
|
}, 8000);
|
|
}
|
|
});
|
|
|
|
maybeWatch(watch?.broadcast?.catalog, (catalog) => {
|
|
if (!catalog) return;
|
|
const hasVideo = Boolean(catalog.video && catalog.video.renditions);
|
|
const hasAudio = Boolean(catalog.audio && catalog.audio.renditions);
|
|
if (hasVideo || hasAudio) {
|
|
setHint(`Live: subscribed to ${name}`, "ok");
|
|
}
|
|
});
|
|
|
|
if (cleanup.length) {
|
|
disposePlayerSignals = () => {
|
|
clearOfflineTimer();
|
|
for (const fn of cleanup) {
|
|
try {
|
|
fn();
|
|
} catch (_) {
|
|
// Best-effort cleanup only.
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
function mountPlayer(relayUrl, name) {
|
|
clearPlayerSignals();
|
|
destroyArchivePlayer();
|
|
|
|
const mount = $("playerMount");
|
|
mount.textContent = "";
|
|
|
|
const watch = document.createElement("moq-watch");
|
|
watch.setAttribute("url", relayUrl);
|
|
watch.setAttribute("path", name);
|
|
|
|
// A canvas enables video rendering. Without it, only audio is played.
|
|
const canvas = document.createElement("canvas");
|
|
canvas.className = "canvas";
|
|
watch.appendChild(canvas);
|
|
|
|
mount.appendChild(watch);
|
|
bindPlayerSignals(watch, name);
|
|
}
|
|
|
|
async function ensureMoqWatchElement() {
|
|
if (window.customElements && window.customElements.get("moq-watch")) return;
|
|
if (!moqWatchModulePromise) {
|
|
moqWatchModulePromise = (async () => {
|
|
let lastErr = null;
|
|
for (const moduleUrl of MOQ_WATCH_MODULE_URLS) {
|
|
try {
|
|
await import(moduleUrl);
|
|
} catch (err) {
|
|
lastErr = err;
|
|
continue;
|
|
}
|
|
if (window.customElements && window.customElements.get("moq-watch")) {
|
|
return;
|
|
}
|
|
}
|
|
throw lastErr || new Error("moq-watch custom element is unavailable");
|
|
})();
|
|
}
|
|
await moqWatchModulePromise;
|
|
if (!(window.customElements && window.customElements.get("moq-watch"))) {
|
|
throw new Error("moq-watch custom element is unavailable");
|
|
}
|
|
}
|
|
|
|
async function ensureHlsPlayerCtor() {
|
|
if (window.Hls) return window.Hls;
|
|
if (!hlsModulePromise) {
|
|
hlsModulePromise = (async () => {
|
|
let lastErr = null;
|
|
for (const moduleUrl of HLS_MODULE_URLS) {
|
|
try {
|
|
const mod = await import(moduleUrl);
|
|
if (mod?.default) {
|
|
window.Hls = mod.default;
|
|
} else if (mod?.Hls) {
|
|
window.Hls = mod.Hls;
|
|
}
|
|
} catch (err) {
|
|
lastErr = err;
|
|
continue;
|
|
}
|
|
if (window.Hls) return window.Hls;
|
|
}
|
|
throw lastErr || new Error("hls.js module is unavailable");
|
|
})();
|
|
}
|
|
return hlsModulePromise;
|
|
}
|
|
|
|
async function mountArchivePlayer(name) {
|
|
clearPlayerSignals();
|
|
destroyArchivePlayer();
|
|
|
|
const mount = $("playerMount");
|
|
mount.textContent = "";
|
|
|
|
const video = document.createElement("video");
|
|
video.className = "archiveVideo";
|
|
video.controls = true;
|
|
video.autoplay = true;
|
|
video.muted = false;
|
|
video.playsInline = true;
|
|
mount.appendChild(video);
|
|
|
|
const archiveUrl = `/api/archive/${encodeURIComponent(name)}/master.m3u8`;
|
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
video.src = archiveUrl;
|
|
void video.play().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
const HlsCtor = await ensureHlsPlayerCtor();
|
|
if (!HlsCtor || typeof HlsCtor.isSupported !== "function" || !HlsCtor.isSupported()) {
|
|
throw new Error("HLS playback is unsupported in this browser");
|
|
}
|
|
|
|
const hls = new HlsCtor({
|
|
liveDurationInfinity: true,
|
|
lowLatencyMode: false,
|
|
backBufferLength: 120,
|
|
});
|
|
activeHlsPlayer = hls;
|
|
hls.on(HlsCtor.Events.ERROR, (_event, data) => {
|
|
if (data?.fatal) {
|
|
setHint(`Archive playback error: ${data.type || "fatal"}`, "warn");
|
|
}
|
|
});
|
|
hls.loadSource(archiveUrl);
|
|
hls.attachMedia(video);
|
|
void video.play().catch(() => {});
|
|
}
|
|
|
|
async function copyToClipboard(text) {
|
|
if (!text) return;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
// Fallback: best-effort.
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
function readParams() {
|
|
const u = new URL(window.location.href);
|
|
// Accept legacy/share-link aliases for compatibility.
|
|
const relay =
|
|
u.searchParams.get("url") ||
|
|
u.searchParams.get("relay") ||
|
|
u.searchParams.get("relayUrl");
|
|
const name =
|
|
u.searchParams.get("name") ||
|
|
u.searchParams.get("broadcast") ||
|
|
u.searchParams.get("path");
|
|
const mode = u.searchParams.get("mode") === "archive" ? "archive" : "live";
|
|
return {
|
|
relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL),
|
|
name: normalizeName(name || ""),
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function writeParams(relayUrl, name, mode) {
|
|
const u = new URL(window.location.href);
|
|
u.pathname = "/watch";
|
|
u.searchParams.set("url", relayUrl);
|
|
u.searchParams.set("name", name);
|
|
if (mode === "archive") u.searchParams.set("mode", "archive");
|
|
else u.searchParams.delete("mode");
|
|
// Canonicalize by dropping stale aliases/extra params.
|
|
for (const k of [...u.searchParams.keys()]) {
|
|
if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
|
|
}
|
|
window.history.replaceState({}, "", u.toString());
|
|
}
|
|
|
|
function hasWebTransport() {
|
|
return typeof window.WebTransport !== "undefined";
|
|
}
|
|
|
|
function renderLiveList(entries, onWatchLive, onWatchArchive) {
|
|
const mount = $("liveList");
|
|
mount.textContent = "";
|
|
if (!entries.length) {
|
|
setListHint("No public streams announced yet.", "");
|
|
return;
|
|
}
|
|
setListHint(`${entries.length} live`, "ok");
|
|
|
|
for (const entry of entries) {
|
|
const row = document.createElement("div");
|
|
row.className = "liveItem";
|
|
|
|
const info = document.createElement("div");
|
|
const title = document.createElement("div");
|
|
title.className = "liveTitle";
|
|
title.textContent = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
|
|
info.appendChild(title);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "liveMeta";
|
|
meta.textContent = `${entry.broadcast_name || ""} @ ${entry.relay_url || DEFAULT_RELAY_URL}`;
|
|
info.appendChild(meta);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "liveActions";
|
|
|
|
const watchBtn = document.createElement("button");
|
|
watchBtn.className = "btn secondary";
|
|
watchBtn.textContent = "Live";
|
|
watchBtn.addEventListener("click", () => {
|
|
onWatchLive(entry);
|
|
});
|
|
|
|
const archiveBtn = document.createElement("button");
|
|
archiveBtn.className = "btn secondary";
|
|
archiveBtn.textContent = "Archive";
|
|
archiveBtn.addEventListener("click", () => {
|
|
onWatchArchive(entry);
|
|
});
|
|
|
|
actions.appendChild(watchBtn);
|
|
actions.appendChild(archiveBtn);
|
|
|
|
row.appendChild(info);
|
|
row.appendChild(actions);
|
|
mount.appendChild(row);
|
|
}
|
|
}
|
|
|
|
async function fetchLiveList() {
|
|
const res = await fetch(PUBLIC_STREAMS_PATH, { cache: "no-store" });
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
const body = await res.json();
|
|
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
|
return entries;
|
|
}
|
|
|
|
function main() {
|
|
const relayInput = $("relayUrl");
|
|
const nameInput = $("broadcastName");
|
|
const archiveModeInput = $("archiveMode");
|
|
const watchBtn = $("watchBtn");
|
|
const copyBtn = $("copyLinkBtn");
|
|
const refreshListBtn = $("refreshListBtn");
|
|
|
|
const initial = readParams();
|
|
relayInput.value = initial.relayUrl;
|
|
nameInput.value = initial.name;
|
|
archiveModeInput.checked = initial.mode === "archive";
|
|
|
|
function updateSharePreview() {
|
|
const relayUrl = normalizeRelayUrl(relayInput.value);
|
|
const name = normalizeName(nameInput.value);
|
|
const mode = archiveModeInput.checked ? "archive" : "live";
|
|
if (!name) {
|
|
setShareLink("");
|
|
return;
|
|
}
|
|
setShareLink(currentShareLink(relayUrl, name, mode));
|
|
}
|
|
|
|
async function start() {
|
|
const relayUrl = normalizeRelayUrl(relayInput.value);
|
|
const name = normalizeName(nameInput.value);
|
|
const mode = archiveModeInput.checked ? "archive" : "live";
|
|
|
|
updateSharePreview();
|
|
|
|
if (!name) {
|
|
setHint("Enter a broadcast name to watch.", "warn");
|
|
return;
|
|
}
|
|
|
|
if (mode === "archive") {
|
|
writeParams(relayUrl, name, mode);
|
|
setHint(`Loading archive DVR: ${name}`, "ok");
|
|
try {
|
|
await mountArchivePlayer(name);
|
|
} catch (e) {
|
|
setHint(
|
|
`Archive playback unavailable: ${String(e)}. Ensure /api/archive is configured.`,
|
|
"warn",
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!hasWebTransport()) {
|
|
setHint(
|
|
"WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
|
|
"warn",
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureMoqWatchElement();
|
|
} catch (e) {
|
|
setHint(
|
|
`Failed to load MoQ web player dependency: ${String(e)}. Disable script blockers for esm.sh/jsdelivr/unpkg and retry.`,
|
|
"warn",
|
|
);
|
|
return;
|
|
}
|
|
|
|
writeParams(relayUrl, name, mode);
|
|
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
|
|
mountPlayer(relayUrl, name);
|
|
}
|
|
|
|
relayInput.addEventListener("input", updateSharePreview);
|
|
nameInput.addEventListener("input", updateSharePreview);
|
|
archiveModeInput.addEventListener("input", updateSharePreview);
|
|
|
|
watchBtn.addEventListener("click", () => {
|
|
void start();
|
|
});
|
|
nameInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") void start();
|
|
});
|
|
|
|
copyBtn.addEventListener("click", async () => {
|
|
const relayUrl = normalizeRelayUrl(relayInput.value);
|
|
const name = normalizeName(nameInput.value);
|
|
const mode = archiveModeInput.checked ? "archive" : "live";
|
|
if (!name) {
|
|
setHint("Enter a broadcast name first.", "warn");
|
|
return;
|
|
}
|
|
const link = currentShareLink(relayUrl, name, mode);
|
|
try {
|
|
await copyToClipboard(link);
|
|
setHint("Link copied.", "ok");
|
|
setShareLink(link);
|
|
} catch (e) {
|
|
setHint(`Copy failed: ${String(e)}`, "warn");
|
|
setShareLink(link);
|
|
}
|
|
});
|
|
|
|
async function refreshLiveList() {
|
|
setListHint("Loading live streams...", "");
|
|
try {
|
|
const entries = await fetchLiveList();
|
|
renderLiveList(
|
|
entries,
|
|
(entry) => {
|
|
archiveModeInput.checked = false;
|
|
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
|
|
nameInput.value = normalizeName(entry.broadcast_name || "");
|
|
updateSharePreview();
|
|
void start();
|
|
},
|
|
(entry) => {
|
|
archiveModeInput.checked = true;
|
|
relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
|
|
nameInput.value = normalizeName(entry.broadcast_name || "");
|
|
updateSharePreview();
|
|
void start();
|
|
},
|
|
);
|
|
} catch (e) {
|
|
$("liveList").textContent = "";
|
|
setListHint(`Live list error: ${String(e)}`, "warn");
|
|
}
|
|
}
|
|
refreshListBtn.addEventListener("click", () => {
|
|
void refreshLiveList();
|
|
});
|
|
|
|
updateSharePreview();
|
|
void refreshLiveList();
|
|
window.setInterval(() => {
|
|
void refreshLiveList();
|
|
}, 15000);
|
|
|
|
// Auto-start if a name was provided.
|
|
if (initial.name) void start();
|
|
}
|
|
|
|
main();
|