feat(web): subtitle lane under the signal chain

This commit is contained in:
Miguel Palhas
2026-08-25 06:24:32 +01:00
parent 8c6d4ca577
commit a836967e32
4 changed files with 80 additions and 1 deletions
+2
View File
@@ -446,6 +446,8 @@
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p> <p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
</article> </article>
</section> </section>
<section class="chain-lane" id="chain-subs" aria-label="subtitle lane" hidden></section>
</section> </section>
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots"> <section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
+16
View File
@@ -9,12 +9,28 @@ export interface Check {
detail?: string; detail?: string;
} }
/** One enabled subtitle provider (#200). */
export interface ProviderCheck {
id: string;
status: CheckStatus;
detail?: string;
}
/** The subtitle lane: providers in use, the selected engine, the binaries. */
export interface SubtitleHealth {
providers: ProviderCheck[];
translation?: Check | null;
alass: Check;
ffmpeg: Check;
}
export interface HealthReport { export interface HealthReport {
status: "ok" | "degraded"; status: "ok" | "degraded";
version: string; version: string;
prowlarr: Check; prowlarr: Check;
transmission: Check; transmission: Check;
tmdb: Check; tmdb: Check;
subtitles: SubtitleHealth;
} }
export type Probe = export type Probe =
+51 -1
View File
@@ -1,4 +1,4 @@
import { type CheckStatus, type Probe, probeHealth } from "./health"; import { type Check, type CheckStatus, type Probe, type SubtitleHealth, probeHealth } from "./health";
import { import {
fetchLibrary, fetchLibrary,
type LibrarySeries, type LibrarySeries,
@@ -171,6 +171,52 @@ function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, de
refs.detail.textContent = detail ?? refs.defaultDetail; refs.detail.textContent = detail ?? refs.defaultDetail;
} }
/** One lamp of the subtitle lane (#200): same module shape as the chain,
* rendered per poll — which providers are in use is a settings row, not
* markup. */
function subtitleModule(name: string, role: string, check: Check): HTMLElement {
const article = document.createElement("article");
article.className = "module";
const head = document.createElement("header");
head.className = "module-head";
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = check.status;
const title = document.createElement("h2");
title.className = "module-name";
title.textContent = name;
const status = document.createElement("span");
status.className = "module-status readout";
status.dataset.tone = TONE_BY_STATUS[check.status];
status.textContent = check.status;
head.append(lamp, title, status);
const roleLine = document.createElement("p");
roleLine.className = "module-role";
roleLine.textContent = role;
const detail = document.createElement("p");
detail.className = "module-detail readout";
detail.textContent = check.detail ?? "";
article.append(head, roleLine, detail);
return article;
}
function renderSubtitles(subLane: HTMLElement, subs: SubtitleHealth) {
const entries: { name: string; role: string; check: Check }[] = subs.providers.map(
(provider) => ({ name: provider.id, role: "subtitles in", check: provider }),
);
if (subs.translation) {
entries.push({ name: "translate", role: "engine out", check: subs.translation });
}
entries.push({ name: "alass", role: "timing sync", check: subs.alass });
entries.push({ name: "ffmpeg", role: "track extraction", check: subs.ffmpeg });
subLane.replaceChildren(...entries.map((entry) => subtitleModule(entry.name, entry.role, entry.check)));
subLane.hidden = false;
}
function must<T extends Element>(selector: string): T { function must<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector); const element = document.querySelector<T>(selector);
if (!element) { if (!element) {
@@ -181,6 +227,7 @@ function must<T extends Element>(selector: string): T {
function main() { function main() {
const chain = must<HTMLElement>(".chain"); const chain = must<HTMLElement>(".chain");
const subLane = must<HTMLElement>("#chain-subs");
const masterLamp = must<HTMLElement>("#master-lamp"); const masterLamp = must<HTMLElement>("#master-lamp");
const version = must<HTMLElement>("#version"); const version = must<HTMLElement>("#version");
@@ -235,6 +282,8 @@ function main() {
for (const name of ["tmdb", "prowlarr", "master"] as const) { for (const name of ["tmdb", "prowlarr", "master"] as const) {
setTrace(name, null); setTrace(name, null);
} }
subLane.hidden = true;
subLane.replaceChildren();
return; return;
} }
@@ -259,6 +308,7 @@ function main() {
setTrace(name, TONE_BY_STATUS[check.status]); setTrace(name, TONE_BY_STATUS[check.status]);
} }
} }
renderSubtitles(subLane, report.subtitles);
} }
let inFlight = false; let inFlight = false;
+11
View File
@@ -315,6 +315,17 @@ body {
/* ---- modules --------------------------------------------------------- */ /* ---- modules --------------------------------------------------------- */
/* The subtitle lane (#200): one lamp per upstream actually in use, rendered
by the health poll under the main chain. Same module vocabulary, no bus —
these feed the reconcile loop, not each other. */
.chain-lane {
margin-top: var(--space-6);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--space-4);
align-items: stretch;
}
.module { .module {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);