fix(web): collapse subtitles into one row chip (#258)
This commit was merged in pull request #258.
This commit is contained in:
@@ -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
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user