every.channel/apps/web/app.js
Conrad Kramer 168e9928a5
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions
Keep web perf marker names stable
2026-06-10 02:40:26 -07:00

1139 lines
34 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://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 safeDetail = { ...(detail || {}) };
if (Object.prototype.hasOwnProperty.call(safeDetail, "name")) {
safeDetail.stream_name = safeDetail.name;
delete safeDetail.name;
}
const event = { name, at_ms: Math.round(at), ...safeDetail };
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();