// 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.2.0/element", "https://cdn.jsdelivr.net/npm/@moq/watch@0.2.0/element/+esm", "https://unpkg.com/@moq/watch@0.2.0/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); // Support both @moq/watch attribute variants across minor versions. watch.setAttribute("name", name); watch.setAttribute("path", name); watch.setAttribute("volume", "1"); // Force WebTransport in-browser; websocket fallback has shown degraded // media behavior (especially audio) against public relay paths. if (watch.connection && typeof watch.connection === "object") { watch.connection.websocket = { enabled: false }; } // 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; } function shouldUseNativeHls(video) { const ua = navigator.userAgent || ""; const vendor = navigator.vendor || ""; const isIos = /iPhone|iPad|iPod/i.test(ua); const isSafari = /Safari/i.test(ua) && /Apple/i.test(vendor) && !/CriOS|FxiOS|EdgiOS|OPiOS|Chrome|Chromium/i.test(ua); return (isIos || isSafari) && Boolean(video.canPlayType("application/vnd.apple.mpegurl")); } 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; video.addEventListener("error", () => { setHint( "Archive replay bytes are not browser-HLS compatible yet (legacy container); timeline is available, live path is unaffected.", "warn", ); }); mount.appendChild(video); const archiveUrl = `/api/archive/${encodeURIComponent(name)}/master.m3u8`; if (shouldUseNativeHls(video)) { 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) return; if (data.type === HlsCtor.ErrorTypes.NETWORK_ERROR) { setHint("Archive network hiccup, retrying…", "warn"); try { hls.startLoad(); } catch (_) { // best-effort recovery } return; } if (data.type === HlsCtor.ErrorTypes.MEDIA_ERROR) { setHint("Archive media hiccup, recovering…", "warn"); try { hls.recoverMediaError(); } catch (_) { // best-effort recovery } return; } 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();