feat(arr): show subtitle chips on title detail
Per media file: a chip per present language (origin, forced/SDH, sync flag, dashed for machine translation) and a chip per still-missing wanted language naming why (§15, #201). Movie and series pages both wire it in through the new status endpoints.
This commit is contained in:
+98
-7
@@ -105,6 +105,16 @@ import {
|
||||
} from "./series";
|
||||
import { armedDelete, settingsMain } from "./settings";
|
||||
import "./style.css";
|
||||
import {
|
||||
type EpisodeSubtitleStatus,
|
||||
type MissingSubtitle,
|
||||
missingChipLabel,
|
||||
movieSubtitleStatus,
|
||||
type Subtitle,
|
||||
type SubtitleStatus,
|
||||
seriesSubtitleStatus,
|
||||
subtitleChipLabel,
|
||||
} from "./subtitles";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
@@ -758,6 +768,63 @@ function countsChip(
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function subtitleChip(subtitle: Subtitle): HTMLSpanElement {
|
||||
return chip(subtitleChipLabel(subtitle), (span) => {
|
||||
if (!subtitle.forced) {
|
||||
span.dataset.verdict = "eligible";
|
||||
}
|
||||
if (subtitle.origin === "translated") {
|
||||
span.dataset.mt = "true";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
span.dataset.movieState = "missing";
|
||||
span.dataset.wanted = "true";
|
||||
if (missing.reason === "failed" && missing.detail !== null) {
|
||||
span.title = missing.detail;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle status row for one media file (§9.6): every present
|
||||
* language, then every wanted language still missing, each carrying its own
|
||||
* reason. `null` only when there is nothing to say — no subtitles and
|
||||
* nothing wanted — which does not happen with the shipped default wanted
|
||||
* set, but an operator could empty it from `/settings`.
|
||||
*/
|
||||
function subtitleStatusLine(status: SubtitleStatus | null): HTMLElement | null {
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
if (status.subtitles.length === 0 && status.missing.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const line = document.createElement("div");
|
||||
line.className = "rel-line subtitle-line";
|
||||
for (const subtitle of status.subtitles) {
|
||||
line.append(subtitleChip(subtitle));
|
||||
}
|
||||
for (const missing of status.missing) {
|
||||
line.append(missingSubtitleChip(missing));
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function rowTitle(title: string, year: number | null): HTMLElement {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "row-id";
|
||||
@@ -1339,6 +1406,7 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
let movieId: number | null = null;
|
||||
let current: LibraryMovie | null = null;
|
||||
let roots: Root[] = [];
|
||||
let subtitlesByFile = new Map<number, SubtitleStatus>();
|
||||
let removed: ((parent: Route) => void) | null = null;
|
||||
let origin: HTMLElement | null = null;
|
||||
let returnTo: HTMLElement | null = null;
|
||||
@@ -1663,6 +1731,10 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
);
|
||||
}
|
||||
item.append(line);
|
||||
const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null);
|
||||
if (subtitleLine !== null) {
|
||||
item.append(subtitleLine);
|
||||
}
|
||||
diskRows.append(item);
|
||||
}
|
||||
}
|
||||
@@ -1865,9 +1937,10 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
sequence += 1;
|
||||
const ticket = sequence;
|
||||
window.clearTimeout(pollTimer);
|
||||
const [movie, filesOutcome, fetchedRoots] = await Promise.all([
|
||||
const [movie, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all([
|
||||
fetchMovie(id),
|
||||
movieFiles(id),
|
||||
movieSubtitleStatus(id),
|
||||
allRoots().catch(() => roots),
|
||||
]);
|
||||
if (ticket !== sequence || movieId !== id) {
|
||||
@@ -1879,6 +1952,11 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
return;
|
||||
}
|
||||
current = movie;
|
||||
subtitlesByFile = new Map(
|
||||
subtitleOutcome.kind === "status"
|
||||
? subtitleOutcome.statuses.map((status) => [status.media_file_id, status])
|
||||
: [],
|
||||
);
|
||||
clearRichDetail();
|
||||
paintIdentity();
|
||||
paintControls();
|
||||
@@ -3129,6 +3207,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
let series: ApiSeries | null = null;
|
||||
let seasons: ApiSeason[] | null = null;
|
||||
let filesByEpisode = new Map<number, EpisodeFile>();
|
||||
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
|
||||
// which seasons stand open survives the refetch every action triggers
|
||||
let expanded = new Set<number>();
|
||||
let origin: HTMLElement | null = null;
|
||||
@@ -3591,6 +3670,10 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
}
|
||||
|
||||
item.append(tag, name, air, chips, actions);
|
||||
const subtitleLine = subtitleStatusLine(subtitlesByEpisode.get(episode.id) ?? null);
|
||||
if (subtitleLine !== null) {
|
||||
item.append(subtitleLine);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -3722,12 +3805,15 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
const ticket = sequence;
|
||||
window.clearTimeout(refreshPollTimer);
|
||||
setStatus("reading series…");
|
||||
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
|
||||
fetchSeries(id),
|
||||
fetchSeasons(id),
|
||||
fetchSeriesFiles(id),
|
||||
allRoots().catch(() => roots),
|
||||
]);
|
||||
const [detail, seasonsOutcome, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all(
|
||||
[
|
||||
fetchSeries(id),
|
||||
fetchSeasons(id),
|
||||
fetchSeriesFiles(id),
|
||||
seriesSubtitleStatus(id),
|
||||
allRoots().catch(() => roots),
|
||||
],
|
||||
);
|
||||
if (ticket !== sequence || seriesId !== id) {
|
||||
return;
|
||||
}
|
||||
@@ -3747,6 +3833,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
? filesOutcome.files.map((file) => [file.episode_id, file])
|
||||
: [],
|
||||
);
|
||||
subtitlesByEpisode = new Map(
|
||||
subtitleOutcome.kind === "status"
|
||||
? subtitleOutcome.statuses.map((status) => [status.episode_id, status])
|
||||
: [],
|
||||
);
|
||||
paintHeader();
|
||||
clearRichDetail();
|
||||
renderSeasons();
|
||||
|
||||
Reference in New Issue
Block a user