fix(web): collapse subtitles into one row chip (#258)
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m26s
e2e / e2e (push) Failing after 3m58s

This commit was merged in pull request #258.
This commit is contained in:
2026-08-26 08:58:14 +01:00
parent 656ca6c259
commit bd5131f424
5 changed files with 377 additions and 76 deletions
+8
View File
@@ -30,6 +30,14 @@ const ICON_PATHS = {
],
warning: ["M8 1.8l6.2 11H1.8z", "M8 5.3v3.4M8 11.2h.01"],
check: ["M3.2 8.4l3 3 6.6-6.8"],
// the caption frame the rest of the world draws for subtitles: a screen
// with two lines of text sitting low in it
captions: [
"M2 3.4h12c.4 0 .7.3.7.7v7.8c0 .4-.3.7-.7.7H2c-.4 0-.7-.3-.7-.7V4.1c0-.4.3-.7.7-.7Z",
"M4.1 8.2h3.1",
"M8.8 8.2h3.1",
"M4.1 10.5h7.8",
],
chevronUp: ["M3 10.2l5-4.6 5 4.6"],
chevronDown: ["M3 5.8l5 4.6 5-4.6"],
} as const;
+172 -68
View File
@@ -124,6 +124,7 @@ import {
import { settingsMain } from "./settings";
import "./style.css";
import {
cachedSubtitleOptions,
deleteSubtitle,
type EpisodeSubtitleStatus,
formatCandidateFlags,
@@ -144,10 +145,13 @@ import {
searchSubtitles,
seriesSubtitleStatus,
subtitleChipLabel,
subtitleOptions,
subtitleRuleLabel,
subtitleSummary,
subtitleSummaryLabel,
summaryWidth,
translateSubtitle,
translationSources,
wantedSubtitles,
} from "./subtitles";
const POLL_MS = 15_000;
@@ -888,14 +892,20 @@ function countsChip(
/**
* One present-subtitle chip. Green like an eligible release (the want is
* met); a forced track never satisfies a want (§15) so it stays neutral
* instead. `data-mt` marks a machine translation with the same dashed
* border a waived release already carries — a translation is a relaxed
* case of "has a subtitle", not the clean one, and §15 wants it readable at
* a glance rather than by hovering for the origin word.
* instead, and so does a language nobody asked for — green here means the
* wanted set moved, not merely that a track exists. `data-mt` marks a
* machine translation with the same dashed border a waived release already
* carries — a translation is a relaxed case of "has a subtitle", not the
* clean one, and §15 wants it readable at a glance rather than by hovering
* for the origin word.
*/
function subtitleChip(subtitle: Subtitle, onDeleted: () => void): HTMLSpanElement {
function subtitleChip(
subtitle: Subtitle,
wanted: string[] | null,
onDeleted: () => void,
): HTMLSpanElement {
const chipEl = chip(subtitleChipLabel(subtitle), (span) => {
if (!subtitle.forced) {
if (!subtitle.forced && (wanted === null || wanted.includes(subtitle.language))) {
span.dataset.verdict = "eligible";
}
if (subtitle.origin === "translated") {
@@ -947,6 +957,28 @@ function subtitleDeleteButton(
return button;
}
/**
* §5.7 honesty: a waived import is never presented as a clean match. The
* rule that was relaxed is a sentence, not a word — "below the size floor"
* costs a row more width than the fact is worth — so the chip draws the
* warning glyph and keeps the sentence in its title and `aria-label`. That
* is §4.2's drawn-glyph exemption used a second time: the dashed amber and
* the triangle both survive the greyscale read the word rule protects.
*/
function waiverChip(waiver: unknown): HTMLSpanElement | null {
const label = waiverLabel(waiver);
if (label === null) {
return null;
}
const span = document.createElement("span");
span.className = "chip chip-glyph";
span.dataset.verdict = "waived";
span.title = label;
span.setAttribute("aria-label", label);
span.append(icon("warning"));
return span;
}
/** One missing-language chip: the same amber "wanted, not yet on disk" ramp §4.2 already uses. */
function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement {
return chip(missingChipLabel(missing), (span) => {
@@ -961,13 +993,15 @@ function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement {
/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */
/**
* The subtitle status row for one media file and the manual panel it opens.
* `update` repaints the chips after a manual fetch or translation changed
* what is on disk, without rebuilding the panel underneath — an operator
* mid-search does not lose the search.
* One media file's subtitle chip and the manual panel it opens. The chip
* rides in the file's own row (§9.6 keeps a file to one line), so the caller
* places it among that row's other chips rather than on a line of its own.
* `update` repaints after a manual fetch or translation changed what is on
* disk, without rebuilding the panel underneath — an operator mid-search
* does not lose the search.
*/
interface SubtitleSection {
line: HTMLElement;
chip: HTMLElement;
panel: HTMLElement;
update: (status: SubtitleStatus) => void;
}
@@ -1282,9 +1316,9 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
const mediaFileId = status.media_file_id;
let subtitles = status.subtitles;
let missing = status.missing;
/** Null until the settings request lands; see `wantedSubtitles`. */
let wanted: string[] | null = null;
const line = document.createElement("div");
line.className = "rel-line subtitle-line";
const panel = document.createElement("div");
panel.className = "subs-panel";
panel.id = `subs-panel-${mediaFileId}`;
@@ -1292,20 +1326,95 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "control control-quiet subs-toggle";
toggle.textContent = "subtitles";
toggle.className = "chip subs-chip";
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-controls", panel.id);
/* ---- what is on the file ---- */
const presentLane = document.createElement("section");
presentLane.className = "deck-group subs-lane";
const presentHead = document.createElement("header");
presentHead.className = "deck-head";
const presentLabel = document.createElement("h3");
presentLabel.className = "deck-label";
presentLabel.textContent = "on this file";
presentHead.append(presentLabel);
const presentChips = document.createElement("div");
presentChips.className = "subs-present";
presentLane.append(presentHead, presentChips);
/**
* The chip names the wanted languages and nothing else (§15: the wanted
* set is what the file is judged against), so the panel is where every
* track it elides — a stray language, a forced one — stays reachable,
* with the delete affordance issue #223 put on each.
*/
function paintChips() {
line.replaceChildren();
for (const subtitle of subtitles) {
line.append(subtitleChip(subtitle, refresh));
const summary = subtitleSummary({ media_file_id: mediaFileId, subtitles, missing }, wanted);
// the codes read as one string — the chip's own flex gap would otherwise
// open a space in front of every comma
const langs = document.createElement("span");
langs.className = "subs-chip-langs";
if (summary.languages.length === 0) {
const none = document.createElement("span");
none.className = "dim";
none.textContent = "none";
langs.append(none);
}
for (const [index, entry] of summary.languages.entries()) {
const language = document.createElement("span");
language.textContent = index === 0 ? entry.language : `,${entry.language}`;
if (entry.translated) {
// §15: a machine translation reads as machine-made without hovering
language.dataset.mt = "true";
}
langs.append(language);
}
if (summary.overflow > 0) {
const rest = document.createElement("span");
rest.textContent = `+${summary.overflow}`;
langs.append(rest);
}
// the run is as wide as the wanted set can ever need, so a row showing
// `en` ends where a row showing `pt-PT,en` ends and the chips after it
// stay in column
langs.style.width = `${summaryWidth(wanted)}ch`;
const mark = document.createElement("span");
mark.className = "subs-chip-mark";
if (summary.satisfied) {
// §4.2's one glyph exemption: everything wanted is on disk, drawn as a
// check so the state survives the greyscale read colour alone fails.
// The slot is there either way — a check appearing must not move the
// row's other chips.
mark.append(icon("check"));
}
toggle.replaceChildren(icon("captions"), langs, mark);
toggle.dataset.verdict = summary.satisfied ? "eligible" : "wanted";
toggle.title = summary.detail;
toggle.setAttribute("aria-label", subtitleSummaryLabel(summary));
// the wanted set first, then its gaps, then everything nobody asked for:
// the lane reads as the answer to "am I done here", with the stray
// languages as the footnote they are
presentChips.replaceChildren();
const asked = wantedSubtitles(subtitles, wanted);
const rest = subtitles.filter((subtitle) => !asked.includes(subtitle));
for (const subtitle of asked) {
presentChips.append(subtitleChip(subtitle, wanted, refresh));
}
for (const gap of missing) {
line.append(missingSubtitleChip(gap));
presentChips.append(missingSubtitleChip(gap));
}
for (const subtitle of rest) {
presentChips.append(subtitleChip(subtitle, wanted, refresh));
}
if (subtitles.length === 0 && missing.length === 0) {
const none = document.createElement("p");
none.className = "readout dim";
none.textContent = "no subtitles on this file yet.";
presentChips.append(none);
}
line.append(toggle);
}
/* ---- fetch lane ---- */
@@ -1365,7 +1474,7 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
translateNote.hidden = true;
translateLane.append(translateHead, translateControls, translateHint, translateNote);
panel.append(fetchLane, translateLane);
panel.append(presentLane, fetchLane, translateLane);
/** Keeps the target picker off the source's own language — a same-language
* translation is refused by the API and is never what was meant. */
@@ -1410,7 +1519,7 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
return;
}
optionsLoaded = true;
const outcome = await subtitleOptions();
const outcome = await cachedSubtitleOptions();
const options: SubtitleOptions =
outcome.kind === "options"
? outcome.options
@@ -1574,8 +1683,15 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
});
paintChips();
// the wanted set decides which languages the chip may name at all, so it
// is asked for on sight rather than when the panel opens; the request is
// shared by every row on the page
void cachedSubtitleOptions().then((outcome) => {
wanted = outcome.kind === "options" ? outcome.options.wanted_languages : [];
paintChips();
});
return {
line,
chip: toggle,
panel,
update: (next: SubtitleStatus) => {
subtitles = next.subtitles;
@@ -1745,14 +1861,9 @@ function libraryRow(
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
chips.append(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
const waiver = waiverChip(movie.waiver);
if (waiver !== null) {
chips.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
chips.append(waiver);
}
if (!movie.wanted) {
chips.append(chip("not wanted"));
@@ -2292,14 +2403,9 @@ function movieMain(views: HideableView[]): MovieView {
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
const waiver = waiverChip(movie.waiver);
if (waiver !== null) {
chipsEl.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
chipsEl.append(waiver);
}
if (movie.blocked) {
chipsEl.append(chip(NO_TARGETED_SEARCH));
@@ -2566,21 +2672,22 @@ function movieMain(views: HideableView[]): MovieView {
for (const attribute of probedAttributeTags(file.probed)) {
line.append(chip(attribute));
}
// the languages a file carries belong together: subtitles sit beside
// the audio tags `probedAttributeTags` just ended on
const status = subtitlesByFile.get(file.id);
const section = status === undefined ? null : subtitleSection(status, refreshSubtitles);
if (status !== undefined && section !== null) {
subtitleSections.set(status.media_file_id, section);
line.append(section.chip);
}
line.append(chip(formatSize(file.size)));
const waiver = waiverLabel(file.waiver);
const waiver = waiverChip(file.waiver);
if (waiver !== null) {
line.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
line.append(waiver);
}
item.append(line);
const status = subtitlesByFile.get(file.id);
if (status !== undefined) {
const section = subtitleSection(status, refreshSubtitles);
subtitleSections.set(status.media_file_id, section);
item.append(section.line, section.panel);
if (section !== null) {
item.append(section.panel);
}
diskRows.append(item);
}
@@ -3692,14 +3799,9 @@ function movieCard(
const root = roots.find((candidate) => candidate.id === movie.root_id);
const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
chips.push(mediaStateChip(movie.state, movie.wanted));
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
const waiver = waiverChip(movie.waiver);
if (waiver !== null) {
chips.push(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
chips.push(waiver);
}
if (!movie.wanted) {
chips.push(chip("not wanted"));
@@ -4632,20 +4734,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}),
);
}
let waived: HTMLSpanElement | null = null;
const file = filesByEpisode.get(episode.id);
if (file) {
// §7.4: attribute tags come from ffprobe, so they are true
for (const attribute of fileAttributeTags(file)) {
chips.append(chip(attribute));
}
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
chips.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
waived = waiverChip(file.waiver);
}
const actions = document.createElement("span");
@@ -4728,12 +4824,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
actions.append(placeholderSlot());
}
item.append(tag, name, air, chips, actions);
// subtitles beside the audio tags, then the waiver last — the row reads
// language, language, then what was relaxed to let the file in
const status = subtitlesByEpisode.get(episode.id);
if (status !== undefined) {
const section = subtitleSection(status, refreshSubtitles);
const section = status === undefined ? null : subtitleSection(status, refreshSubtitles);
if (status !== undefined && section !== null) {
subtitleSections.set(status.media_file_id, section);
item.append(section.line, section.panel);
chips.append(section.chip);
}
if (waived !== null) {
chips.append(waived);
}
item.append(tag, name, air, chips, actions);
if (section !== null) {
item.append(section.panel);
}
return item;
}
+5
View File
@@ -5,6 +5,7 @@
import { armedDeleteIcon, icon } from "./icons";
import { navigate } from "./router";
import type { Root } from "./search";
import { forgetSubtitleOptions } from "./subtitles";
/** One policy row: the JSON columns as the API returns them. */
export interface PolicyDoc {
@@ -926,6 +927,10 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
};
void saveSubtitleSettings(input).then((outcome) => {
if (outcome.ok) {
// the wanted set decides which languages a title page's subtitle
// chip may name, and that page caches it — this is where it goes
// stale, so this is where it is dropped
forgetSubtitleOptions();
void load();
return;
}
+78 -8
View File
@@ -1730,13 +1730,83 @@ body {
/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */
/* forces the subtitle row below a disk-row's or episode's own line, the
same 100%-basis trick .rel-name uses to keep the release name secondary */
.subtitle-line {
flex-basis: 100%;
padding-top: var(--space-1);
margin-top: var(--space-1);
border-top: 1px solid oklch(from var(--line) l c h / 30%);
/* the one subtitle chip a file's row carries (§9.6 keeps a file to one
line): it names the wanted languages and opens the panel, so it is a
button wearing the chip's geometry rather than a second control idiom */
.subs-chip {
font-weight: 500;
cursor: pointer;
transition:
color 150ms var(--ease-out),
border-color 150ms var(--ease-out);
}
.subs-chip .icon {
height: 0.75rem;
opacity: 0.7;
}
/* the comma between two codes belongs to the string, so the languages sit
in their own gapless run inside the chip. Its width is set from the
wanted set (see `summaryWidth`) rather than from the row's own content:
the same fixed-column reasoning as `.cw`, so a chip that says `en` is as
wide as one that says `pt-PT,en`. */
.subs-chip-langs {
display: inline-flex;
align-items: baseline;
flex: none;
gap: 0;
overflow: hidden;
white-space: nowrap;
}
/* the satisfied check has a slot whether or not it is drawn — a language
landing must not shove the chips after it sideways */
.subs-chip-mark {
display: inline-flex;
flex: none;
justify-content: center;
width: 0.75rem;
}
/* §15: a machine translation reads as machine-made at a glance. The chip has
no room for the word, so the language carries a dotted mark and the title
spells the origin out in full. */
.subs-chip-langs [data-mt="true"] {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
/* amber is §4.2's own hue for wanted and not yet on disk; a satisfied chip
takes the eligible green through the shared [data-verdict] rule */
.chip[data-verdict="wanted"] {
color: var(--signal-warn);
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
.subs-chip:hover,
.subs-chip:focus-visible,
.subs-chip[aria-expanded="true"] {
border-color: currentcolor;
}
/* the panel's own list of what is on the file: every track, including the
languages nobody asked for and the forced ones the row's chip elides */
.subs-present {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
padding: var(--space-3) var(--space-1) 0;
}
/* a chip whose whole label is its glyph: the words live in the title and the
aria-label, so the box shrinks to the icon rather than to a truncated
sentence */
.chip-glyph {
min-width: var(--control-h);
justify-content: center;
padding: 0 var(--space-2);
}
/* a machine translation reads as machine-made at a glance (§15) — the same
@@ -2226,7 +2296,7 @@ body {
/* the state chip stays, and so does a flag — a vanished episode is a
conflict, not an attribute. Resolution, language and the waived-rule
chip go; they are what a wide screen is for. */
.ep-chips .chip:not([data-movie-state]):not([data-flag]) {
.ep-chips .chip:not([data-movie-state]):not([data-flag]):not(.subs-chip) {
display: none;
}
+114
View File
@@ -398,3 +398,117 @@ export function subtitleRuleLabel(rule: string | null): string {
}
return SUBTITLE_RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
/* ---- the row's one subtitle chip (§9.6's one-row rule) ----------------- */
/**
* The wanted set is a global setting, not a property of a file, so every
* row on a page asks the same question. One request answers all of them,
* and it is re-asked only when `/settings` writes a new one.
*/
let pendingOptions: Promise<SubtitleOptionsOutcome> | null = null;
export function cachedSubtitleOptions(): Promise<SubtitleOptionsOutcome> {
pendingOptions ??= subtitleOptions();
return pendingOptions;
}
/** Called after `/settings` saves: the next reader re-reads the wanted set. */
export function forgetSubtitleOptions(): void {
pendingOptions = null;
}
/** One language in the row chip, and whether the only subtitle in it is machine-made. */
export interface SummaryLanguage {
language: string;
translated: boolean;
}
export interface SubtitleSummary {
/** Wanted languages present on the file, in the operator's own order. */
languages: SummaryLanguage[];
/** How many of those the chip elides — `+N` after the two it names. */
overflow: number;
/** Nothing wanted is missing (the server's own verdict, via `missing`). */
satisfied: boolean;
/** Line per language, present then missing, for the chip's title. */
detail: string;
}
/** How many languages the chip names before it counts the rest. */
const SUMMARY_SHOWN = 2;
/**
* The languages a row is willing to talk about: the wanted ones (§15's
* global set), never a forced track, which satisfies nothing. A subtitle in
* some language nobody asked for is not a fact about this file worth a
* row's width — it lives in the panel, with the rest of the detail.
*
* `wanted` is `null` only in the moment before the settings request lands;
* every non-forced language shows then, which is a superset rather than a
* wrong answer.
*/
export function wantedSubtitles(subtitles: Subtitle[], wanted: string[] | null): Subtitle[] {
const present = subtitles.filter((subtitle) => !subtitle.forced);
if (wanted === null) {
return present;
}
// the operator's order, not the file's: two files with the same languages
// must read identically at a glance
return wanted.flatMap((language) => present.filter((subtitle) => subtitle.language === language));
}
export function subtitleSummary(status: SubtitleStatus, wanted: string[] | null): SubtitleSummary {
const present = wantedSubtitles(status.subtitles, wanted);
const seen = new Set<string>();
const languages: SummaryLanguage[] = [];
for (const subtitle of present) {
if (seen.has(subtitle.language)) {
continue;
}
seen.add(subtitle.language);
languages.push({
language: subtitle.language,
translated: present
.filter((other) => other.language === subtitle.language)
.every((other) => other.origin === "translated"),
});
}
const lines = [
...present.map((subtitle) => subtitleChipLabel(subtitle)),
...status.missing.map((gap) => `${missingChipLabel(gap)} — missing`),
];
return {
languages: languages.slice(0, SUMMARY_SHOWN),
overflow: Math.max(0, languages.length - SUMMARY_SHOWN),
satisfied: status.missing.length === 0,
detail: lines.length === 0 ? "no subtitles on this file" : lines.join("\n"),
};
}
/**
* How many characters wide the chip's language run must be so that every
* row on the page ends at the same x. The wanted set is global, so one
* width serves the whole page: the widest label it can produce is its two
* longest codes, comma-joined, plus the `+N` for whatever it elides.
* Widths that come from the row's own content would misalign every chip
* after it, which is the misalignment §9.3's fixed columns exist to avoid.
*/
export function summaryWidth(wanted: string[] | null): number {
const none = "none".length;
if (wanted === null || wanted.length === 0) {
return none;
}
const longest = [...wanted].sort((a, b) => b.length - a.length).slice(0, SUMMARY_SHOWN);
const joined = longest.join(",").length;
const elided = wanted.length > SUMMARY_SHOWN ? `+${wanted.length - SUMMARY_SHOWN}`.length : 0;
return Math.max(none, joined + elided);
}
/** What the chip says out loud, for a reader who never sees its colour. */
export function subtitleSummaryLabel(summary: SubtitleSummary): string {
const named = summary.languages.map((entry) => entry.language);
const spoken = named.length === 0 ? "none" : named.join(", ");
const rest = summary.overflow === 0 ? "" : ` and ${summary.overflow} more`;
return `subtitles: ${spoken}${rest} — ${summary.satisfied ? "complete" : "incomplete"}`;
}