Redesign hosted web as broadcast console
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions

This commit is contained in:
every.channel 2026-05-04 01:06:41 -07:00
parent bd5d9857ed
commit 5d6f77f868
No known key found for this signature in database
5 changed files with 469 additions and 300 deletions

View file

@ -110,19 +110,19 @@ function bindPlayerSignals(watch, name, extraCleanup) {
if (status === "loading") {
clearOfflineTimer();
sawLoading = true;
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
setHint(`Tuning ${name}...`, "ok");
return;
}
if (status === "live") {
clearOfflineTimer();
setHint(`Live: subscribed to ${name}`, "ok");
setHint(`On air: ${name}`, "ok");
return;
}
if (status === "offline" && sawLoading) {
clearOfflineTimer();
// Avoid flashing a false negative during short reconnect windows.
offlineTimer = window.setTimeout(() => {
setHint(`Connection interrupted, retrying: ${name}`, "warn");
setHint(`Signal faded, retrying ${name}...`, "warn");
offlineTimer = null;
}, 8000);
}
@ -133,7 +133,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) {
setHint(`Live: subscribed to ${name}`, "ok");
setHint(`On air: ${name}`, "ok");
}
});
@ -197,7 +197,7 @@ function mountPlayer(relayUrl, name) {
forceAudioOn();
watch.backend?.paused?.set?.(true);
watch.backend?.paused?.set?.(false);
setHint(`Live: subscribed to ${name} (audio unlocked)`, "ok");
setHint(`On air: ${name} (sound on)`, "ok");
};
document.addEventListener("pointerdown", unlockAudio, { once: true });
canvas.addEventListener("pointerdown", unlockAudio, { once: true });
@ -205,7 +205,7 @@ function mountPlayer(relayUrl, name) {
document.removeEventListener("pointerdown", unlockAudio);
canvas.removeEventListener("pointerdown", unlockAudio);
});
setHint(`Live: subscribed to ${name} (tap player to unmute)`, "warn");
setHint(`On air: ${name} (tap picture for sound)`, "warn");
bindPlayerSignals(watch, name, cleanup);
}
@ -285,7 +285,7 @@ async function mountArchivePlayer(name) {
video.playsInline = true;
video.addEventListener("error", () => {
setHint(
"Archive replay bytes are not browser-HLS compatible yet (legacy container); timeline is available, live path is unaffected.",
"This replay is not ready for browser playback yet. Live tuning is unaffected.",
"warn",
);
});
@ -312,7 +312,7 @@ async function mountArchivePlayer(name) {
hls.on(HlsCtor.Events.ERROR, (_event, data) => {
if (!data?.fatal) return;
if (data.type === HlsCtor.ErrorTypes.NETWORK_ERROR) {
setHint("Archive network hiccup, retrying…", "warn");
setHint("Replay hiccup, retrying...", "warn");
try {
hls.startLoad();
} catch (_) {
@ -321,7 +321,7 @@ async function mountArchivePlayer(name) {
return;
}
if (data.type === HlsCtor.ErrorTypes.MEDIA_ERROR) {
setHint("Archive media hiccup, recovering…", "warn");
setHint("Replay picture hiccup, recovering...", "warn");
try {
hls.recoverMediaError();
} catch (_) {
@ -329,7 +329,7 @@ async function mountArchivePlayer(name) {
}
return;
}
setHint(`Archive playback error: ${data.type || "fatal"}`, "warn");
setHint(`Replay unavailable: ${data.type || "fatal"}`, "warn");
});
hls.loadSource(archiveUrl);
hls.attachMedia(video);
@ -392,10 +392,10 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
const mount = $("liveList");
mount.textContent = "";
if (!entries.length) {
setListHint("No public streams announced yet.", "");
setListHint("No stations are on air yet.", "");
return;
}
setListHint(`${entries.length} live`, "ok");
setListHint(`${entries.length} on air`, "ok");
for (const entry of entries) {
const row = document.createElement("div");
@ -409,7 +409,9 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
const meta = document.createElement("div");
meta.className = "liveMeta";
meta.textContent = `${entry.broadcast_name || ""} @ ${entry.relay_url || DEFAULT_RELAY_URL}`;
meta.textContent = entry.broadcast_name
? `Channel key: ${entry.broadcast_name}`
: "Ready to tune";
info.appendChild(meta);
const actions = document.createElement("div");
@ -417,14 +419,14 @@ function renderLiveList(entries, onWatchLive, onWatchArchive) {
const watchBtn = document.createElement("button");
watchBtn.className = "btn secondary";
watchBtn.textContent = "Live";
watchBtn.textContent = "Watch";
watchBtn.addEventListener("click", () => {
onWatchLive(entry);
});
const archiveBtn = document.createElement("button");
archiveBtn.className = "btn secondary";
archiveBtn.textContent = "Archive";
archiveBtn.textContent = "DVR";
archiveBtn.addEventListener("click", () => {
onWatchArchive(entry);
});
@ -480,18 +482,18 @@ function main() {
updateSharePreview();
if (!name) {
setHint("Enter a broadcast name to watch.", "warn");
setHint("Pick a station or enter a channel key.", "warn");
return;
}
if (mode === "archive") {
writeParams(relayUrl, name, mode);
setHint(`Loading archive DVR: ${name}`, "ok");
setHint(`Loading replay: ${name}`, "ok");
try {
await mountArchivePlayer(name);
} catch (e) {
setHint(
`Archive playback unavailable: ${String(e)}. Ensure /api/archive is configured.`,
`Replay unavailable: ${String(e)}.`,
"warn",
);
}
@ -500,7 +502,7 @@ function main() {
if (!hasWebTransport()) {
setHint(
"WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
"This browser cannot tune the live player yet. Try Chrome or Edge.",
"warn",
);
return;
@ -510,14 +512,14 @@ function main() {
await ensureMoqWatchElement();
} catch (e) {
setHint(
`Failed to load MoQ web player dependency: ${String(e)}. Disable script blockers for esm.sh/jsdelivr/unpkg and retry.`,
`The player did not load: ${String(e)}. Try again after allowing the page scripts.`,
"warn",
);
return;
}
writeParams(relayUrl, name, mode);
setHint(`Connecting to relay and subscribing: ${name}`, "ok");
setHint(`Tuning ${name}...`, "ok");
mountPlayer(relayUrl, name);
}
@ -537,7 +539,7 @@ function main() {
const name = normalizeName(nameInput.value);
const mode = archiveModeInput.checked ? "archive" : "live";
if (!name) {
setHint("Enter a broadcast name first.", "warn");
setHint("Pick a station first.", "warn");
return;
}
const link = currentShareLink(relayUrl, name, mode);
@ -552,7 +554,7 @@ function main() {
});
async function refreshLiveList() {
setListHint("Loading live streams...", "");
setListHint("Scanning stations...", "");
try {
const entries = await fetchLiveList();
renderLiveList(
@ -574,7 +576,7 @@ function main() {
);
} catch (e) {
$("liveList").textContent = "";
setListHint(`Live list error: ${String(e)}`, "warn");
setListHint("Station guide is unavailable right now.", "warn");
}
}
refreshListBtn.addEventListener("click", () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View file

@ -10,70 +10,72 @@
/>
<link data-trunk rel="css" href="style.css" />
<link data-trunk rel="copy-file" href="app.js" />
<link data-trunk rel="copy-dir" href="assets" />
</head>
<body>
<main class="shell">
<header class="top">
<div class="brand">
<div class="brand-title">every.channel</div>
<div class="brand-subtitle">Watch live streams over WebTransport.</div>
</div>
<div class="badge" title="MoQ over WebTransport">
WebTransport
</div>
</header>
<section class="panel">
<div class="panel-title">Watch</div>
<div class="row">
<label class="field">
<div class="label">Relay URL</div>
<input id="relayUrl" class="input" type="text" spellcheck="false" />
</label>
<label class="field">
<div class="label">Broadcast name</div>
<input id="broadcastName" class="input" type="text" spellcheck="false" />
</label>
<label class="field checkField">
<div class="label">Mode</div>
<div class="checkRow">
<input id="archiveMode" type="checkbox" />
<span>Archive DVR</span>
</div>
</label>
<button id="watchBtn" class="btn">Watch</button>
</div>
<div class="hint" id="hint"></div>
<div class="row share">
<button id="copyLinkBtn" class="btn secondary">Copy link</button>
<div class="shareLink" id="shareLink" aria-live="polite"></div>
</div>
</section>
<section class="panel">
<div class="panelHead">
<div class="panel-title">Live Now</div>
<button id="refreshListBtn" class="btn secondary">Refresh</button>
</div>
<div class="hint" id="listHint"></div>
<div id="liveList" class="liveList"></div>
</section>
<section class="player">
<div class="tv">
<div class="tv-glow"></div>
<div class="tv-frame">
<div id="playerMount" class="mount"></div>
<div class="tv-scanlines" aria-hidden="true"></div>
<section class="studio">
<header class="masthead">
<div>
<div class="brand-title">every.channel</div>
<div class="brand-subtitle">Live television, tuned from real signals.</div>
</div>
</div>
<div class="badge">Local broadcast</div>
</header>
<section class="watchDeck">
<section class="player" aria-label="Live player">
<div class="tv">
<div class="tv-frame">
<div id="playerMount" class="mount"></div>
<div class="tv-scanlines" aria-hidden="true"></div>
</div>
</div>
<div class="hint" id="hint"></div>
</section>
<aside class="console" aria-label="Channel console">
<div class="consoleHead">
<div>
<div class="panel-title">On Air</div>
<div class="consoleCopy">Pick a station and the picture starts here.</div>
</div>
<button id="refreshListBtn" class="btn secondary">Scan</button>
</div>
<div class="hint listHint" id="listHint"></div>
<div id="liveList" class="liveList"></div>
<details class="signalDrawer">
<summary>Signal source</summary>
<div class="signalGrid">
<label class="field">
<div class="label">Signal address</div>
<input id="relayUrl" class="input" type="text" spellcheck="false" />
</label>
<label class="field">
<div class="label">Channel key</div>
<input id="broadcastName" class="input" type="text" spellcheck="false" />
</label>
<label class="field checkField">
<div class="checkRow">
<input id="archiveMode" type="checkbox" />
<span>DVR replay</span>
</div>
</label>
<button id="watchBtn" class="btn">Tune in</button>
</div>
<div class="share">
<button id="copyLinkBtn" class="btn secondary">Copy link</button>
<div class="shareLink" id="shareLink" aria-live="polite"></div>
</div>
</details>
</aside>
</section>
</section>
<footer class="foot">
<div class="foot-left">AGPLv3</div>
<div class="foot-right">
For Safari: WebTransport is behind a Developer/Advanced feature flag and may be incomplete.
</div>
<div>AGPLv3</div>
<div>Safari support is still experimental.</div>
</footer>
</main>

View file

@ -1,17 +1,18 @@
:root {
--bg0: #0b0f14;
--bg1: #0f1720;
--panel: rgba(255, 255, 255, 0.06);
--panel2: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.65);
--faint: rgba(255, 255, 255, 0.45);
--line: rgba(255, 255, 255, 0.12);
--accent: #ffb86c;
--accent2: #6ee7ff;
--ok: #7cf7a2;
--warn: #ffd36e;
--shadow: rgba(0, 0, 0, 0.55);
--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);
}
* {
@ -20,190 +21,215 @@
html,
body {
height: 100%;
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
color: var(--ink);
background: var(--bg);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -2;
background-image: url("assets/sony-master-control.png");
background-position: center top;
background-size: cover;
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(1200px 700px at 15% 10%, rgba(255, 184, 108, 0.16), transparent 55%),
radial-gradient(900px 600px at 85% 20%, rgba(110, 231, 255, 0.12), transparent 60%),
radial-gradient(900px 900px at 50% 120%, rgba(255, 255, 255, 0.08), transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
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));
}
.shell {
max-width: 1100px;
width: min(1220px, calc(100% - 32px));
min-height: 100vh;
margin: 0 auto;
padding: 28px 18px 22px;
display: grid;
gap: 16px;
grid-template-rows: 1fr auto;
gap: 14px;
padding: 22px 0 14px;
}
.top {
.studio {
min-height: calc(100vh - 58px);
display: grid;
grid-template-rows: auto 1fr;
gap: 18px;
}
.masthead {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
box-shadow: 0 18px 38px var(--shadow);
gap: 18px;
padding-top: 4px;
}
.brand-title {
font-size: 34px;
line-height: 1;
font-weight: 800;
letter-spacing: -0.02em;
font-size: 22px;
line-height: 1.05;
letter-spacing: 0;
text-shadow: 0 2px 22px rgba(0, 0, 0, 0.85);
}
.brand-subtitle {
margin-top: 4px;
margin-top: 8px;
max-width: 420px;
color: var(--muted);
font-size: 13px;
font-size: 15px;
line-height: 1.35;
text-shadow: 0 1px 18px rgba(0, 0, 0, 0.9);
}
.badge {
padding: 8px 10px;
font-size: 12px;
color: rgba(0, 0, 0, 0.78);
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 999px;
font-weight: 700;
}
.panel {
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 18px 38px var(--shadow);
}
.panel-title {
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 12px;
}
.panelHead {
display: flex;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
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);
font-size: 12px;
font-weight: 800;
}
.panelHead .panel-title {
margin-bottom: 0;
}
.row {
.watchDeck {
align-self: end;
display: grid;
grid-template-columns: 1.15fr 1fr auto auto;
gap: 10px;
grid-template-columns: minmax(0, 1fr) 356px;
gap: 18px;
align-items: end;
}
.field .label {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
.player {
min-width: 0;
}
.input {
.tv {
padding: 12px;
border-radius: 8px;
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);
box-shadow:
0 30px 70px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.tv-frame {
position: relative;
padding: 10px;
border-radius: 6px;
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);
}
.mount {
width: 100%;
padding: 11px 12px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 4px;
background: #000;
border: 1px solid rgba(255, 248, 235, 0.20);
}
.canvas,
.archiveVideo {
width: 100%;
height: 100%;
display: block;
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;
background:
linear-gradient(180deg, rgba(255, 248, 235, 0.08), rgba(255, 248, 235, 0.03)),
var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(0, 0, 0, 0.28);
color: var(--text);
outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
box-shadow: 0 30px 68px var(--shadow);
backdrop-filter: blur(18px);
}
.input:focus {
border-color: rgba(255, 184, 108, 0.55);
box-shadow: 0 0 0 3px rgba(255, 184, 108, 0.16);
}
.checkField .checkRow {
.consoleHead {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(0, 0, 0, 0.28);
color: var(--text);
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.checkField input[type="checkbox"] {
width: 16px;
height: 16px;
.panel-title {
margin: 0;
color: var(--amber);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.btn {
padding: 11px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 184, 108, 0.35);
background: linear-gradient(180deg, rgba(255, 184, 108, 0.22), rgba(255, 184, 108, 0.12));
color: var(--text);
font-weight: 700;
cursor: pointer;
transition: transform 80ms ease, background 120ms ease, border-color 120ms ease;
}
.btn:hover {
transform: translateY(-1px);
border-color: rgba(255, 184, 108, 0.55);
background: linear-gradient(180deg, rgba(255, 184, 108, 0.28), rgba(255, 184, 108, 0.14));
}
.btn:active {
transform: translateY(0);
}
.btn.secondary {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
.consoleCopy {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.hint {
min-height: 20px;
margin-top: 10px;
font-size: 13px;
color: var(--muted);
min-height: 18px;
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"] {
color: var(--ok);
color: var(--green);
}
.hint[data-kind="warn"] {
color: var(--warn);
color: #ffd17a;
}
.share {
margin-top: 8px;
grid-template-columns: auto 1fr;
align-items: center;
}
.shareLink {
color: var(--faint);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 4px;
.listHint {
margin-top: 12px;
}
.liveList {
@ -215,22 +241,44 @@ body {
.liveItem {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
grid-template-columns: 10px minmax(0, 1fr) auto;
align-items: center;
padding: 9px 10px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
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);
}
.liveItem::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;
}
.liveTitle {
font-size: 14px;
font-weight: 600;
font-weight: 800;
line-height: 1.2;
}
.liveMeta {
margin-top: 2px;
margin-top: 3px;
color: var(--faint);
font-size: 12px;
overflow: hidden;
@ -243,100 +291,186 @@ body {
gap: 6px;
}
.player {
padding: 0;
.signalDrawer {
margin-top: 14px;
border-top: 1px solid rgba(255, 248, 235, 0.12);
padding-top: 12px;
}
.tv {
position: relative;
border-radius: 24px;
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
radial-gradient(900px 500px at 10% 10%, rgba(255, 184, 108, 0.10), transparent 55%),
radial-gradient(700px 500px at 90% 10%, rgba(110, 231, 255, 0.08), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.10));
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.62);
.signalDrawer summary {
cursor: pointer;
color: var(--muted);
font-size: 13px;
font-weight: 800;
list-style-position: outside;
}
.tv-glow {
position: absolute;
inset: -40px -20px -20px -20px;
background:
radial-gradient(350px 220px at 18% 22%, rgba(255, 184, 108, 0.18), transparent 70%),
radial-gradient(320px 220px at 82% 26%, rgba(110, 231, 255, 0.14), transparent 72%);
filter: blur(18px);
opacity: 0.95;
pointer-events: none;
.signalGrid {
display: grid;
gap: 10px;
margin-top: 12px;
}
.tv-frame {
position: relative;
border-radius: 18px;
padding: 12px;
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.22));
border: 1px solid rgba(255, 255, 255, 0.12);
.field {
display: grid;
gap: 6px;
}
.mount {
aspect-ratio: 16 / 9;
.label {
color: var(--faint);
font-size: 12px;
}
.input {
width: 100%;
border-radius: 12px;
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;
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 {
min-height: 42px;
display: flex;
align-items: center;
gap: 8px;
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;
}
.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;
}
.shareLink {
min-width: 0;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.canvas {
width: 100%;
height: 100%;
display: block;
}
.archiveVideo {
width: 100%;
height: 100%;
display: block;
background: #000;
}
.tv-scanlines {
position: absolute;
inset: 12px;
border-radius: 12px;
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03) 1px,
rgba(0, 0, 0, 0.00) 3px,
rgba(0, 0, 0, 0.00) 6px
);
mix-blend-mode: overlay;
pointer-events: none;
opacity: 0.25;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--faint);
font-size: 12px;
}
.foot {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 2px 0;
color: var(--faint);
color: rgba(255, 248, 235, 0.48);
font-size: 12px;
}
@media (max-width: 900px) {
.row {
grid-template-columns: 1fr;
align-items: stretch;
@media (max-width: 980px) {
body::before {
background-position: center top;
}
.share {
grid-template-columns: 1fr;
.shell {
width: min(100% - 24px, 760px);
padding-top: 16px;
}
.watchDeck {
grid-template-columns: 1fr;
align-self: start;
}
.console {
order: -1;
}
}
@media (max-width: 620px) {
.masthead {
align-items: flex-start;
}
.brand-title {
font-size: 28px;
}
.badge {
display: none;
}
.tv,
.console {
padding: 10px;
}
.liveItem {
grid-template-columns: minmax(0, 1fr);
}
.liveItem::before {
display: none;
}
.liveItem > div:first-child,
.liveActions {
grid-column: auto;
}
.liveActions,
.share {
grid-template-columns: 1fr 1fr;
width: 100%;
}
.liveActions {
display: grid;
}
.foot {
flex-direction: column;
}

View file

@ -0,0 +1,31 @@
# ECP-0118: Broadcast Console Web Presentation
Status: Draft
## Problem / context
The hosted web page presented the live player as a technical protocol console: relay URL, broadcast
name, WebTransport badge, and diagnostic wording were all first-screen material. That is useful for
operators, but it makes the public experience feel like infrastructure instead of television.
## Decision
Use a generated 1980s broadcast master-control image as the page backdrop and redesign the first
screen around a tuned television surface plus a compact station console. Keep the live player and
public stream list visible, but move relay URL, channel key, DVR mode, and share URL into a
collapsed "Signal source" drawer.
## Consequences
- The first impression is a premium broadcast console instead of a protocol dashboard.
- Station discovery remains one click from the first screen.
- Operator controls are still present for debugging and direct links, but no longer dominate the
public page.
- The generated image is unbranded and contains no readable labels, so it evokes the Sony-era
hardware direction without making a visible trademark claim.
## Rollout / teardown
Deploy the web asset bundle and validate the hosted page visually in the browser plus the existing
watch E2E. Teardown is removing the generated asset, restoring the prior panel layout, and promoting
the signal fields back to the first-screen controls.