// 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 PUBLIC_STREAMS_PATH = "/api/public-streams"; let moqWatchModulePromise = null; let disposePlayerSignals = 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) { const u = new URL(window.location.href); u.pathname = "/watch"; u.searchParams.set("url", relayUrl); u.searchParams.set("name", name); // Avoid leaking other params. for (const k of [...u.searchParams.keys()]) { if (k !== "url" && k !== "name") 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 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(); 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 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"); return { relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL), name: normalizeName(name || ""), }; } function writeParams(relayUrl, name) { const u = new URL(window.location.href); u.pathname = "/watch"; u.searchParams.set("url", relayUrl); u.searchParams.set("name", name); // Canonicalize by dropping stale aliases/extra params. for (const k of [...u.searchParams.keys()]) { if (k !== "url" && k !== "name") u.searchParams.delete(k); } window.history.replaceState({}, "", u.toString()); } function hasWebTransport() { return typeof window.WebTransport !== "undefined"; } function renderLiveList(entries, onWatch) { 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 btn = document.createElement("button"); btn.className = "btn secondary"; btn.textContent = "Watch"; btn.addEventListener("click", () => { onWatch(entry); }); row.appendChild(info); row.appendChild(btn); 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 watchBtn = $("watchBtn"); const copyBtn = $("copyLinkBtn"); const refreshListBtn = $("refreshListBtn"); const initial = readParams(); relayInput.value = initial.relayUrl; nameInput.value = initial.name; function updateSharePreview() { const relayUrl = normalizeRelayUrl(relayInput.value); const name = normalizeName(nameInput.value); if (!name) { setShareLink(""); return; } setShareLink(currentShareLink(relayUrl, name)); } async function start() { const relayUrl = normalizeRelayUrl(relayInput.value); const name = normalizeName(nameInput.value); updateSharePreview(); if (!name) { setHint("Enter a broadcast name to watch.", "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); setHint(`Connecting to relay and subscribing: ${name}`, "ok"); mountPlayer(relayUrl, name); } relayInput.addEventListener("input", updateSharePreview); nameInput.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); if (!name) { setHint("Enter a broadcast name first.", "warn"); return; } const link = currentShareLink(relayUrl, name); 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) => { 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();