// 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://relay.every.channel/anon"; const MOQ_WATCH_VERSION = "0.2.10"; const MOQ_WATCH_MODULE_URLS = [ `https://esm.sh/@moq/watch@${MOQ_WATCH_VERSION}/element`, `https://cdn.jsdelivr.net/npm/@moq/watch@${MOQ_WATCH_VERSION}/element/+esm`, `https://unpkg.com/@moq/watch@${MOQ_WATCH_VERSION}/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"; const LIVE_JITTER_MS = 1250; const GUIDE_CACHE_KEY = "every.channel.publicStreams.v2"; const GUIDE_CACHE_MAX_AGE_MS = 5 * 60 * 1000; const GUIDE_INITIAL_TIMEOUT_MS = 1800; const GUIDE_REFRESH_TIMEOUT_MS = 5000; const FIRST_FRAME_TIMEOUT_MS = 12000; let moqWatchModulePromise = null; let hlsModulePromise = null; let disposePlayerSignals = null; let activeHlsPlayer = null; const perfState = (window.__ecPerf = window.__ecPerf || { events: [], marks: {}, measures: {}, }); function perfNow() { return window.performance && typeof window.performance.now === "function" ? window.performance.now() : Date.now(); } function markPerf(name, detail) { const at = perfNow(); const event = { name, at_ms: Math.round(at), ...(detail || {}) }; perfState.marks[name] = event; perfState.events.push(event); if (window.performance && typeof window.performance.mark === "function") { try { window.performance.mark(`ec:${name}`); } catch (_) { // Browser performance marks are best-effort. } } if (window.console && typeof window.console.info === "function") { try { window.console.info(`[ec-perf] ${name} ${JSON.stringify(event)}`); } catch (_) { window.console.info(`[ec-perf] ${name}`); } } return event; } function elapsedSincePerf(name) { const mark = perfState.marks[name]; if (!mark) return null; return Math.max(0, Math.round(perfNow() - mark.at_ms)); } function measurePerf(name, startName, endName) { const start = perfState.marks[startName]; const end = perfState.marks[endName]; if (!start || !end) return null; const duration = Math.max(0, Math.round(end.at_ms - start.at_ms)); perfState.measures[name] = duration; return duration; } 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 entryRelays(entry) { const relays = []; const addRelay = (relay) => { if (!relay?.relay_url || !relay.broadcast_name) return; const normalized = { relay_url: normalizeRelayUrl(relay.relay_url), broadcast_name: normalizeName(relay.broadcast_name), track_name: normalizeName(relay.track_name || "video0.m4s"), }; const key = `${normalized.relay_url}\n${normalized.broadcast_name}\n${normalized.track_name}`; if (!relays.some((existing) => `${existing.relay_url}\n${existing.broadcast_name}\n${existing.track_name}` === key)) { relays.push(normalized); } }; addRelay({ relay_url: entry?.relay_url, broadcast_name: entry?.broadcast_name, track_name: entry?.track_name, }); if (Array.isArray(entry?.relays)) { entry.relays.forEach(addRelay); } return relays; } function entryPrimaryRelay(entry) { return entryRelays(entry)[0] || { relay_url: DEFAULT_RELAY_URL, broadcast_name: normalizeName(entry?.broadcast_name || ""), track_name: "video0.m4s", }; } function normalizeGuideEntries(entries) { if (!Array.isArray(entries)) return []; return entries .filter((entry) => entry && (entry.stream_id || entry.broadcast_name || entry.title)) .map((entry) => ({ ...entry, relay_url: normalizeRelayUrl(entry.relay_url), broadcast_name: normalizeName(entry.broadcast_name || entry.stream_id || ""), title: normalizeName(entry.title || entry.stream_id || entry.broadcast_name || "Live"), relays: Array.isArray(entry.relays) ? entry.relays .filter((relay) => relay?.relay_url && relay.broadcast_name) .map((relay) => ({ ...relay, relay_url: normalizeRelayUrl(relay.relay_url), broadcast_name: normalizeName(relay.broadcast_name), track_name: normalizeName(relay.track_name || "video0.m4s"), })) : entry.relays, })); } function guideEntryKey(entry) { return normalizeName(entry?.stream_id || entry?.broadcast_name || entry?.title || ""); } function mergeGuideEntries(primary, fallback) { const merged = []; const seen = new Set(); for (const entry of [...normalizeGuideEntries(primary), ...normalizeGuideEntries(fallback)]) { const key = guideEntryKey(entry); if (!key || seen.has(key)) continue; seen.add(key); merged.push(entry); } return merged; } function readInitialPublicStreams() { const el = document.getElementById("initialPublicStreams"); if (!el) return []; try { const body = JSON.parse(el.textContent || "{}"); return normalizeGuideEntries(body.entries); } catch (err) { markPerf("guide.seed.error", { error: String(err) }); return []; } } function readGuideCache() { try { const raw = window.localStorage?.getItem(GUIDE_CACHE_KEY); if (!raw) return []; const cached = JSON.parse(raw); if (!cached?.saved_ms || Date.now() - cached.saved_ms > GUIDE_CACHE_MAX_AGE_MS) return []; return normalizeGuideEntries(cached.entries); } catch (_) { return []; } } function writeGuideCache(entries) { if (!entries.length) return; try { window.localStorage?.setItem( GUIDE_CACHE_KEY, JSON.stringify({ saved_ms: Date.now(), entries: normalizeGuideEntries(entries) }), ); } catch (_) { // Ignore private-mode or quota failures; the HTML seed remains available. } } 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 setNowTitle(text) { const el = document.getElementById("nowTitle"); if (el) el.textContent = text || "Ready"; } function setShareLink(text) { const el = $("shareLink"); el.textContent = text || ""; } function setListHint(text, kind) { const el = $("listHint"); el.textContent = text || ""; el.dataset.kind = kind || ""; } function openSignalDrawer() { const drawer = $("signalDrawer"); drawer.open = true; $("broadcastName").focus(); } function renderManualTunePrompt(message) { const mount = $("liveList"); mount.textContent = ""; const empty = document.createElement("div"); empty.className = "emptyState"; const title = document.createElement("div"); title.className = "emptyTitle"; title.textContent = message; const action = document.createElement("button"); action.className = "secondaryButton"; action.type = "button"; action.textContent = "Add channel"; action.addEventListener("click", openSignalDrawer); empty.appendChild(title); empty.appendChild(action); mount.appendChild(empty); } function setThumbPosition(el, index) { const positions = [ ["0%", "0%"], ["100%", "0%"], ["0%", "100%"], ["100%", "100%"], ]; const [x, y] = positions[index % positions.length]; el.style.setProperty("--thumb-x", x); el.style.setProperty("--thumb-y", y); } function renderDefaultMultiview() { const grid = document.getElementById("multiViewGrid"); if (!grid) return; const labels = ["News", "Game", "Weather", "Kitchen"]; grid.textContent = ""; labels.forEach((label, index) => { const button = document.createElement("button"); button.className = `miniScreen tile${String.fromCharCode(65 + index)}`; button.type = "button"; button.textContent = label; button.addEventListener("click", () => { setHint("Add a channel key to tune this slot.", "warn"); openSignalDrawer(); }); grid.appendChild(button); }); } function renderMultiview(entries, onWatchLive) { const grid = document.getElementById("multiViewGrid"); if (!grid) return; if (!entries.length) { renderDefaultMultiview(); return; } grid.textContent = ""; entries.slice(0, 4).forEach((entry, index) => { const title = entry.title || entry.stream_id || entry.broadcast_name || "Live"; const button = document.createElement("button"); button.className = "miniScreen"; button.type = "button"; button.textContent = title; setThumbPosition(button, index); button.addEventListener("click", () => onWatchLive(entry)); grid.appendChild(button); }); } 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, extraCleanup) { const cleanup = Array.isArray(extraCleanup) ? [...extraCleanup] : []; let offlineTimer = null; let markedLive = false; let markedCatalog = false; 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; setNowTitle(name); setHint(`Tuning ${name}...`, "ok"); return; } if (status === "live") { clearOfflineTimer(); setNowTitle(name); setHint(`On air: ${name}`, "ok"); if (!markedLive) { markedLive = true; markPerf("watch.live-status", { name, elapsed_ms: elapsedSincePerf("watch.requested"), }); measurePerf("watch.request-to-live", "watch.requested", "watch.live-status"); } return; } if (status === "offline" && sawLoading) { clearOfflineTimer(); // Avoid flashing a false negative during short reconnect windows. offlineTimer = window.setTimeout(() => { setHint(`Signal faded, 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) { setNowTitle(name); setHint(`On air: ${name}`, "ok"); if (!markedCatalog) { markedCatalog = true; markPerf("watch.catalog-ready", { name, has_video: hasVideo, has_audio: hasAudio, elapsed_ms: elapsedSincePerf("watch.requested"), }); measurePerf("watch.request-to-catalog", "watch.requested", "watch.catalog-ready"); } } }); if (cleanup.length) { disposePlayerSignals = () => { clearOfflineTimer(); for (const fn of cleanup) { try { fn(); } catch (_) { // Best-effort cleanup only. } } }; } } function observeFirstCanvasFrame(canvas, name, cleanup) { if (!canvas || typeof canvas.captureStream !== "function") return; let stream = null; let done = false; let timeoutId = null; const video = document.createElement("video"); video.muted = true; video.playsInline = true; video.setAttribute("aria-hidden", "true"); video.style.cssText = "position:fixed;width:1px;height:1px;left:-9999px;top:-9999px;opacity:0;pointer-events:none"; const cleanupFrameObserver = () => { if (done) return; done = true; if (timeoutId) window.clearTimeout(timeoutId); try { stream?.getTracks?.().forEach((track) => track.stop()); } catch (_) { // Best-effort cleanup. } video.remove(); }; const finish = (state) => { if (done) return; markPerf("watch.first-canvas-frame", { name, state, elapsed_ms: elapsedSincePerf("watch.requested"), }); cleanupFrameObserver(); }; try { stream = canvas.captureStream(); video.srcObject = stream; } catch (err) { markPerf("watch.first-canvas-frame.error", { name, error: String(err) }); return; } timeoutId = window.setTimeout(() => finish("timeout"), FIRST_FRAME_TIMEOUT_MS); if (typeof video.requestVideoFrameCallback === "function") { video.requestVideoFrameCallback(() => finish("captured")); } else { video.addEventListener("playing", () => finish("playing"), { once: true }); } cleanup.push(cleanupFrameObserver); document.body.appendChild(video); void video.play().catch(() => finish("play-blocked")); } function mountPlayer(relayUrl, name) { clearPlayerSignals(); destroyArchivePlayer(); const mount = $("playerMount"); mount.textContent = ""; setNowTitle(name); 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"); watch.setAttribute("jitter", String(LIVE_JITTER_MS)); const canvas = document.createElement("canvas"); canvas.className = "canvas"; canvas.width = 1280; canvas.height = 720; watch.appendChild(canvas); mount.appendChild(watch); markPerf("watch.element-mounted", { name, elapsed_ms: elapsedSincePerf("watch.requested"), }); const cleanup = []; observeFirstCanvasFrame(canvas, name, cleanup); let audioUnlocked = false; const forceAudioOn = () => { try { watch.backend?.audio?.muted?.set?.(false); watch.backend?.audio?.volume?.set?.(1); watch.backend?.muted?.set?.(false); watch.backend?.volume?.set?.(1); } catch (_) { // Best effort only. } watch.removeAttribute("muted"); watch.muted = false; }; const unlockAudio = () => { audioUnlocked = true; forceAudioOn(); watch.backend?.paused?.set?.(true); watch.backend?.paused?.set?.(false); setHint(`On air: ${name} (sound on)`, "ok"); }; document.addEventListener("pointerdown", unlockAudio, { once: true }); canvas.addEventListener("pointerdown", unlockAudio, { once: true }); cleanup.push(() => { document.removeEventListener("pointerdown", unlockAudio); canvas.removeEventListener("pointerdown", unlockAudio); }); setHint(`On air: ${name} (tap picture for sound)`, "warn"); bindPlayerSignals(watch, name, cleanup); } 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"); })(); } try { await moqWatchModulePromise; } catch (err) { moqWatchModulePromise = null; throw err; } if (!(window.customElements && window.customElements.get("moq-watch"))) { throw new Error("moq-watch custom element is unavailable"); } } function warmMoqWatchElement() { if (!hasLiveTransport()) return; const warm = async () => { markPerf("player.module.warm.start"); try { await ensureMoqWatchElement(); markPerf("player.module.ready", { elapsed_ms: elapsedSincePerf("player.module.warm.start"), }); } catch (err) { markPerf("player.module.warm.error", { error: String(err) }); } }; if (typeof window.requestIdleCallback === "function") { window.requestIdleCallback(() => { void warm(); }, { timeout: 900 }); return; } window.setTimeout(() => { void warm(); }, 0); } 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 = ""; setNowTitle(name); const video = document.createElement("video"); video.className = "archiveVideo"; video.controls = true; video.autoplay = true; video.muted = false; video.playsInline = true; video.addEventListener("error", () => { setHint( "This replay is not ready for browser playback yet. Live tuning 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("Replay hiccup, retrying...", "warn"); try { hls.startLoad(); } catch (_) { // best-effort recovery } return; } if (data.type === HlsCtor.ErrorTypes.MEDIA_ERROR) { setHint("Replay picture hiccup, recovering...", "warn"); try { hls.recoverMediaError(); } catch (_) { // best-effort recovery } return; } setHint(`Replay unavailable: ${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 hasLiveTransport() { return typeof window.WebTransport !== "undefined" || typeof window.WebSocket !== "undefined"; } function renderLiveList(entries, onWatchLive, onWatchArchive) { const mount = $("liveList"); mount.textContent = ""; if (!entries.length) { setListHint("No stations are on air yet.", ""); renderMultiview([], onWatchLive); renderManualTunePrompt("No channels found"); return; } setListHint(`${entries.length} on air`, "ok"); renderMultiview(entries, onWatchLive); for (const [index, entry] of entries.entries()) { const titleText = entry.title || entry.stream_id || entry.broadcast_name || "Live stream"; const row = document.createElement("div"); row.className = "liveItem"; const tuneButton = document.createElement("button"); tuneButton.className = "liveTune"; tuneButton.type = "button"; tuneButton.setAttribute("aria-label", `Watch ${titleText}`); setThumbPosition(tuneButton, index); tuneButton.addEventListener("click", () => { onWatchLive(entry); }); const info = document.createElement("div"); const title = document.createElement("div"); title.className = "liveTitle"; title.textContent = titleText; info.appendChild(title); const meta = document.createElement("div"); meta.className = "liveMeta"; const relayCount = entryRelays(entry).length; const relayText = relayCount > 1 ? ` ยท ${relayCount} relays` : ""; meta.textContent = `${entry.broadcast_name || "Ready"}${relayText}`; info.appendChild(meta); const watchBadge = document.createElement("span"); watchBadge.className = "watchBadge"; watchBadge.textContent = "Watch"; const actions = document.createElement("div"); actions.className = "liveActions"; const archiveBtn = document.createElement("button"); archiveBtn.className = "secondaryButton"; archiveBtn.type = "button"; archiveBtn.textContent = "DVR"; archiveBtn.addEventListener("click", () => { onWatchArchive(entry); }); actions.appendChild(archiveBtn); tuneButton.appendChild(info); tuneButton.appendChild(watchBadge); row.appendChild(tuneButton); row.appendChild(actions); mount.appendChild(row); } } async function fetchLiveList(options) { const timeoutMs = options?.timeoutMs ?? GUIDE_REFRESH_TIMEOUT_MS; const controller = typeof AbortController !== "undefined" ? new AbortController() : null; const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null; markPerf("guide.fetch.start", { timeout_ms: timeoutMs }); try { const res = await fetch(PUBLIC_STREAMS_PATH, { cache: "no-store", signal: controller?.signal, }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const body = await res.json(); const entries = normalizeGuideEntries(body?.entries); markPerf("guide.fetch.done", { count: entries.length, elapsed_ms: elapsedSincePerf("guide.fetch.start"), }); return entries; } catch (err) { markPerf("guide.fetch.error", { error: controller?.signal?.aborted ? "timeout" : String(err), elapsed_ms: elapsedSincePerf("guide.fetch.start"), }); throw err; } finally { if (timeoutId) window.clearTimeout(timeoutId); } } function main() { markPerf("app.start"); const relayInput = $("relayUrl"); const nameInput = $("broadcastName"); const archiveModeInput = $("archiveMode"); const watchBtn = $("watchBtn"); const copyBtn = $("copyLinkBtn"); const refreshListBtn = $("refreshListBtn"); const liveModeBtn = $("liveModeBtn"); const dvrModeBtn = $("dvrModeBtn"); const scrubber = $("watchScrubber"); const clipStartBtn = $("clipStartBtn"); const clipEndBtn = $("clipEndBtn"); const copyClipBtn = $("copyClipBtn"); const multiViewBtn = $("multiViewBtn"); const multiView = document.querySelector(".multiview"); let clipStart = Number(scrubber.value || 0); let clipEnd = Number(scrubber.value || 0); const initial = readParams(); relayInput.value = initial.relayUrl; nameInput.value = initial.name; archiveModeInput.checked = initial.mode === "archive"; setNowTitle(initial.name || "Ready"); function updateModeButtons() { const isArchive = archiveModeInput.checked; liveModeBtn.classList.toggle("pressed", !isArchive); dvrModeBtn.classList.toggle("pressed", isArchive); liveModeBtn.setAttribute("aria-pressed", String(!isArchive)); dvrModeBtn.setAttribute("aria-pressed", String(isArchive)); } 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)); updateModeButtons(); } function currentClipLink() { const relayUrl = normalizeRelayUrl(relayInput.value); const name = normalizeName(nameInput.value); const mode = archiveModeInput.checked ? "archive" : "live"; if (!name) return ""; const u = new URL(currentShareLink(relayUrl, name, mode)); const start = Math.min(clipStart, clipEnd); const end = Math.max(clipStart, clipEnd); u.searchParams.set("clipStart", String(start)); u.searchParams.set("clipEnd", String(end)); return u.toString(); } async function start() { const relayUrl = normalizeRelayUrl(relayInput.value); const name = normalizeName(nameInput.value); const mode = archiveModeInput.checked ? "archive" : "live"; updateSharePreview(); markPerf("watch.requested", { name, mode, relay_url: relayUrl }); if (!name) { setHint("Pick a station or enter a channel key.", "warn"); setNowTitle("Ready"); openSignalDrawer(); return; } if (mode === "archive") { writeParams(relayUrl, name, mode); setNowTitle(name); setHint(`Loading replay: ${name}`, "ok"); try { await mountArchivePlayer(name); } catch (e) { setHint( `Replay unavailable: ${String(e)}.`, "warn", ); } return; } if (!hasLiveTransport()) { setHint( "Live playback needs a browser live transport. Try DVR replay or another browser.", "warn", ); return; } try { markPerf("player.module.await.start", { elapsed_ms: elapsedSincePerf("watch.requested"), }); await ensureMoqWatchElement(); markPerf("player.module.ready-for-watch", { elapsed_ms: elapsedSincePerf("watch.requested"), }); } catch (e) { setHint( `The player did not load: ${String(e)}. Try again after allowing the page scripts.`, "warn", ); return; } writeParams(relayUrl, name, mode); setNowTitle(name); setHint(`Tuning ${name}...`, "ok"); mountPlayer(relayUrl, name); } relayInput.addEventListener("input", updateSharePreview); nameInput.addEventListener("input", updateSharePreview); archiveModeInput.addEventListener("input", updateSharePreview); archiveModeInput.addEventListener("change", updateModeButtons); liveModeBtn.addEventListener("click", () => { archiveModeInput.checked = false; updateSharePreview(); }); dvrModeBtn.addEventListener("click", () => { archiveModeInput.checked = true; updateSharePreview(); }); scrubber.addEventListener("input", () => { setHint(`Marker ${scrubber.value}%`, ""); }); clipStartBtn.addEventListener("click", () => { clipStart = Number(scrubber.value || 0); setHint(`Clip starts at ${clipStart}%`, "ok"); }); clipEndBtn.addEventListener("click", () => { clipEnd = Number(scrubber.value || 0); setHint(`Clip ends at ${clipEnd}%`, "ok"); }); copyClipBtn.addEventListener("click", async () => { const link = currentClipLink(); if (!link) { setHint("Pick a channel first.", "warn"); return; } try { await copyToClipboard(link); setHint("Clip link copied.", "ok"); setShareLink(link); } catch (e) { setHint(`Copy failed: ${String(e)}`, "warn"); setShareLink(link); } }); multiViewBtn.addEventListener("click", () => { const isHidden = multiView?.hasAttribute("hidden"); if (isHidden) { multiView?.removeAttribute("hidden"); multiViewBtn.classList.add("pressed"); multiViewBtn.setAttribute("aria-pressed", "true"); } else { multiView?.setAttribute("hidden", ""); multiViewBtn.classList.remove("pressed"); multiViewBtn.setAttribute("aria-pressed", "false"); } }); 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("Pick a station 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); } }); let lastRenderedEntries = []; let guideFirstRendered = false; const watchLiveEntry = (entry) => { const relay = entryPrimaryRelay(entry); archiveModeInput.checked = false; relayInput.value = relay.relay_url; nameInput.value = relay.broadcast_name; updateSharePreview(); void start(); }; const watchArchiveEntry = (entry) => { const relay = entryPrimaryRelay(entry); archiveModeInput.checked = true; relayInput.value = relay.relay_url; nameInput.value = relay.broadcast_name; updateSharePreview(); void start(); }; function renderGuide(entries, source) { const normalized = normalizeGuideEntries(entries); lastRenderedEntries = normalized; renderLiveList(normalized, watchLiveEntry, watchArchiveEntry); const detail = { source, count: normalized.length, elapsed_ms: elapsedSincePerf("app.start"), }; if (!guideFirstRendered && normalized.length) { guideFirstRendered = true; markPerf("guide.first-render", detail); measurePerf("app-to-guide-first-render", "app.start", "guide.first-render"); return; } markPerf("guide.render", detail); } async function refreshLiveList(options) { const manual = Boolean(options?.manual); const hadEntries = lastRenderedEntries.length > 0; if (manual || !hadEntries) { setListHint(hadEntries ? "Checking channels..." : "Scanning stations...", ""); } try { const entries = await fetchLiveList({ timeoutMs: manual ? GUIDE_REFRESH_TIMEOUT_MS : GUIDE_INITIAL_TIMEOUT_MS, }); if (!entries.length && hadEntries) { setListHint("Using starter channel guide.", "warn"); return; } if (entries.length) { writeGuideCache(entries); renderGuide(entries, "network"); return; } renderGuide([], "network-empty"); } catch (e) { const fallback = mergeGuideEntries(readGuideCache(), readInitialPublicStreams()); if (hadEntries) { setListHint("Using saved channel guide.", "warn"); return; } if (fallback.length) { renderGuide(fallback, "fallback"); setListHint(`${fallback.length} on air`, "ok"); return; } $("liveList").textContent = ""; setListHint("Station guide is unavailable.", "warn"); renderMultiview([], () => {}); renderManualTunePrompt("Have a channel key?"); } } refreshListBtn.addEventListener("click", () => { void refreshLiveList({ manual: true }); }); updateSharePreview(); updateModeButtons(); const seededEntries = readInitialPublicStreams(); const cachedEntries = readGuideCache(); const initialEntries = mergeGuideEntries(cachedEntries, seededEntries); if (initialEntries.length) { renderGuide(initialEntries, cachedEntries.length ? "cache" : "html-seed"); } else { renderDefaultMultiview(); } warmMoqWatchElement(); void refreshLiveList(); window.setInterval(() => { void refreshLiveList(); }, 15000); // Auto-start if a name was provided. if (initial.name) void start(); } main();