diff --git a/apps/web/app.js b/apps/web/app.js index 17f2502..4b2ac36 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -39,6 +39,41 @@ 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 currentShareLink(relayUrl, name, mode) { const u = new URL(window.location.href); u.pathname = "/watch"; @@ -59,6 +94,11 @@ function setHint(text, kind) { 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 || ""; @@ -70,6 +110,84 @@ function setListHint(text, kind) { 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(); @@ -110,11 +228,13 @@ function bindPlayerSignals(watch, name, extraCleanup) { if (status === "loading") { clearOfflineTimer(); sawLoading = true; + setNowTitle(name); setHint(`Tuning ${name}...`, "ok"); return; } if (status === "live") { clearOfflineTimer(); + setNowTitle(name); setHint(`On air: ${name}`, "ok"); return; } @@ -133,6 +253,7 @@ function bindPlayerSignals(watch, name, extraCleanup) { 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"); } }); @@ -157,6 +278,7 @@ function mountPlayer(relayUrl, name) { const mount = $("playerMount"); mount.textContent = ""; + setNowTitle(name); const watch = document.createElement("moq-watch"); watch.setAttribute("url", relayUrl); @@ -276,6 +398,7 @@ async function mountArchivePlayer(name) { const mount = $("playerMount"); mount.textContent = ""; + setNowTitle(name); const video = document.createElement("video"); video.className = "archiveVideo"; @@ -393,48 +516,60 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) { 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 entry of entries) { + 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 = entry.title || entry.stream_id || entry.broadcast_name || "Live stream"; + title.textContent = titleText; info.appendChild(title); const meta = document.createElement("div"); meta.className = "liveMeta"; - meta.textContent = entry.broadcast_name - ? `Channel key: ${entry.broadcast_name}` - : "Ready to tune"; + 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 watchBtn = document.createElement("button"); - watchBtn.className = "btn secondary"; - watchBtn.textContent = "Watch"; - watchBtn.addEventListener("click", () => { - onWatchLive(entry); - }); - const archiveBtn = document.createElement("button"); - archiveBtn.className = "btn secondary"; + archiveBtn.className = "secondaryButton"; + archiveBtn.type = "button"; archiveBtn.textContent = "DVR"; archiveBtn.addEventListener("click", () => { onWatchArchive(entry); }); - actions.appendChild(watchBtn); actions.appendChild(archiveBtn); - row.appendChild(info); + tuneButton.appendChild(info); + tuneButton.appendChild(watchBadge); + row.appendChild(tuneButton); row.appendChild(actions); mount.appendChild(row); } @@ -457,11 +592,30 @@ function main() { 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); @@ -472,6 +626,20 @@ function main() { 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() { @@ -483,11 +651,14 @@ function main() { 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); @@ -502,7 +673,7 @@ function main() { if (!hasWebTransport()) { setHint( - "This browser cannot tune the live player yet. Try Chrome or Edge.", + "Live playback needs WebTransport here. Try DVR replay or another browser.", "warn", ); return; @@ -519,6 +690,7 @@ function main() { } writeParams(relayUrl, name, mode); + setNowTitle(name); setHint(`Tuning ${name}...`, "ok"); mountPlayer(relayUrl, name); } @@ -526,6 +698,53 @@ function main() { 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(); @@ -560,23 +779,27 @@ function main() { renderLiveList( entries, (entry) => { + const relay = entryPrimaryRelay(entry); archiveModeInput.checked = false; - relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL); - nameInput.value = normalizeName(entry.broadcast_name || ""); + relayInput.value = relay.relay_url; + nameInput.value = relay.broadcast_name; updateSharePreview(); void start(); }, (entry) => { + const relay = entryPrimaryRelay(entry); archiveModeInput.checked = true; - relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL); - nameInput.value = normalizeName(entry.broadcast_name || ""); + relayInput.value = relay.relay_url; + nameInput.value = relay.broadcast_name; updateSharePreview(); void start(); }, ); } catch (e) { $("liveList").textContent = ""; - setListHint("Station guide is unavailable right now.", "warn"); + setListHint("Station guide is unavailable.", "warn"); + renderMultiview([], () => {}); + renderManualTunePrompt("Have a channel key?"); } } refreshListBtn.addEventListener("click", () => { @@ -584,6 +807,8 @@ function main() { }); updateSharePreview(); + updateModeButtons(); + renderDefaultMultiview(); void refreshLiveList(); window.setInterval(() => { void refreshLiveList(); diff --git a/apps/web/assets/live-preview-mosaic.png b/apps/web/assets/live-preview-mosaic.png new file mode 100644 index 0000000..ba8b35c Binary files /dev/null and b/apps/web/assets/live-preview-mosaic.png differ diff --git a/apps/web/assets/sony-master-control.png b/apps/web/assets/sony-master-control.png deleted file mode 100644 index 0a9d3aa..0000000 Binary files a/apps/web/assets/sony-master-control.png and /dev/null differ diff --git a/apps/web/assets/tv-control-material.png b/apps/web/assets/tv-control-material.png new file mode 100644 index 0000000..c7314ee Binary files /dev/null and b/apps/web/assets/tv-control-material.png differ diff --git a/apps/web/index.html b/apps/web/index.html index 0faba97..1477e4c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,79 +4,113 @@ every.channel - + -
-
-
-
-
every.channel
-
Live television, tuned from real signals.
+
+
+ + + every.channel + + + + + + +
+ +
+
+
+
+
+
+ + Pick a channel +
+
+
+
+
+
+
Now
+
Ready
+
+
+
+ +
+ + + +
+ +
+ + + + +
+
-
Local broadcast
-
-
-
-
-
-
- -
+
+
+ + + +
-
- -
-
-
-
AGPLv3
-
Safari support is still experimental.
-
+ +
diff --git a/apps/web/style.css b/apps/web/style.css index 325abbb..76817f8 100644 --- a/apps/web/style.css +++ b/apps/web/style.css @@ -1,18 +1,24 @@ :root { - --bg: #050505; - --ink: rgba(255, 248, 235, 0.96); - --muted: rgba(255, 248, 235, 0.70); - --faint: rgba(255, 248, 235, 0.48); - --line: rgba(255, 248, 235, 0.18); - --panel: rgba(10, 9, 7, 0.74); - --panel-strong: rgba(6, 6, 5, 0.88); - --button: rgba(255, 248, 235, 0.10); - --button-hover: rgba(255, 248, 235, 0.16); - --amber: #f1a94f; - --green: #79e28b; - --red: #f06958; - --blue: #7eb6d8; - --shadow: rgba(0, 0, 0, 0.68); + color-scheme: light; + --room: #d9d0bf; + --room-bright: #efe8dc; + --ink: #17130e; + --muted: rgba(23, 19, 14, 0.62); + --soft: rgba(23, 19, 14, 0.10); + --line: rgba(57, 42, 27, 0.20); + --cream: #f6efe1; + --glass: rgba(255, 252, 242, 0.70); + --glass-strong: rgba(255, 250, 237, 0.86); + --metal: #bfc1ba; + --metal-dark: #6f726c; + --wood: #8b5d36; + --wood-dark: #4f311f; + --blue: #3c7b8f; + --red: #be513c; + --green: #477b52; + --shadow: rgba(57, 37, 18, 0.22); + --hard-shadow: rgba(26, 18, 10, 0.36); + --radius: 10px; } * { @@ -27,7 +33,10 @@ body { body { margin: 0; color: var(--ink); - background: var(--bg); + background: + linear-gradient(180deg, rgba(255, 251, 240, 0.80), rgba(217, 208, 191, 0.74)), + radial-gradient(circle at 18% 10%, #fff9eb 0, rgba(255, 249, 235, 0) 34%), + linear-gradient(115deg, #e6dccb 0%, #cfc4b0 58%, #b9b0a3 100%); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } @@ -36,9 +45,11 @@ body::before { position: fixed; inset: 0; z-index: -2; - background-image: url("assets/sony-master-control.png"); - background-position: center top; + background-image: url("assets/tv-control-material.png"); background-size: cover; + background-position: center bottom; + opacity: 0.12; + filter: saturate(0.8) brightness(1.55); } body::after { @@ -47,106 +58,278 @@ body::after { inset: 0; z-index: -1; background: - linear-gradient(90deg, rgba(0, 0, 0, 0.58), rgba(0, 0, 0, 0.12) 48%, rgba(0, 0, 0, 0.66)), - linear-gradient(180deg, rgba(0, 0, 0, 0.24), rgba(0, 0, 0, 0.78) 82%, rgba(0, 0, 0, 0.95)); + linear-gradient(90deg, rgba(255, 252, 240, 0.82), rgba(255, 252, 240, 0.30) 52%, rgba(96, 76, 55, 0.18)), + repeating-linear-gradient(90deg, rgba(68, 46, 28, 0.035) 0 1px, transparent 1px 10px); + pointer-events: none; } -.shell { - width: min(1220px, calc(100% - 32px)); +button, +input { + font: inherit; +} + +button { + color: inherit; +} + +.watchSurface { + width: min(1440px, calc(100% - 28px)); min-height: 100vh; margin: 0 auto; display: grid; - grid-template-rows: 1fr auto; - gap: 14px; - padding: 22px 0 14px; -} - -.studio { - min-height: calc(100vh - 58px); - display: grid; grid-template-rows: auto 1fr; gap: 18px; + padding: 18px 0 22px; } -.masthead { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - padding-top: 4px; +.topbar { + min-height: 64px; + display: grid; + grid-template-columns: auto minmax(180px, 420px) auto auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.52); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(237, 228, 211, 0.62)), + rgba(252, 246, 232, 0.72); + box-shadow: + 0 18px 44px rgba(95, 73, 44, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.80); + backdrop-filter: blur(18px); } -.brand-title { - font-size: 34px; - line-height: 1; - font-weight: 800; - letter-spacing: 0; - text-shadow: 0 2px 22px rgba(0, 0, 0, 0.85); -} - -.brand-subtitle { - margin-top: 8px; - max-width: 420px; - color: var(--muted); - font-size: 15px; - line-height: 1.35; - text-shadow: 0 1px 18px rgba(0, 0, 0, 0.9); -} - -.badge { - min-height: 34px; +.brand { display: inline-flex; align-items: center; - padding: 0 12px; - border: 1px solid rgba(121, 226, 139, 0.48); - border-radius: 6px; - color: rgba(236, 255, 232, 0.95); - background: rgba(17, 44, 22, 0.58); + gap: 10px; + min-width: 0; + color: var(--ink); + text-decoration: none; + font-size: 24px; + font-weight: 900; + letter-spacing: 0; +} + +.brandLens { + width: 32px; + height: 32px; + border-radius: 50%; + background: + radial-gradient(circle at 38% 34%, #ffffff 0 10%, #9ec1ca 12% 26%, #1f3f49 48%, #0c171b 72%), + #244752; + border: 2px solid rgba(255, 255, 255, 0.72); + box-shadow: + inset 0 -3px 8px rgba(0, 0, 0, 0.42), + 0 6px 14px rgba(25, 46, 52, 0.22); +} + +.quickTune { + min-height: 44px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 5px 8px 5px 14px; + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(226, 220, 207, 0.58)), + rgba(255, 250, 239, 0.88); + border: 1px solid rgba(65, 48, 30, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); +} + +.quickTuneLabel { + color: var(--muted); font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.quickTuneInput { + width: 100%; + min-width: 0; + border: 0; + outline: none; + color: var(--ink); + background: transparent; + font-size: 16px; font-weight: 800; } -.watchDeck { - align-self: end; - display: grid; - grid-template-columns: minmax(0, 1fr) 356px; - gap: 18px; - align-items: end; +.primaryButton, +.secondaryButton, +.roundButton, +.modeButton, +.toolButton, +.transportButton, +.liveTune, +.miniScreen { + cursor: pointer; } -.player { - min-width: 0; -} - -.tv { - padding: 12px; - border-radius: 8px; +.primaryButton, +.secondaryButton, +.toolButton, +.modeButton { + min-height: 42px; + border-radius: 9px; + border: 1px solid rgba(74, 51, 30, 0.22); background: - linear-gradient(180deg, rgba(24, 23, 20, 0.96), rgba(7, 7, 6, 0.98)), - #090908; - border: 1px solid rgba(255, 248, 235, 0.16); + linear-gradient(180deg, #fffaf0, #d6c6ad), + var(--cream); box-shadow: - 0 30px 70px var(--shadow), - inset 0 1px 0 rgba(255, 255, 255, 0.12); + inset 0 1px 0 rgba(255, 255, 255, 0.86), + inset 0 -2px 0 rgba(84, 57, 32, 0.12), + 0 8px 18px rgba(71, 48, 27, 0.14); + color: #27180e; + font-size: 13px; + font-weight: 900; } -.tv-frame { - position: relative; - padding: 10px; - border-radius: 6px; +.primaryButton { + min-width: 94px; + color: #fffaf0; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.35)), - #050505; - border: 1px solid rgba(255, 248, 235, 0.12); + linear-gradient(180deg, #4e8796, #275769), + var(--blue); + border-color: rgba(20, 68, 82, 0.45); +} + +.roundButton, +.transportButton { + width: 44px; + height: 44px; + border: 1px solid rgba(74, 51, 30, 0.24); + border-radius: 50%; + background: + linear-gradient(180deg, #fff8ea, #c7beb0), + var(--metal); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.78), + inset 0 -3px 0 rgba(71, 48, 27, 0.12), + 0 8px 20px rgba(55, 39, 22, 0.14); + display: grid; + place-items: center; +} + +.scanIcon { + width: 18px; + height: 18px; + border: 3px solid #325765; + border-radius: 50%; + border-left-color: transparent; + display: block; +} + +.stageGrid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(310px, 382px); + gap: 18px; + align-items: start; +} + +.playerStack { + min-width: 0; + display: grid; + gap: 14px; +} + +.television { + overflow: hidden; + border-radius: 18px; + background: + linear-gradient(90deg, rgba(104, 66, 35, 0.94), rgba(151, 103, 59, 0.92) 12%, rgba(236, 221, 196, 0.72) 13%, rgba(185, 154, 112, 0.84) 88%, rgba(78, 49, 29, 0.94)), + linear-gradient(180deg, #9b6a3d, #4f311f); + border: 1px solid rgba(60, 39, 23, 0.38); + box-shadow: + 0 28px 70px var(--shadow), + inset 0 1px 0 rgba(255, 242, 220, 0.48); } .mount { - width: 100%; + position: relative; + width: calc(100% - 28px); aspect-ratio: 16 / 9; + margin: 14px; overflow: hidden; - border-radius: 4px; - background: #000; - border: 1px solid rgba(255, 248, 235, 0.20); + border-radius: 12px; + background: #050606; + border: 8px solid #20201d; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + inset 0 20px 60px rgba(255, 255, 255, 0.06), + 0 12px 26px rgba(44, 25, 13, 0.34); +} + +.mount > moq-watch { + width: 100%; + height: 100%; + display: block; +} + +.idleScreen { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: rgba(255, 250, 235, 0.94); + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.50)), + url("assets/live-preview-mosaic.png"); + background-size: cover; + background-position: center; +} + +.idleScreen::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.10), transparent 32%, rgba(255, 255, 255, 0.08) 62%, transparent), + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 4px); + pointer-events: none; +} + +.idlePlate { + position: relative; + z-index: 1; + display: grid; + place-items: center; + gap: 10px; + padding: 16px 20px; + border-radius: 12px; + background: rgba(16, 20, 21, 0.54); + border: 1px solid rgba(255, 255, 255, 0.24); + backdrop-filter: blur(12px); +} + +.playGlyph, +.backGlyph, +.forwardGlyph { + display: block; + width: 0; + height: 0; +} + +.playGlyph { + border-top: 15px solid transparent; + border-bottom: 15px solid transparent; + border-left: 23px solid #fff6e4; +} + +.backGlyph { + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-right: 16px solid #5d4c3c; +} + +.forwardGlyph { + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 16px solid #5d4c3c; } .canvas, @@ -157,168 +340,303 @@ body::after { background: #000; } -.tv-scanlines { - position: absolute; - inset: 10px; - border-radius: 4px; - background: repeating-linear-gradient( - to bottom, - rgba(255, 255, 255, 0.025), - rgba(255, 255, 255, 0.025) 1px, - rgba(0, 0, 0, 0.00) 3px, - rgba(0, 0, 0, 0.00) 6px - ); - mix-blend-mode: overlay; - pointer-events: none; - opacity: 0.35; -} - -.console { - padding: 14px; - border-radius: 8px; +.controlShelf { + display: grid; + gap: 12px; + padding: 13px 16px 16px; background: - linear-gradient(180deg, rgba(255, 248, 235, 0.08), rgba(255, 248, 235, 0.03)), - var(--panel); - border: 1px solid var(--line); - box-shadow: 0 30px 68px var(--shadow); - backdrop-filter: blur(18px); + linear-gradient(180deg, rgba(248, 241, 226, 0.84), rgba(206, 189, 166, 0.72)), + rgba(237, 225, 206, 0.82); + border-top: 1px solid rgba(255, 255, 255, 0.40); } -.consoleHead { +.nowLine { display: flex; - align-items: flex-start; + align-items: end; justify-content: space-between; gap: 12px; } -.panel-title { - margin: 0; - color: var(--amber); - font-size: 12px; - font-weight: 800; - letter-spacing: 0; +.kicker { + color: rgba(77, 55, 36, 0.64); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.06em; text-transform: uppercase; } -.consoleCopy { - margin-top: 4px; +.nowTitle { + margin-top: 2px; + font-size: 22px; + line-height: 1.05; + font-weight: 950; +} + +.hint, +.listHint { color: var(--muted); font-size: 13px; - line-height: 1.35; + font-weight: 750; } -.hint { - min-height: 20px; - margin-top: 10px; - color: var(--muted); - font-size: 13px; - line-height: 1.35; -} - -.player > .hint { - margin-left: 2px; - text-shadow: 0 1px 14px rgba(0, 0, 0, 0.9); -} - -.hint[data-kind="ok"] { +.hint[data-kind="ok"], +.listHint[data-kind="ok"] { color: var(--green); } -.hint[data-kind="warn"] { - color: #ffd17a; +.hint[data-kind="warn"], +.listHint[data-kind="warn"] { + color: #855524; } -.listHint { - margin-top: 12px; +.scrubDeck { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.scrubberWrap { + position: relative; + min-height: 42px; + display: grid; + align-items: center; +} + +.scrubber { + width: 100%; + accent-color: var(--blue); +} + +.chapterMarks { + position: absolute; + inset: auto 10px 4px; + height: 6px; + border-radius: 999px; + pointer-events: none; + background: + linear-gradient(90deg, transparent 0 12%, rgba(39, 32, 24, 0.34) 12% 13%, transparent 13% 31%, rgba(39, 32, 24, 0.34) 31% 32%, transparent 32% 52%, rgba(39, 32, 24, 0.34) 52% 53%, transparent 53% 77%, rgba(39, 32, 24, 0.34) 77% 78%, transparent 78%); +} + +.toolRow { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.toolButton.pressed, +.modeButton.pressed { + color: #fff9e8; + background: + linear-gradient(180deg, #6e93a0, #315c6c), + var(--blue); +} + +.multiview { + border-radius: 16px; + padding: 11px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.70), rgba(230, 219, 201, 0.62)), + rgba(247, 239, 223, 0.78); + border: 1px solid rgba(255, 255, 255, 0.54); + box-shadow: 0 18px 42px rgba(79, 60, 37, 0.14); +} + +.multiViewGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.miniScreen { + min-height: 84px; + display: flex; + align-items: end; + justify-content: start; + padding: 9px; + border-radius: 10px; + border: 2px solid rgba(34, 28, 22, 0.20); + color: #fffaf0; + font-size: 13px; + font-weight: 900; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7); + background-image: + linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.54)), + url("assets/live-preview-mosaic.png"); + background-size: 200% 200%; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28); +} + +.tileA { background-position: 0 0; } +.tileB { background-position: 100% 0; } +.tileC { background-position: 0 100%; } +.tileD { background-position: 100% 100%; } + +.channelRail { + min-width: 0; + display: grid; + gap: 12px; + padding: 14px; + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 252, 243, 0.84), rgba(222, 207, 183, 0.70)), + rgba(250, 242, 226, 0.84); + border: 1px solid rgba(255, 255, 255, 0.62); + box-shadow: + 0 24px 62px rgba(73, 52, 30, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.78); + backdrop-filter: blur(18px); +} + +.railHead { + display: flex; + align-items: start; + justify-content: space-between; + gap: 10px; +} + +.modeSwitch { + display: inline-grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 4px; + border-radius: 12px; + background: rgba(98, 73, 44, 0.10); +} + +.modeButton { + min-height: 34px; + padding: 0 10px; + box-shadow: none; + font-size: 12px; } .liveList { display: grid; - grid-template-columns: 1fr; - gap: 8px; - margin-top: 10px; + gap: 10px; } .liveItem { display: grid; - grid-template-columns: 10px minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; + min-height: 74px; +} + +.liveTune { + min-width: 0; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 10px; - min-height: 58px; - padding: 10px; - border: 1px solid rgba(255, 248, 235, 0.14); - border-radius: 6px; - background: rgba(0, 0, 0, 0.34); + width: 100%; + padding: 8px; + border: 1px solid rgba(61, 45, 28, 0.18); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(231, 219, 199, 0.70)), + rgba(255, 250, 239, 0.82); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.82), + 0 10px 22px rgba(75, 52, 28, 0.10); + text-align: left; } -.liveItem::before { +.liveTune::before { content: ""; - grid-column: 1; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--red); - box-shadow: 0 0 12px rgba(240, 105, 88, 0.85); - align-self: center; -} - -.liveItem > div:first-child { - grid-column: 2; - min-width: 0; -} - -.liveActions { - grid-column: 3; + width: 54px; + height: 54px; + border-radius: 9px; + background-image: + linear-gradient(180deg, rgba(0, 0, 0, 0.03), rgba(0, 0, 0, 0.36)), + url("assets/live-preview-mosaic.png"); + background-size: 210% 210%; + background-position: var(--thumb-x, 0) var(--thumb-y, 0); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24); } .liveTitle { + color: #20140b; font-size: 14px; - font-weight: 800; - line-height: 1.2; + line-height: 1.18; + font-weight: 950; } .liveMeta { - margin-top: 3px; - color: var(--faint); - font-size: 12px; + margin-top: 4px; overflow: hidden; + color: rgba(49, 35, 22, 0.60); + font-size: 12px; + font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } .liveActions { - display: flex; - gap: 6px; + display: grid; +} + +.watchBadge, +.emptyState .secondaryButton { + align-self: center; +} + +.watchBadge { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border-radius: 999px; + color: #fffaf0; + background: linear-gradient(180deg, #588c9a, #2d5b69); + font-size: 12px; + font-weight: 950; +} + +.emptyState { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 74px; + padding: 10px; + border: 1px dashed rgba(68, 49, 29, 0.30); + border-radius: 12px; + background: rgba(255, 252, 242, 0.58); +} + +.emptyTitle { + color: rgba(42, 30, 19, 0.72); + font-size: 14px; + font-weight: 950; } .signalDrawer { - margin-top: 14px; - border-top: 1px solid rgba(255, 248, 235, 0.12); - padding-top: 12px; + border-top: 1px solid rgba(67, 47, 29, 0.16); + padding-top: 10px; } .signalDrawer summary { cursor: pointer; - color: var(--muted); + color: rgba(38, 28, 18, 0.70); font-size: 13px; - font-weight: 800; - list-style-position: outside; + font-weight: 950; } .signalGrid { display: grid; gap: 10px; - margin-top: 12px; + margin-top: 10px; } .field { display: grid; gap: 6px; -} - -.label { - color: var(--faint); + color: rgba(38, 28, 18, 0.68); font-size: 12px; + font-weight: 850; } .input { @@ -326,152 +644,187 @@ body::after { min-height: 42px; padding: 10px 11px; color: var(--ink); - background: rgba(0, 0, 0, 0.42); - border: 1px solid rgba(255, 248, 235, 0.17); - border-radius: 6px; + border: 1px solid rgba(66, 47, 29, 0.22); + border-radius: 9px; + background: rgba(255, 252, 243, 0.72); outline: none; } -.input:focus { - border-color: rgba(241, 169, 79, 0.72); - box-shadow: 0 0 0 3px rgba(241, 169, 79, 0.16); -} - -.checkRow { +.checkField { min-height: 42px; display: flex; align-items: center; - gap: 8px; + gap: 10px; padding: 0 10px; - color: var(--muted); - background: rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 248, 235, 0.14); - border-radius: 6px; + border: 1px solid rgba(66, 47, 29, 0.18); + border-radius: 9px; + background: rgba(255, 252, 243, 0.62); + font-size: 13px; + font-weight: 850; } -.checkRow input { - width: 16px; - height: 16px; -} - -.btn { - min-height: 38px; - padding: 9px 12px; - border-radius: 6px; - border: 1px solid rgba(241, 169, 79, 0.48); - color: var(--ink); - background: - linear-gradient(180deg, rgba(241, 169, 79, 0.28), rgba(141, 82, 26, 0.26)), - var(--button); - font-weight: 800; - cursor: pointer; - transition: transform 80ms ease, border-color 120ms ease, background 120ms ease; -} - -.btn:hover { - transform: translateY(-1px); - border-color: rgba(241, 169, 79, 0.72); - background: - linear-gradient(180deg, rgba(241, 169, 79, 0.34), rgba(141, 82, 26, 0.30)), - var(--button-hover); -} - -.btn:active { - transform: translateY(0); -} - -.btn.secondary { - border-color: rgba(255, 248, 235, 0.18); - background: rgba(255, 248, 235, 0.09); -} - -.share { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 8px; - align-items: center; - margin-top: 10px; +.checkField input { + width: 18px; + height: 18px; + accent-color: var(--blue); } .shareLink { min-width: 0; overflow: hidden; + color: rgba(35, 26, 18, 0.56); + font-size: 12px; + font-weight: 700; text-overflow: ellipsis; white-space: nowrap; - color: var(--faint); - font-size: 12px; } -.foot { - display: flex; - justify-content: space-between; - gap: 12px; - color: rgba(255, 248, 235, 0.48); - font-size: 12px; +.primaryButton:hover, +.secondaryButton:hover, +.toolButton:hover, +.modeButton:hover, +.roundButton:hover, +.transportButton:hover, +.liveTune:hover, +.miniScreen:hover { + transform: translateY(-1px); } -@media (max-width: 980px) { - body::before { - background-position: center top; - } +.primaryButton:active, +.secondaryButton:active, +.toolButton:active, +.modeButton:active, +.roundButton:active, +.transportButton:active, +.liveTune:active, +.miniScreen:active { + transform: translateY(0); +} - .shell { - width: min(100% - 24px, 760px); - padding-top: 16px; - } +.primaryButton:focus-visible, +.secondaryButton:focus-visible, +.toolButton:focus-visible, +.modeButton:focus-visible, +.roundButton:focus-visible, +.transportButton:focus-visible, +.liveTune:focus-visible, +.miniScreen:focus-visible, +.signalDrawer summary:focus-visible, +.quickTuneInput:focus-visible, +.input:focus-visible, +.scrubber:focus-visible { + outline: 3px solid rgba(60, 123, 143, 0.48); + outline-offset: 3px; +} - .watchDeck { +@media (max-width: 1080px) { + .stageGrid { grid-template-columns: 1fr; - align-self: start; } - .console { - order: -1; + .channelRail { + order: 2; } } -@media (max-width: 620px) { - .masthead { - align-items: flex-start; +@media (max-width: 720px) { + .watchSurface { + width: min(100% - 20px, 560px); + gap: 12px; + padding-top: 10px; } - .brand-title { - font-size: 28px; + .topbar { + grid-template-columns: minmax(0, 1fr) auto auto; } - .badge { - display: none; + .brand { + font-size: 21px; } - .tv, - .console { - padding: 10px; + .quickTune { + grid-column: 1 / -1; + grid-row: 2; } - .liveItem { + .mount { + width: calc(100% - 18px); + margin: 9px; + border-width: 6px; + } + + .controlShelf { + padding: 11px; + } + + .nowLine { + align-items: start; + flex-direction: column; + } + + .nowTitle { + font-size: 19px; + } + + .scrubDeck { grid-template-columns: minmax(0, 1fr); } - .liveItem::before { + .transportButton { display: none; } - .liveItem > div:first-child, - .liveActions { - grid-column: auto; + .toolRow, + .multiViewGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .liveActions, - .share { - grid-template-columns: 1fr 1fr; - width: 100%; - } - - .liveActions { - display: grid; - } - - .foot { - flex-direction: column; + .miniScreen { + min-height: 92px; + } +} + +@media (max-width: 440px) { + .watchSurface { + width: min(100% - 16px, 420px); + } + + .topbar { + padding: 8px; + } + + .brand span:last-child { + overflow: hidden; + text-overflow: ellipsis; + } + + .primaryButton { + min-width: 78px; + } + + .channelRail { + padding: 10px; + } + + .railHead { + align-items: stretch; + flex-direction: column; + } + + .liveItem, + .emptyState { + grid-template-columns: 1fr; + } + + .liveActions { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; } } diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index bcace45..2e8de92 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -5116,20 +5116,33 @@ async fn control_resolve(args: ControlResolveArgs) -> Result<()> { )) } -fn select_relay_transport_for_web( - transports: &[StreamTransportDescriptor], -) -> Option<(String, String, String)> { - for transport in transports { - if let StreamTransportDescriptor::RelayMoq { - url, - broadcast_name, - track_name, - } = transport - { - return Some((url.clone(), broadcast_name.clone(), track_name.clone())); - } - } - None +fn relay_transports_for_web(transports: &[StreamTransportDescriptor]) -> Vec { + transports + .iter() + .filter_map(|transport| { + if let StreamTransportDescriptor::RelayMoq { + url, + broadcast_name, + track_name, + } = transport + { + Some(WebStreamRelay { + relay_url: url.clone(), + broadcast_name: broadcast_name.clone(), + track_name: track_name.clone(), + }) + } else { + None + } + }) + .collect() +} + +#[derive(Debug, Clone, serde::Serialize)] +struct WebStreamRelay { + relay_url: String, + broadcast_name: String, + track_name: String, } #[derive(Debug, serde::Serialize)] @@ -5139,6 +5152,7 @@ struct WebStreamUpsertReq<'a> { relay_url: &'a str, broadcast_name: &'a str, track_name: &'a str, + relays: &'a [WebStreamRelay], expires_ms: u64, } @@ -5207,11 +5221,11 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> { } } - let Some((relay_url, broadcast_name, track_name)) = - select_relay_transport_for_web(&announcement.transports) - else { + let relays = relay_transports_for_web(&announcement.transports); + if relays.is_empty() { continue; - }; + } + let primary_relay = &relays[0]; if last_upserted_unix_ms .get(&stream_id) @@ -5224,9 +5238,10 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> { let payload = WebStreamUpsertReq { stream_id: &stream_id, title: &announcement.stream.title, - relay_url: &relay_url, - broadcast_name: &broadcast_name, - track_name: &track_name, + relay_url: &primary_relay.relay_url, + broadcast_name: &primary_relay.broadcast_name, + track_name: &primary_relay.track_name, + relays: &relays, expires_ms: now_unix_ms().saturating_add(ttl_ms), }; @@ -5253,8 +5268,9 @@ async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> { last_upserted_unix_ms.insert(stream_id.clone(), announcement.updated_unix_ms); tracing::info!( stream = %stream_id, - relay = %relay_url, - broadcast = %broadcast_name, + relay = %primary_relay.relay_url, + broadcast = %primary_relay.broadcast_name, + relay_count = relays.len(), "web stream upserted" ); if args.once { @@ -6853,6 +6869,7 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::inherit()); + cmd.kill_on_drop(true); tracing::info!(input=%args.input, "spawning ffmpeg"); let mut child = cmd.spawn().context("failed to spawn ffmpeg")?; @@ -6990,6 +7007,7 @@ async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> { cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::inherit()); + cmd.kill_on_drop(true); tracing::info!( source_url = %args.source_url, diff --git a/deploy/cloudflare-worker/containers/ec-api/src/main.rs b/deploy/cloudflare-worker/containers/ec-api/src/main.rs index f3d2313..49ff080 100644 --- a/deploy/cloudflare-worker/containers/ec-api/src/main.rs +++ b/deploy/cloudflare-worker/containers/ec-api/src/main.rs @@ -38,6 +38,31 @@ struct DirectoryList { entries: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize)] +struct PublicStreamRelay { + relay_url: String, + broadcast_name: String, + track_name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct PublicStreamEntry { + stream_id: String, + title: String, + relay_url: String, + broadcast_name: String, + track_name: String, + relays: Vec, + updated_ms: u64, + expires_ms: u64, +} + +#[derive(Clone, Debug, Serialize)] +struct PublicStreamList { + now_ms: u64, + entries: Vec, +} + #[derive(Clone, Debug, Serialize)] struct HealthResp { ok: bool, @@ -69,10 +94,29 @@ struct AnswerGetReq { stream_id: String, } +#[derive(Clone, Debug, Deserialize)] +struct StreamUpsertReq { + stream_id: String, + title: String, + relay_url: Option, + broadcast_name: Option, + track_name: Option, + relays: Option>, + expires_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamUpsertResp { + ok: bool, + ttl_ms: u64, + entry: PublicStreamEntry, +} + #[derive(Default)] struct State { entries: HashMap, answers: HashMap, + streams: HashMap, } fn now_ms() -> u64 { @@ -100,6 +144,7 @@ fn json_headers() -> HeaderMap { fn prune_state(state: &mut State, now: u64) { state.entries.retain(|_, v| v.expires_ms > now); state.answers.retain(|_, v| v.expires_ms > now); + state.streams.retain(|_, v| v.expires_ms > now); // Cap growth defensively. This is not spam-resistant; it's a bootstrap rendezvous. if state.entries.len() > 200 { @@ -114,6 +159,12 @@ fn prune_state(state: &mut State, now: u64) { items.truncate(500); state.answers = items.into_iter().map(|e| (e.stream_id.clone(), e)).collect(); } + if state.streams.len() > 1000 { + let mut items = state.streams.values().cloned().collect::>(); + items.sort_by_key(|e| std::cmp::Reverse(e.updated_ms)); + items.truncate(1000); + state.streams = items.into_iter().map(|e| (e.stream_id.clone(), e)).collect(); + } } async fn health() -> impl IntoResponse { @@ -129,6 +180,118 @@ async fn directory(state: axum::extract::State>>) -> impl Into (json_headers(), Json(DirectoryList { now_ms: now, entries })) } +fn push_stream_relay(relays: &mut Vec, relay: PublicStreamRelay) { + if relay.relay_url.is_empty() || relay.broadcast_name.is_empty() { + return; + } + if relays.iter().any(|existing| { + existing.relay_url == relay.relay_url + && existing.broadcast_name == relay.broadcast_name + && existing.track_name == relay.track_name + }) { + return; + } + if relays.len() < 16 { + relays.push(relay); + } +} + +fn normalize_stream_relays(body: &StreamUpsertReq) -> Vec { + let mut relays = Vec::new(); + if let (Some(relay_url), Some(broadcast_name)) = (&body.relay_url, &body.broadcast_name) { + push_stream_relay( + &mut relays, + PublicStreamRelay { + relay_url: clamp_str(relay_url.clone(), 512), + broadcast_name: clamp_str(broadcast_name.clone(), 256), + track_name: clamp_str( + body.track_name + .clone() + .unwrap_or_else(|| "video0.m4s".to_string()), + 256, + ), + }, + ); + } + if let Some(body_relays) = &body.relays { + for relay in body_relays { + push_stream_relay( + &mut relays, + PublicStreamRelay { + relay_url: clamp_str(relay.relay_url.clone(), 512), + broadcast_name: clamp_str(relay.broadcast_name.clone(), 256), + track_name: clamp_str( + if relay.track_name.is_empty() { + "video0.m4s".to_string() + } else { + relay.track_name.clone() + }, + 256, + ), + }, + ); + } + } + relays +} + +async fn public_streams(state: axum::extract::State>>) -> impl IntoResponse { + let now = now_ms(); + let mut guard = state.write().await; + prune_state(&mut guard, now); + let mut entries = guard.streams.values().cloned().collect::>(); + entries.sort_by_key(|e| std::cmp::Reverse(e.updated_ms)); + (json_headers(), Json(PublicStreamList { now_ms: now, entries })) +} + +async fn stream_upsert( + state: axum::extract::State>>, + Json(body): Json, +) -> impl IntoResponse { + let now = now_ms(); + let relays = normalize_stream_relays(&body); + + if body.stream_id.is_empty() + || body.title.is_empty() + || body.relay_url.as_deref().unwrap_or_default().is_empty() + || body.broadcast_name.as_deref().unwrap_or_default().is_empty() + { + let resp = + serde_json::json!({ "error": "missing stream_id/title/relay_url/broadcast_name" }); + return (StatusCode::BAD_REQUEST, json_headers(), Json(resp)).into_response(); + } + + let requested_expires = body.expires_ms.unwrap_or(now + 20_000); + let requested_ttl = requested_expires.saturating_sub(now); + let ttl_ms = requested_ttl.clamp(5_000, 60_000); + let primary = relays[0].clone(); + + let entry = PublicStreamEntry { + stream_id: clamp_str(body.stream_id, 256), + title: clamp_str(body.title, 128), + relay_url: primary.relay_url, + broadcast_name: primary.broadcast_name, + track_name: primary.track_name, + relays, + updated_ms: now, + expires_ms: now + ttl_ms, + }; + + let mut guard = state.write().await; + prune_state(&mut guard, now); + guard.streams.insert(entry.stream_id.clone(), entry.clone()); + + ( + json_headers(), + Json(StreamUpsertResp { + ok: true, + ttl_ms, + entry, + }), + ) + .into_response() +} + async fn announce( state: axum::extract::State>>, Json(body): Json, @@ -233,6 +396,8 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/api/health", get(health)) .route("/api/directory", get(directory)) + .route("/api/public-streams", get(public_streams)) + .route("/api/stream-upsert", post(stream_upsert)) .route("/api/announce", post(announce)) .route("/api/answer", post(post_answer).get(get_answer)) .with_state(state) diff --git a/deploy/cloudflare-worker/src/index.ts b/deploy/cloudflare-worker/src/index.ts index 725a652..bd30e3f 100644 --- a/deploy/cloudflare-worker/src/index.ts +++ b/deploy/cloudflare-worker/src/index.ts @@ -212,10 +212,17 @@ type PublicStreamEntry = { relay_url: string; broadcast_name: string; track_name: string; + relays: PublicStreamRelay[]; updated_ms: number; expires_ms: number; }; +type PublicStreamRelay = { + relay_url: string; + broadcast_name: string; + track_name: string; +}; + type PublicStreamList = { now_ms: number; entries: PublicStreamEntry[]; @@ -323,9 +330,41 @@ type StreamUpsertReq = { relay_url: string; broadcast_name: string; track_name?: string; + relays?: PublicStreamRelay[]; expires_ms?: number; }; +function publicStreamRelayKey(relay: PublicStreamRelay): string { + return `${relay.relay_url}\n${relay.broadcast_name}\n${relay.track_name}`; +} + +function normalizePublicStreamRelays(body: StreamUpsertReq): PublicStreamRelay[] { + const relays: PublicStreamRelay[] = []; + + const addRelay = (candidate?: Partial) => { + if (!candidate?.relay_url || !candidate.broadcast_name) return; + const relay: PublicStreamRelay = { + relay_url: clampStr(candidate.relay_url, 512), + broadcast_name: clampStr(candidate.broadcast_name, 256), + track_name: clampStr(candidate.track_name || "video0.m4s", 256), + }; + if (!relays.some((existing) => publicStreamRelayKey(existing) === publicStreamRelayKey(relay))) { + relays.push(relay); + } + }; + + addRelay({ + relay_url: body.relay_url, + broadcast_name: body.broadcast_name, + track_name: body.track_name, + }); + if (Array.isArray(body.relays)) { + for (const relay of body.relays) addRelay(relay); + } + + return relays.slice(0, 16); +} + function authBearerToken(request: Request): string | null { const auth = request.headers.get("authorization"); if (!auth) return null; @@ -392,17 +431,20 @@ export class EcApiContainer implements DurableObject { { status: 400 }, ); } + const relays = normalizePublicStreamRelays(body); const requestedExpires = body.expires_ms ?? now + 20_000; const requestedTtl = Math.max(0, requestedExpires - now); const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl)); + const primaryRelay = relays[0]; const entry: PublicStreamEntry = { stream_id: clampStr(body.stream_id, 256), title: clampStr(body.title, 128), - relay_url: clampStr(body.relay_url, 512), - broadcast_name: clampStr(body.broadcast_name, 256), - track_name: clampStr(body.track_name || "video0.m4s", 256), + relay_url: primaryRelay.relay_url, + broadcast_name: primaryRelay.broadcast_name, + track_name: primaryRelay.track_name, + relays, updated_ms: now, expires_ms: now + ttlMs, }; diff --git a/evolution/proposals/ECP-0120-material-watch-surface.md b/evolution/proposals/ECP-0120-material-watch-surface.md new file mode 100644 index 0000000..3be9cdd --- /dev/null +++ b/evolution/proposals/ECP-0120-material-watch-surface.md @@ -0,0 +1,44 @@ +# ECP-0120: Material Watch Surface + +Status: Draft + +## Problem statement + +The web watch page should feel like a real television/player surface, not a protocol console, +marketing page, or dark decorative shell. It also needs room for modern watch behaviors: channel +switching, multiview, scrubbing, clipping, direct tuning, and DVR mode. + +## Constraints + +- Preserve existing live WebTransport playback, DVR replay, public station scanning, share links, + and manual signal tuning. +- Keep the first screen content/player-first and operable with obvious controls. +- Use YouTube as interaction grammar, not as visual branding. +- Keep the palette bright enough for daytime/living-room use. +- Do not rely on browser-specific copy. + +## Alternatives considered + +- Continue the dark broadcast-console skin from ECP-0118. Rejected because it reads too much like + a control-room hero image and too little like a daily-use player. +- Copy YouTube visually. Rejected because every.channel should inherit watch-page mechanics without + borrowing YouTube brand language. +- Use pure flat CSS. Rejected because the desired direction is a tactile television object with + material depth. + +## Decision + +Rebuild the static web watch surface around a large video player, right-side channel rail, lower +scrubber, clip controls, and multiview tray. Use generated bitmap material assets for live-preview +tiles and subtle hardware texture, then layer a lighter skeuomorphic system in CSS: warm wood, +brushed metal, smoked acrylic, cream buttons, and broadcast-monitor geometry. + +The design lineage is intentionally loose: Braun/Ulm clarity for simple control layout, Sony-style +broadcast monitor seriousness for the player frame, and Bang & Olufsen-style furniture warmth for +the room/object feel. These references are constraints, not objects to copy. + +## Rollout / teardown plan + +Ship as a static web UI change and validate with desktop/mobile screenshots plus the web build. +Teardown is reverting the HTML/CSS shell to the previous watch page while leaving playback, +directory, and share-link code paths intact. diff --git a/evolution/proposals/ECP-0121-greedy-multi-relay-streams.md b/evolution/proposals/ECP-0121-greedy-multi-relay-streams.md new file mode 100644 index 0000000..32e9d0c --- /dev/null +++ b/evolution/proposals/ECP-0121-greedy-multi-relay-streams.md @@ -0,0 +1,50 @@ +# ECP-0121: Greedy Multi-Relay Streams + +Status: Draft + +## Problem statement + +Live streams should be publishable to more than one relay at once so viewers can pick the fastest +path and regional relays can mirror each other. The current public directory shape only exposes one +`relay_url` per stream, which encourages duplicate publishers when we want redundancy. For OTA +sources, duplicate publishers are dangerous because each one opens another tuner read. + +## Constraints + +- Preserve the existing `relay_url`, `broadcast_name`, and `track_name` fields for deployed web, + archive, and manual watch links. +- Keep those primary fields as the compatibility contract; `relays[]` is additive and optional for + consumers. +- Do not duplicate HDHomeRun source reads to get multi-region relay presence. +- Let the public directory advertise multiple relays before every consumer implements racing. +- Keep rollback simple: clients can ignore `relays[]` and keep using the primary legacy fields. + +## Decision + +Add an ordered `relays[]` candidate list to public stream entries and stream upserts. Stream upserts +continue to require the primary legacy fields; the first relay is mirrored into those fields and +remains the primary/default path. `control-bridge-web` now forwards all relay transports already +present in a control announcement instead of flattening to the first relay only. Current consumers +can keep reading the legacy fields until they explicitly add relay racing. + +The intended next step is a single ingest/fanout publisher: read the source once, encode/fragment +once, publish the same stream objects to LAX and NYC relay sessions, and optionally let relays mirror +to each other. Consumers can then race candidates greedily by availability/latency without causing +extra source reads. + +## Alternatives considered + +- Start one publisher per relay. Rejected because it duplicates source reads and can exhaust physical + tuners, which was the LA outage failure mode. +- Replace the legacy fields with `relays[]`. Rejected because deployed clients and archive workers + already depend on the single-relay shape. +- Accept `relays[]` without primary legacy fields. Rejected because that would make rollback depend + on every publisher being downgraded at the same time as the directory. +- Wait for full relay racing before changing the directory. Rejected because exposing the ordered + candidate set is a small compatible step that unblocks incremental consumers. + +## Rollout / teardown plan + +Deploy the compatible schema first. Then add publisher fanout and consumer relay racing behind +separate flags. Teardown is removing `relays[]` from upserts and consumers; legacy primary-field +behavior remains intact throughout. diff --git a/evolution/proposals/ECP-0122-publisher-source-locks-and-cgroup-cleanup.md b/evolution/proposals/ECP-0122-publisher-source-locks-and-cgroup-cleanup.md new file mode 100644 index 0000000..302e44d --- /dev/null +++ b/evolution/proposals/ECP-0122-publisher-source-locks-and-cgroup-cleanup.md @@ -0,0 +1,44 @@ +# ECP-0122: Publisher Source Locks And Cgroup Cleanup + +Status: Draft + +## Problem statement + +LA channels disappeared when stale proof/archive publisher helpers kept HDHomeRun tuner HTTP streams +open after the managed publishers restarted. The restarted publishers saw `503 Service Unavailable` +from the tuners, stopped refreshing the public stream directory, and the guide expired to empty. + +## Constraints + +- A publisher restart must not leave child media processes holding tuners. +- A duplicate publisher on the same node must not open the same physical source URL. +- Keep rollback simple and deployment-owned; no source-device firmware or manual tuner reset should be + required for normal recovery. + +## Decision + +The NixOS publisher wrapper now takes a non-blocking per-source lock under +`/run/every-channel/source-locks` before launching `ec-node`. If another managed publisher on the +same node is already reading that input URL, the duplicate launch logs and skips instead of opening a +second tuner stream. + +Publisher and archive worker services also set explicit `KillMode=control-group`, +`TimeoutStopSec=10s`, and `SendSIGKILL=true`, and archive auto-workers terminate tracked children on +shutdown before systemd's cgroup cleanup runs. The async `wt-publish` and `nbc-wt-publish` ffmpeg +children are marked kill-on-drop so cancelled Rust futures do not strand encoder children. + +## Alternatives considered + +- Rely on operator cleanup only. Rejected because the failure silently empties the public guide after + TTL expiry. +- Run duplicate publishers for redundancy. Rejected because OTA tuner capacity is the scarce resource; + redundancy should happen after one source read, via publisher fanout and relay mirroring. +- Add only systemd cgroup cleanup. Rejected because it does not prevent two managed units from + intentionally opening the same source at the same time. + +## Rollout / teardown plan + +Deploy the NixOS module update to every publisher node. Confirm no stale proof/archive helpers remain, +all managed publisher units are active, and `/api/public-streams` lists the expected channels. +Rollback is reverting this module change and redeploying; source locks are runtime files under `/run` +and disappear on reboot. diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix index 4828e46..e2b167e 100644 --- a/nix/modules/ec-node.nix +++ b/nix/modules/ec-node.nix @@ -452,6 +452,7 @@ in systemd.tmpfiles.rules = [ "d /run/every-channel 1777 root root - -" + "d /run/every-channel/source-locks 1777 root root - -" ] ++ lib.optionals cfg.nbc.enable [ "d /var/lib/every-channel 0750 every-channel every-channel - -" @@ -487,6 +488,7 @@ in pkgs.findutils pkgs.gawk pkgs.iproute2 + pkgs.util-linux cfg.package ] ++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ pkgs.mullvad-vpn ] @@ -580,8 +582,36 @@ in return "$status" } + run_source_command() { + local status source_lock_fd + status=0 + source_lock_fd="" + + if [[ -n "''${source_lock:-}" ]]; then + exec {source_lock_fd}>"$source_lock" + if ! flock -n "$source_lock_fd"; then + echo "ec-node: source already active on this node, skipping duplicate publisher: $source_id" >&2 + exec {source_lock_fd}>&- + return 0 + fi + fi + + set +e + "$@" + status=$? + set -e + + if [[ -n "$source_lock_fd" ]]; then + flock -u "$source_lock_fd" 2>/dev/null || true + exec {source_lock_fd}>&- + fi + return "$status" + } + nbc_url=${lib.escapeShellArg nbcUrlStr} input="" + source_id="" + source_lock="" if [[ -z "$nbc_url" ]]; then explicit_input=${lib.escapeShellArg explicitInputStr} if [[ -n "$explicit_input" ]]; then @@ -676,9 +706,11 @@ in host="''${hostport%%:*}" input="http://$host:5004/auto/v$ch" fi + source_id="$input" fi if [[ -n "$nbc_url" ]]; then + source_id="$nbc_url" cmd=( ${lib.escapeShellArg "${cfg.package}/bin/ec-node"} nbc-wt-publish @@ -715,6 +747,11 @@ in ''} ${extraArgsLine} + if [[ -n "$source_id" ]]; then + source_key="$(printf '%s' "$source_id" | tr -c 'A-Za-z0-9_.-' '_')" + source_lock="/run/every-channel/source-locks/$source_key.lock" + fi + # Keep the unit alive even if the relay is temporarily unreachable. # This avoids `switch-to-configuration test` failing due to a unit that exits # quickly during activation. @@ -726,9 +763,9 @@ in continue fi ''} - ${lib.optionalString (isNbc && cfg.nbc.isolateWithUserNetns) "run_in_user_netns || true"} + ${lib.optionalString (isNbc && cfg.nbc.isolateWithUserNetns) "run_source_command run_in_user_netns || true"} ${lib.optionalString (!isNbc || !cfg.nbc.isolateWithUserNetns) '' - "''${cmd[@]}" || true + run_source_command "''${cmd[@]}" || true ''} sleep 2 done @@ -763,6 +800,9 @@ in ExecStart = "${runner}/bin/${unit}"; Restart = "always"; RestartSec = 2; + KillMode = "control-group"; + TimeoutStopSec = "10s"; + SendSIGKILL = true; DynamicUser = !isNbc; User = lib.mkIf isNbc "every-channel"; @@ -949,14 +989,24 @@ in poll_secs="$(awk 'BEGIN { printf "%.3f", ${toString cfg.archive.pollIntervalMs} / 1000.0 }')" cleanup_children() { + pids=() for pid_file in "$pids_dir"/*.pid; do [[ -e "$pid_file" ]] || continue pid="$(cat "$pid_file" 2>/dev/null || true)" - if [[ -n "$pid" ]]; then - kill "$pid" 2>/dev/null || true + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + pids+=("$pid") fi rm -f "$pid_file" done + if [[ "''${#pids[@]}" -gt 0 ]]; then + kill -TERM "''${pids[@]}" 2>/dev/null || true + sleep 1 + for pid in "''${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + done + fi } trap cleanup_children INT TERM EXIT @@ -970,7 +1020,7 @@ in while IFS= read -r entry; do name="$(printf '%s\n' "$entry" | jq -r '.broadcast_name // empty')" - relay="$(printf '%s\n' "$entry" | jq -r '.relay_url // empty')" + relay="$(printf '%s\n' "$entry" | jq -r '(.relay_url // .relays[0].relay_url // empty)')" if [[ -z "$name" ]]; then continue fi @@ -1039,6 +1089,9 @@ in ExecStart = "${archiveRunner}/bin/${archiveUnit}"; Restart = "always"; RestartSec = 2; + KillMode = "control-group"; + TimeoutStopSec = "10s"; + SendSIGKILL = true; NoNewPrivileges = true; PrivateTmp = true;