diff --git a/README.md b/README.md index a272fef..ad06ff8 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Publish (node -> Cloudflare relay): ```sh cargo run -p ec-node -- wt-publish \ - --url https://cdn.moq.dev/anon \ + --url https://relay.every.channel/anon \ --name la-nbc \ --input http:///auto/v4.1 \ --control-announce \ @@ -91,14 +91,14 @@ cargo run -p ec-node -- wt-publish \ Watch (web): ```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): ```sh cargo run -p ec-node -- wt-archive \ - --url https://cdn.moq.dev/anon \ + --url https://relay.every.channel/anon \ --name la-nbc \ --output-dir /tank/every-channel/archive \ --manifest-dir /var/lib/every-channel/manifests @@ -122,7 +122,7 @@ cargo run -p ec-node -- control-listen --gossip-peer # Announcer (on node B) cargo run -p ec-node -- control-announce \ --stream-id la-nbc \ - --relay-url https://cdn.moq.dev/anon \ + --relay-url https://relay.every.channel/anon \ --relay-broadcast la-nbc \ --gossip-peer diff --git a/apps/web/app.js b/apps/web/app.js index 4b2ac36..13e4b50 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -3,7 +3,7 @@ // This uses the upstream moq watch web component (WebTransport + WebCodecs). // It is intentionally dependency-light: no framework, no bundler. -const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon"; +const 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`, @@ -17,10 +17,63 @@ const HLS_MODULE_URLS = [ ]; 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); @@ -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) { const u = new URL(window.location.href); u.pathname = "/watch"; @@ -209,6 +336,8 @@ function destroyArchivePlayer() { function bindPlayerSignals(watch, name, extraCleanup) { const cleanup = Array.isArray(extraCleanup) ? [...extraCleanup] : []; let offlineTimer = null; + let markedLive = false; + let markedCatalog = false; const clearOfflineTimer = () => { if (offlineTimer) { @@ -236,6 +365,14 @@ function bindPlayerSignals(watch, name, extraCleanup) { 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) { @@ -255,6 +392,16 @@ function bindPlayerSignals(watch, name, extraCleanup) { 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"); + } } }); @@ -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) { clearPlayerSignals(); destroyArchivePlayer(); @@ -288,19 +488,18 @@ function mountPlayer(relayUrl, name) { watch.setAttribute("volume", "1"); 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"); 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 { @@ -350,12 +549,41 @@ async function ensureMoqWatchElement() { throw lastErr || new Error("moq-watch custom element is unavailable"); })(); } - await moqWatchModulePromise; + 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) { @@ -507,8 +735,8 @@ function writeParams(relayUrl, name, mode) { window.history.replaceState({}, "", u.toString()); } -function hasWebTransport() { - return typeof window.WebTransport !== "undefined"; +function hasLiveTransport() { + return typeof window.WebTransport !== "undefined" || typeof window.WebSocket !== "undefined"; } function renderLiveList(entries, onWatchLive, onWatchArchive) { @@ -575,17 +803,41 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) { } } -async function fetchLiveList() { - const res = await fetch(PUBLIC_STREAMS_PATH, { cache: "no-store" }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); +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); } - const body = await res.json(); - const entries = Array.isArray(body?.entries) ? body.entries : []; - return entries; } function main() { + markPerf("app.start"); const relayInput = $("relayUrl"); const nameInput = $("broadcastName"); const archiveModeInput = $("archiveMode"); @@ -648,6 +900,7 @@ function main() { 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"); @@ -671,16 +924,22 @@ function main() { return; } - if (!hasWebTransport()) { + if (!hasLiveTransport()) { 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", ); 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.`, @@ -772,30 +1031,76 @@ function main() { } }); - async function refreshLiveList() { - setListHint("Scanning stations...", ""); + 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(); - renderLiveList( - entries, - (entry) => { - const relay = entryPrimaryRelay(entry); - archiveModeInput.checked = false; - relayInput.value = relay.relay_url; - nameInput.value = relay.broadcast_name; - updateSharePreview(); - void start(); - }, - (entry) => { - const relay = entryPrimaryRelay(entry); - archiveModeInput.checked = true; - relayInput.value = relay.relay_url; - nameInput.value = relay.broadcast_name; - updateSharePreview(); - void start(); - }, - ); + 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([], () => {}); @@ -803,12 +1108,20 @@ function main() { } } refreshListBtn.addEventListener("click", () => { - void refreshLiveList(); + void refreshLiveList({ manual: true }); }); updateSharePreview(); updateModeButtons(); - renderDefaultMultiview(); + 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(); diff --git a/apps/web/index.html b/apps/web/index.html index 1477e4c..bdc254f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,9 @@ every.channel + + + @@ -113,6 +116,55 @@ + diff --git a/apps/web/style.css b/apps/web/style.css index 76817f8..6a02c11 100644 --- a/apps/web/style.css +++ b/apps/web/style.css @@ -224,6 +224,7 @@ button { } .stageGrid { + min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, 382px); @@ -238,6 +239,7 @@ button { } .television { + min-width: 0; overflow: hidden; border-radius: 18px; background: @@ -729,7 +731,7 @@ button { @media (max-width: 720px) { .watchSurface { - width: min(100% - 20px, 560px); + width: min(calc(100% - 20px), 560px); gap: 12px; padding-top: 10px; } @@ -785,21 +787,86 @@ button { } @media (max-width: 440px) { + html, + body { + overflow-x: hidden; + } + .watchSurface { - width: min(100% - 16px, 420px); + width: min(calc(100% - 16px), 420px); + max-width: calc(100% - 16px); } .topbar { + grid-template-columns: minmax(0, 1fr); 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 { overflow: hidden; text-overflow: ellipsis; } .primaryButton { + width: 100%; 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 { diff --git a/crates/ec-node/tests/e2e_remote_website_watch_existing.rs b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs index 48c9528..9c1a0ba 100644 --- a/crates/ec-node/tests/e2e_remote_website_watch_existing.rs +++ b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs @@ -171,7 +171,7 @@ fn e2e_remote_website_watch_existing_stream_id() -> anyhow::Result<()> { let site_url = std::env::var("EVERY_CHANNEL_SITE_URL") .unwrap_or_else(|_| "https://every.channel/".to_string()); 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") { Ok(v) if !v.trim().is_empty() => v, _ => return Ok(()), // skip diff --git a/evolution/proposals/ECP-0123-instant-station-guide-and-player-warmup.md b/evolution/proposals/ECP-0123-instant-station-guide-and-player-warmup.md new file mode 100644 index 0000000..672ec3b --- /dev/null +++ b/evolution/proposals/ECP-0123-instant-station-guide-and-player-warmup.md @@ -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. diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix index e2b167e..d944bf8 100644 --- a/nix/modules/ec-node.nix +++ b/nix/modules/ec-node.nix @@ -48,7 +48,7 @@ in relayUrl = lib.mkOption { 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."; }; diff --git a/nix/modules/ec-publisher-guest.nix b/nix/modules/ec-publisher-guest.nix index 28e08f9..3dac84d 100644 --- a/nix/modules/ec-publisher-guest.nix +++ b/nix/modules/ec-publisher-guest.nix @@ -4,7 +4,7 @@ networking.hostName = lib.mkForce "ec-publisher"; 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; hdhomerun.autoDiscover = lib.mkDefault true;