Speed up station guide startup
This commit is contained in:
parent
cfc4902016
commit
797f96e7eb
8 changed files with 531 additions and 52 deletions
|
|
@ -79,7 +79,7 @@ Publish (node -> Cloudflare relay):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p ec-node -- wt-publish \
|
cargo run -p ec-node -- wt-publish \
|
||||||
--url https://cdn.moq.dev/anon \
|
--url https://relay.every.channel/anon \
|
||||||
--name la-nbc \
|
--name la-nbc \
|
||||||
--input http://<hdhr-host>/auto/v4.1 \
|
--input http://<hdhr-host>/auto/v4.1 \
|
||||||
--control-announce \
|
--control-announce \
|
||||||
|
|
@ -91,14 +91,14 @@ cargo run -p ec-node -- wt-publish \
|
||||||
Watch (web):
|
Watch (web):
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
https://every.channel/watch?url=https%3A%2F%2Fcdn.moq.dev%2Fanon&name=la-nbc
|
https://every.channel/watch?url=https%3A%2F%2Frelay.every.channel%2Fanon&name=la-nbc
|
||||||
```
|
```
|
||||||
|
|
||||||
Archive (relay -> CAS objects + JSONL manifests):
|
Archive (relay -> CAS objects + JSONL manifests):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p ec-node -- wt-archive \
|
cargo run -p ec-node -- wt-archive \
|
||||||
--url https://cdn.moq.dev/anon \
|
--url https://relay.every.channel/anon \
|
||||||
--name la-nbc \
|
--name la-nbc \
|
||||||
--output-dir /tank/every-channel/archive \
|
--output-dir /tank/every-channel/archive \
|
||||||
--manifest-dir /var/lib/every-channel/manifests
|
--manifest-dir /var/lib/every-channel/manifests
|
||||||
|
|
@ -122,7 +122,7 @@ cargo run -p ec-node -- control-listen --gossip-peer <node-b-endpoint-addr-json>
|
||||||
# Announcer (on node B)
|
# Announcer (on node B)
|
||||||
cargo run -p ec-node -- control-announce \
|
cargo run -p ec-node -- control-announce \
|
||||||
--stream-id la-nbc \
|
--stream-id la-nbc \
|
||||||
--relay-url https://cdn.moq.dev/anon \
|
--relay-url https://relay.every.channel/anon \
|
||||||
--relay-broadcast la-nbc \
|
--relay-broadcast la-nbc \
|
||||||
--gossip-peer <node-a-endpoint-addr-json>
|
--gossip-peer <node-a-endpoint-addr-json>
|
||||||
|
|
||||||
|
|
|
||||||
365
apps/web/app.js
365
apps/web/app.js
|
|
@ -3,7 +3,7 @@
|
||||||
// This uses the upstream moq watch web component (WebTransport + WebCodecs).
|
// This uses the upstream moq watch web component (WebTransport + WebCodecs).
|
||||||
// It is intentionally dependency-light: no framework, no bundler.
|
// It is intentionally dependency-light: no framework, no bundler.
|
||||||
|
|
||||||
const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon";
|
const DEFAULT_RELAY_URL = "https://relay.every.channel/anon";
|
||||||
const MOQ_WATCH_VERSION = "0.2.10";
|
const MOQ_WATCH_VERSION = "0.2.10";
|
||||||
const MOQ_WATCH_MODULE_URLS = [
|
const MOQ_WATCH_MODULE_URLS = [
|
||||||
`https://esm.sh/@moq/watch@${MOQ_WATCH_VERSION}/element`,
|
`https://esm.sh/@moq/watch@${MOQ_WATCH_VERSION}/element`,
|
||||||
|
|
@ -17,10 +17,63 @@ const HLS_MODULE_URLS = [
|
||||||
];
|
];
|
||||||
const PUBLIC_STREAMS_PATH = "/api/public-streams";
|
const PUBLIC_STREAMS_PATH = "/api/public-streams";
|
||||||
const LIVE_JITTER_MS = 1250;
|
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 moqWatchModulePromise = null;
|
||||||
let hlsModulePromise = null;
|
let hlsModulePromise = null;
|
||||||
let disposePlayerSignals = null;
|
let disposePlayerSignals = null;
|
||||||
let activeHlsPlayer = 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) {
|
function $(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|
@ -74,6 +127,80 @@ function entryPrimaryRelay(entry) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function currentShareLink(relayUrl, name, mode) {
|
||||||
const u = new URL(window.location.href);
|
const u = new URL(window.location.href);
|
||||||
u.pathname = "/watch";
|
u.pathname = "/watch";
|
||||||
|
|
@ -209,6 +336,8 @@ function destroyArchivePlayer() {
|
||||||
function bindPlayerSignals(watch, name, extraCleanup) {
|
function bindPlayerSignals(watch, name, extraCleanup) {
|
||||||
const cleanup = Array.isArray(extraCleanup) ? [...extraCleanup] : [];
|
const cleanup = Array.isArray(extraCleanup) ? [...extraCleanup] : [];
|
||||||
let offlineTimer = null;
|
let offlineTimer = null;
|
||||||
|
let markedLive = false;
|
||||||
|
let markedCatalog = false;
|
||||||
|
|
||||||
const clearOfflineTimer = () => {
|
const clearOfflineTimer = () => {
|
||||||
if (offlineTimer) {
|
if (offlineTimer) {
|
||||||
|
|
@ -236,6 +365,14 @@ function bindPlayerSignals(watch, name, extraCleanup) {
|
||||||
clearOfflineTimer();
|
clearOfflineTimer();
|
||||||
setNowTitle(name);
|
setNowTitle(name);
|
||||||
setHint(`On air: ${name}`, "ok");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "offline" && sawLoading) {
|
if (status === "offline" && sawLoading) {
|
||||||
|
|
@ -255,6 +392,16 @@ function bindPlayerSignals(watch, name, extraCleanup) {
|
||||||
if (hasVideo || hasAudio) {
|
if (hasVideo || hasAudio) {
|
||||||
setNowTitle(name);
|
setNowTitle(name);
|
||||||
setHint(`On air: ${name}`, "ok");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -272,6 +419,59 @@ function bindPlayerSignals(watch, name, extraCleanup) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function mountPlayer(relayUrl, name) {
|
||||||
clearPlayerSignals();
|
clearPlayerSignals();
|
||||||
destroyArchivePlayer();
|
destroyArchivePlayer();
|
||||||
|
|
@ -288,19 +488,18 @@ function mountPlayer(relayUrl, name) {
|
||||||
watch.setAttribute("volume", "1");
|
watch.setAttribute("volume", "1");
|
||||||
watch.setAttribute("jitter", String(LIVE_JITTER_MS));
|
watch.setAttribute("jitter", String(LIVE_JITTER_MS));
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.className = "canvas";
|
canvas.className = "canvas";
|
||||||
canvas.width = 1280;
|
canvas.width = 1280;
|
||||||
canvas.height = 720;
|
canvas.height = 720;
|
||||||
watch.appendChild(canvas);
|
watch.appendChild(canvas);
|
||||||
mount.appendChild(watch);
|
mount.appendChild(watch);
|
||||||
|
markPerf("watch.element-mounted", {
|
||||||
|
name,
|
||||||
|
elapsed_ms: elapsedSincePerf("watch.requested"),
|
||||||
|
});
|
||||||
const cleanup = [];
|
const cleanup = [];
|
||||||
|
observeFirstCanvasFrame(canvas, name, cleanup);
|
||||||
let audioUnlocked = false;
|
let audioUnlocked = false;
|
||||||
const forceAudioOn = () => {
|
const forceAudioOn = () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -350,12 +549,41 @@ async function ensureMoqWatchElement() {
|
||||||
throw lastErr || new Error("moq-watch custom element is unavailable");
|
throw lastErr || new Error("moq-watch custom element is unavailable");
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await moqWatchModulePromise;
|
await moqWatchModulePromise;
|
||||||
|
} catch (err) {
|
||||||
|
moqWatchModulePromise = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
if (!(window.customElements && window.customElements.get("moq-watch"))) {
|
if (!(window.customElements && window.customElements.get("moq-watch"))) {
|
||||||
throw new Error("moq-watch custom element is unavailable");
|
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() {
|
async function ensureHlsPlayerCtor() {
|
||||||
if (window.Hls) return window.Hls;
|
if (window.Hls) return window.Hls;
|
||||||
if (!hlsModulePromise) {
|
if (!hlsModulePromise) {
|
||||||
|
|
@ -507,8 +735,8 @@ function writeParams(relayUrl, name, mode) {
|
||||||
window.history.replaceState({}, "", u.toString());
|
window.history.replaceState({}, "", u.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasWebTransport() {
|
function hasLiveTransport() {
|
||||||
return typeof window.WebTransport !== "undefined";
|
return typeof window.WebTransport !== "undefined" || typeof window.WebSocket !== "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveList(entries, onWatchLive, onWatchArchive) {
|
function renderLiveList(entries, onWatchLive, onWatchArchive) {
|
||||||
|
|
@ -575,17 +803,41 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLiveList() {
|
async function fetchLiveList(options) {
|
||||||
const res = await fetch(PUBLIC_STREAMS_PATH, { cache: "no-store" });
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}`);
|
throw new Error(`HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
const entries = normalizeGuideEntries(body?.entries);
|
||||||
|
markPerf("guide.fetch.done", {
|
||||||
|
count: entries.length,
|
||||||
|
elapsed_ms: elapsedSincePerf("guide.fetch.start"),
|
||||||
|
});
|
||||||
return entries;
|
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() {
|
function main() {
|
||||||
|
markPerf("app.start");
|
||||||
const relayInput = $("relayUrl");
|
const relayInput = $("relayUrl");
|
||||||
const nameInput = $("broadcastName");
|
const nameInput = $("broadcastName");
|
||||||
const archiveModeInput = $("archiveMode");
|
const archiveModeInput = $("archiveMode");
|
||||||
|
|
@ -648,6 +900,7 @@ function main() {
|
||||||
const mode = archiveModeInput.checked ? "archive" : "live";
|
const mode = archiveModeInput.checked ? "archive" : "live";
|
||||||
|
|
||||||
updateSharePreview();
|
updateSharePreview();
|
||||||
|
markPerf("watch.requested", { name, mode, relay_url: relayUrl });
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setHint("Pick a station or enter a channel key.", "warn");
|
setHint("Pick a station or enter a channel key.", "warn");
|
||||||
|
|
@ -671,16 +924,22 @@ function main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasWebTransport()) {
|
if (!hasLiveTransport()) {
|
||||||
setHint(
|
setHint(
|
||||||
"Live playback needs WebTransport here. Try DVR replay or another browser.",
|
"Live playback needs a browser live transport. Try DVR replay or another browser.",
|
||||||
"warn",
|
"warn",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
markPerf("player.module.await.start", {
|
||||||
|
elapsed_ms: elapsedSincePerf("watch.requested"),
|
||||||
|
});
|
||||||
await ensureMoqWatchElement();
|
await ensureMoqWatchElement();
|
||||||
|
markPerf("player.module.ready-for-watch", {
|
||||||
|
elapsed_ms: elapsedSincePerf("watch.requested"),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setHint(
|
setHint(
|
||||||
`The player did not load: ${String(e)}. Try again after allowing the page scripts.`,
|
`The player did not load: ${String(e)}. Try again after allowing the page scripts.`,
|
||||||
|
|
@ -772,30 +1031,76 @@ function main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshLiveList() {
|
let lastRenderedEntries = [];
|
||||||
setListHint("Scanning stations...", "");
|
let guideFirstRendered = false;
|
||||||
try {
|
|
||||||
const entries = await fetchLiveList();
|
const watchLiveEntry = (entry) => {
|
||||||
renderLiveList(
|
|
||||||
entries,
|
|
||||||
(entry) => {
|
|
||||||
const relay = entryPrimaryRelay(entry);
|
const relay = entryPrimaryRelay(entry);
|
||||||
archiveModeInput.checked = false;
|
archiveModeInput.checked = false;
|
||||||
relayInput.value = relay.relay_url;
|
relayInput.value = relay.relay_url;
|
||||||
nameInput.value = relay.broadcast_name;
|
nameInput.value = relay.broadcast_name;
|
||||||
updateSharePreview();
|
updateSharePreview();
|
||||||
void start();
|
void start();
|
||||||
},
|
};
|
||||||
(entry) => {
|
|
||||||
|
const watchArchiveEntry = (entry) => {
|
||||||
const relay = entryPrimaryRelay(entry);
|
const relay = entryPrimaryRelay(entry);
|
||||||
archiveModeInput.checked = true;
|
archiveModeInput.checked = true;
|
||||||
relayInput.value = relay.relay_url;
|
relayInput.value = relay.relay_url;
|
||||||
nameInput.value = relay.broadcast_name;
|
nameInput.value = relay.broadcast_name;
|
||||||
updateSharePreview();
|
updateSharePreview();
|
||||||
void start();
|
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) {
|
} 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 = "";
|
$("liveList").textContent = "";
|
||||||
setListHint("Station guide is unavailable.", "warn");
|
setListHint("Station guide is unavailable.", "warn");
|
||||||
renderMultiview([], () => {});
|
renderMultiview([], () => {});
|
||||||
|
|
@ -803,12 +1108,20 @@ function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshListBtn.addEventListener("click", () => {
|
refreshListBtn.addEventListener("click", () => {
|
||||||
void refreshLiveList();
|
void refreshLiveList({ manual: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSharePreview();
|
updateSharePreview();
|
||||||
updateModeButtons();
|
updateModeButtons();
|
||||||
|
const seededEntries = readInitialPublicStreams();
|
||||||
|
const cachedEntries = readGuideCache();
|
||||||
|
const initialEntries = mergeGuideEntries(cachedEntries, seededEntries);
|
||||||
|
if (initialEntries.length) {
|
||||||
|
renderGuide(initialEntries, cachedEntries.length ? "cache" : "html-seed");
|
||||||
|
} else {
|
||||||
renderDefaultMultiview();
|
renderDefaultMultiview();
|
||||||
|
}
|
||||||
|
warmMoqWatchElement();
|
||||||
void refreshLiveList();
|
void refreshLiveList();
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
void refreshLiveList();
|
void refreshLiveList();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>every.channel</title>
|
<title>every.channel</title>
|
||||||
<meta name="description" content="Watch live television from every.channel." />
|
<meta name="description" content="Watch live television from every.channel." />
|
||||||
|
<link rel="preconnect" href="https://relay.every.channel" />
|
||||||
|
<link rel="preconnect" href="https://esm.sh" />
|
||||||
|
<link rel="modulepreload" href="https://esm.sh/@moq/watch@0.2.10/element" />
|
||||||
<link data-trunk rel="css" href="style.css" />
|
<link data-trunk rel="css" href="style.css" />
|
||||||
<link data-trunk rel="copy-file" href="app.js" />
|
<link data-trunk rel="copy-file" href="app.js" />
|
||||||
<link data-trunk rel="copy-dir" href="assets" />
|
<link data-trunk rel="copy-dir" href="assets" />
|
||||||
|
|
@ -113,6 +116,55 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script id="initialPublicStreams" type="application/json">
|
||||||
|
{
|
||||||
|
"now_ms": 1781081086809,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"stream_id": "la-kcbs",
|
||||||
|
"title": "KCBS Los Angeles",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-kcbs",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stream_id": "la-ktla",
|
||||||
|
"title": "KTLA",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-ktla",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stream_id": "la-kcet",
|
||||||
|
"title": "KCET",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-kcet",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stream_id": "la-pbs-socal",
|
||||||
|
"title": "PBS SoCal",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-pbs-socal",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stream_id": "la-nbc4",
|
||||||
|
"title": "NBC4 Los Angeles",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-nbc4",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stream_id": "la-kcop",
|
||||||
|
"title": "KCOP",
|
||||||
|
"relay_url": "https://relay.every.channel/anon",
|
||||||
|
"broadcast_name": "la-kcop",
|
||||||
|
"track_name": "0.m4s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.stageGrid {
|
.stageGrid {
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(310px, 382px);
|
grid-template-columns: minmax(0, 1fr) minmax(310px, 382px);
|
||||||
|
|
@ -238,6 +239,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.television {
|
.television {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -729,7 +731,7 @@ button {
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.watchSurface {
|
.watchSurface {
|
||||||
width: min(100% - 20px, 560px);
|
width: min(calc(100% - 20px), 560px);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -785,21 +787,86 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 440px) {
|
@media (max-width: 440px) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.watchSurface {
|
.watchSurface {
|
||||||
width: min(100% - 16px, 420px);
|
width: min(calc(100% - 16px), 420px);
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.stageGrid,
|
||||||
|
.playerStack,
|
||||||
|
.television,
|
||||||
|
.multiview,
|
||||||
|
.channelRail {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span:last-child,
|
||||||
|
.quickTuneInput {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand span:last-child {
|
.brand span:last-child {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryButton {
|
.primaryButton {
|
||||||
|
width: 100%;
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quickTune {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolRow,
|
||||||
|
.multiViewGrid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveTune {
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveTune::before {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchBadge {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundButton {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channelRail {
|
.channelRail {
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ fn e2e_remote_website_watch_existing_stream_id() -> anyhow::Result<()> {
|
||||||
let site_url = std::env::var("EVERY_CHANNEL_SITE_URL")
|
let site_url = std::env::var("EVERY_CHANNEL_SITE_URL")
|
||||||
.unwrap_or_else(|_| "https://every.channel/".to_string());
|
.unwrap_or_else(|_| "https://every.channel/".to_string());
|
||||||
let relay_url = std::env::var("EVERY_CHANNEL_RELAY_URL")
|
let relay_url = std::env::var("EVERY_CHANNEL_RELAY_URL")
|
||||||
.unwrap_or_else(|_| "https://cdn.moq.dev/anon".to_string());
|
.unwrap_or_else(|_| "https://relay.every.channel/anon".to_string());
|
||||||
let stream_id = match std::env::var("EVERY_CHANNEL_STREAM_ID") {
|
let stream_id = match std::env::var("EVERY_CHANNEL_STREAM_ID") {
|
||||||
Ok(v) if !v.trim().is_empty() => v,
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
_ => return Ok(()), // skip
|
_ => return Ok(()), // skip
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# ECP-0123: Instant Station Guide and Player Warmup
|
||||||
|
|
||||||
|
Status: Draft
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
The hosted watch page can feel broken when `/api/public-streams` is slow, empty, or temporarily
|
||||||
|
unreachable: the channel rail waits on the network before showing stations. Even after stations
|
||||||
|
appear, the first channel tap pays the `@moq/watch` module import cost before the player element can
|
||||||
|
mount. That increases time to first playing frame and makes channel selection feel unreliable.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep the first screen a usable TV/player interface, not a marketing page.
|
||||||
|
- Preserve manual tuning, share links, DVR mode, public station refresh, and existing relay fields.
|
||||||
|
- Keep rollback simple: the live API remains authoritative after it answers.
|
||||||
|
- Avoid Chrome-only transport assumptions and keep live playback failure messages actionable.
|
||||||
|
- Keep the change static-site compatible so Cloudflare Worker asset deployment stays simple.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Embed the current starter LA station guide in `index.html` and render it immediately on first load.
|
||||||
|
Also keep a short-lived local guide cache, merge cached entries with the HTML seed, and refresh
|
||||||
|
`/api/public-streams` in the background with a bounded timeout. A slow or empty refresh no longer
|
||||||
|
clears already visible channels.
|
||||||
|
|
||||||
|
Change the web client, publisher module defaults, runbook examples, and remote watch E2E default
|
||||||
|
relay to `https://relay.every.channel/anon`. Preload/preconnect the primary player dependencies and
|
||||||
|
warm the `@moq/watch` custom element after first paint. Let `@moq/watch` use its available live
|
||||||
|
transport fallback instead of forcing WebTransport-only playback. Add client performance marks for
|
||||||
|
guide first render, guide fetch, watch request, player module readiness, player mount, catalog/live
|
||||||
|
status, and first observable canvas frame when the browser exposes it.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Wait only for `/api/public-streams`. Rejected because the UI becomes blank when the directory is
|
||||||
|
slow and because children should be able to tap visible channels immediately.
|
||||||
|
- Make the API response cacheable at the CDN. Rejected as the only fix because active streams are
|
||||||
|
short TTL and stale-but-visible fallback belongs in the client.
|
||||||
|
- Bundle `@moq/watch` into the static site. Deferred because CDN import fallback is already in use;
|
||||||
|
warming and modulepreload reduce tap latency without changing the build graph.
|
||||||
|
|
||||||
|
## Rollout / teardown plan
|
||||||
|
|
||||||
|
Ship the static web change with the existing Worker asset deploy. Validate with clean-cache
|
||||||
|
desktop/mobile browser loads and check the app's `window.__ecPerf` marks. Teardown is removing the
|
||||||
|
HTML seed/cache/warmup path and returning to live-API-only station rendering.
|
||||||
|
|
@ -48,7 +48,7 @@ in
|
||||||
|
|
||||||
relayUrl = lib.mkOption {
|
relayUrl = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "https://cdn.moq.dev/anon";
|
default = "https://relay.every.channel/anon";
|
||||||
description = "MoQ relay URL for ec-node wt-publish.";
|
description = "MoQ relay URL for ec-node wt-publish.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
networking.hostName = lib.mkForce "ec-publisher";
|
networking.hostName = lib.mkForce "ec-publisher";
|
||||||
|
|
||||||
services.every-channel.ec-node = {
|
services.every-channel.ec-node = {
|
||||||
relayUrl = lib.mkDefault "https://cdn.moq.dev/anon";
|
relayUrl = lib.mkDefault "https://relay.every.channel/anon";
|
||||||
passthrough = lib.mkDefault false;
|
passthrough = lib.mkDefault false;
|
||||||
|
|
||||||
hdhomerun.autoDiscover = lib.mkDefault true;
|
hdhomerun.autoDiscover = lib.mkDefault true;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue