From bd5131f42407939bb1a887d11bc6bc2df24e1f45 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Wed, 26 Aug 2026 08:58:14 +0100 Subject: [PATCH] fix(web): collapse subtitles into one row chip (#258) --- web/src/icons.ts | 8 ++ web/src/main.ts | 240 +++++++++++++++++++++++++++++++------------ web/src/settings.ts | 5 + web/src/style.css | 86 ++++++++++++++-- web/src/subtitles.ts | 114 ++++++++++++++++++++ 5 files changed, 377 insertions(+), 76 deletions(-) diff --git a/web/src/icons.ts b/web/src/icons.ts index cdb1272..072f1e7 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -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; diff --git a/web/src/main.ts b/web/src/main.ts index 64e2689..91fbcda 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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; } diff --git a/web/src/settings.ts b/web/src/settings.ts index e0910c7..7836f53 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -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; } diff --git a/web/src/style.css b/web/src/style.css index e3ea6b7..be2dafd 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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; } diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts index eb357ef..fef1440 100644 --- a/web/src/subtitles.ts +++ b/web/src/subtitles.ts @@ -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 | null = null; + +export function cachedSubtitleOptions(): Promise { + 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(); + 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"}`; +}