-
+
+
+
+
+
+
-
-
-
-
-
+
+
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;