feat(web): movie detail page at /movies/{id}

This commit is contained in:
Miguel Palhas
2026-08-24 00:00:18 +01:00
parent 8af85606a0
commit fdaaeccb40
7 changed files with 1181 additions and 181 deletions
+600 -140
View File
@@ -6,6 +6,21 @@ import {
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
fileName,
formatMetaLine,
formatRating,
formatVoteCount,
imdbLink,
type MetadataCastMember,
movieMetadata,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
updateMovie,
youtubeLink,
} from "./movie";
import {
type AttentionMovie,
type AttentionQueues,
@@ -17,6 +32,7 @@ import {
} from "./queues";
import {
bucketOf,
type FilesOutcome,
formatAudio,
formatHdr,
formatResolution,
@@ -32,6 +48,7 @@ import {
movieFiles,
movieReleases,
movieSearchState,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
@@ -230,18 +247,18 @@ function main() {
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
// the tv deck registers its Escape handler before the series view does,
// so one Esc always steps back from the innermost layer first
const tvDeck = tvReleasesMain();
const seriesDetail = seriesMain(board, tvDeck, views);
const library = libraryMain(board, releases, seriesDetail, views);
const queues = queuesMain(board, releases, views);
const movieDetail = movieMain(board, views);
const library = libraryMain(board, movieDetail, seriesDetail, views);
const queues = queuesMain(board, movieDetail, views);
const settings = settingsMain(board, views);
views.push(tvDeck, seriesDetail, library, queues, settings);
const search = searchMain(board, releases, seriesDetail, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(board, movieDetail, seriesDetail, views);
// a removed title must not survive on the surface the page opened over
movieDetail.setRemoved((parent) => {
switch (parent.kind) {
case "library":
library.open();
@@ -281,6 +298,7 @@ function main() {
case "search":
search.restore(route.query);
break;
case "movie":
case "releases": {
const movie = await fetchMovie(route.movieId);
if (!movie) {
@@ -291,9 +309,19 @@ function main() {
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
library.open();
releases.open(movie, must<HTMLElement>("#nav-library"), must<HTMLElement>("#library"), {
kind: "library",
});
await movieDetail.open(
route.movieId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{
kind: "library",
},
);
// `/movies/{id}/releases` keeps resolving (#149): same page, deck
// section in view
if (route.kind === "releases") {
must<HTMLElement>("#movie-releases").scrollIntoView({ block: "start" });
}
break;
}
case "series": {
@@ -406,7 +434,7 @@ interface SearchView {
function searchMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): SearchView {
@@ -453,13 +481,21 @@ function searchMain(
});
};
/** A movie hit opens its §9.6 page (#149); the deck section is on it. */
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, refs.deck, {
kind: "search",
query: input.value.trim(),
});
};
let timer: number | undefined;
let controller: AbortController | null = null;
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
for (const view of views) {
view.hide();
}
@@ -468,7 +504,6 @@ function searchMain(
}
function showDeck() {
releases.hide();
for (const view of views) {
view.hide();
}
@@ -546,12 +581,7 @@ function searchMain(
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, refs.deck, { kind: "search", query: input.value.trim() });
}),
);
refs.groups.library.rows.append(libraryRow(movie, roots, openMovie));
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
@@ -736,7 +766,7 @@ function libraryRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
@@ -1024,19 +1054,23 @@ function addPanel(
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
/* ---- movie page (§9.6, issue #149) ------------------------------------ */
interface ReleasesView {
/** Opens over `returnTo`, which back and Esc restore; `parentRoute` is where they navigate. */
interface MovieView {
hide: () => void;
/**
* Opens `/movies/{id}` over `returnTo`, which back and Esc restore;
* `parentRoute` is where they navigate. The deep link passes the library
* surface, exactly as a series deep link does.
*/
open: (
movie: LibraryMovie,
movieId: number,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => void;
hide: () => void;
) => Promise<void>;
/**
* Called with the deck's parent route once a title is removed, so the
* Called with the page's parent route once a title is removed, so the
* surface underneath repaints instead of listing something that is gone.
*/
setRemoved: (handler: (parent: Route) => void) => void;
@@ -1085,16 +1119,33 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
function releasesMain(): ReleasesView {
const view = must<HTMLElement>("#releases");
const deck = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#releases-back");
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const remove = must<HTMLButtonElement>("#releases-remove");
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
const titleEl = must<HTMLElement>("#movie-title");
const yearEl = must<HTMLElement>("#movie-year");
const chipsEl = must<HTMLElement>("#movie-chips");
const ratingEl = must<HTMLElement>("#movie-rating");
const metaEl = must<HTMLElement>("#movie-meta");
const taglineEl = must<HTMLElement>("#movie-tagline");
const overviewEl = must<HTMLElement>("#movie-overview");
const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked");
const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const status = must<HTMLElement>("#releases-status");
const castSection = must<HTMLElement>("#movie-cast");
const castRows = must<HTMLUListElement>("#rows-cast");
const filesSection = must<HTMLElement>("#movie-files");
const diskCount = must<HTMLElement>("#count-disk");
const diskRows = must<HTMLUListElement>("#rows-disk");
const releaseStatus = must<HTMLElement>("#releases-status");
const eligible: BucketsDom["eligible"] = {
section: must<HTMLElement>("#bucket-eligible"),
count: must<HTMLElement>("#count-eligible"),
@@ -1115,19 +1166,21 @@ function releasesMain(): ReleasesView {
},
} as const;
let movieId: number | null = null;
let current: LibraryMovie | null = null;
let roots: Root[] = [];
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let returnTo: HTMLElement | null = null;
let parentRoute: Route = { kind: "board" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
reload: () => reloadReleases(),
notify: (text, tone) => setReleaseStatus(text, tone),
grab: async (release, bucket) => {
const movie = current;
if (!movie) {
return { kind: "error", detail: "deck closed", overrideWritten: false };
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
@@ -1148,57 +1201,421 @@ function releasesMain(): ReleasesView {
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
status.hidden = text === null;
statusEl.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
status.replaceChildren(lamp, document.createTextNode(text));
statusEl.replaceChildren(lamp, document.createTextNode(text));
} else {
status.textContent = text ?? "";
statusEl.textContent = text ?? "";
}
if (tone) {
status.dataset.tone = tone;
statusEl.dataset.tone = tone;
} else {
delete status.dataset.tone;
delete statusEl.dataset.tone;
}
}
function render(releases: MovieRelease[]) {
/** The releases section speaks beside its own buckets, not from the page top. */
function setReleaseStatus(text: string | null, tone?: "fault", busy = false) {
releaseStatus.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
releaseStatus.replaceChildren(lamp, document.createTextNode(text));
} else {
releaseStatus.textContent = text ?? "";
}
if (tone) {
releaseStatus.dataset.tone = tone;
} else {
delete releaseStatus.dataset.tone;
}
}
/* ---- identity: stored fields first, rich detail on top ---- */
function paintIdentity() {
const movie = current;
if (!movie) {
return;
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(
chip(movie.state, (span) => {
span.dataset.movieState = movie.state;
}),
);
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chipsEl.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
}
}
function paintRootOptions() {
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "movie")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
}
function paintControls() {
const movie = current;
if (!movie) {
return;
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
if (rootSelect.value !== String(movie.root_id)) {
const option = document.createElement("option");
option.value = String(movie.root_id);
option.textContent = `root ${movie.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(movie.root_id);
sweep.disabled = false;
}
async function refreshMovie() {
const id = movieId;
if (id === null) {
return;
}
const fresh = await fetchMovie(id);
if (fresh === null || movieId !== id) {
return;
}
current = fresh;
paintIdentity();
paintControls();
}
wanted.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
wanted.disabled = true;
void updateMovie(movie.id, { wanted: !movie.wanted }).then((outcome) => {
if (outcome.kind === "error") {
wanted.disabled = false;
setStatus(`wanted failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
wanted.disabled = false;
});
});
});
blocked.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
blocked.disabled = true;
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
blocked.disabled = false;
});
});
});
rootSelect.addEventListener("change", () => {
const movie = current;
if (!movie) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === movie.root_id) {
return;
}
rootSelect.disabled = true;
void updateMovie(movie.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(movie.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
function starIcon(): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("class", "star");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
);
svg.append(path);
return svg;
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
function castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name}${member.character}`;
item.append(link);
return item;
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await movieMetadata(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
// the stored identity stands; the actions row says why the rest is absent
const note = document.createElement("p");
note.className = "add-note readout";
note.dataset.tone = "fault";
note.textContent = `metadata unavailable — ${outcome.detail}`;
actionsEl.append(note);
return;
}
const detail = outcome.metadata;
const posterUrl = tmdbImage(detail.poster_path, "w342");
if (posterUrl !== null && current !== null) {
poster.src = posterUrl;
poster.alt = `${current.title} poster`;
poster.hidden = false;
}
const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
if (backdropUrl !== null) {
hero.classList.add("has-backdrop");
hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
}
if (detail.vote_count > 0) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
);
}
if (detail.cast.length > 0 && current !== null) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
function paintFiles(outcome: FilesOutcome) {
diskRows.replaceChildren();
filesSection.hidden = false;
if (outcome.kind === "error") {
diskCount.textContent = "";
const row = document.createElement("li");
row.className = "rel rel-none readout";
row.dataset.tone = "fault";
row.textContent = `files unreadable — ${outcome.detail}`;
diskRows.append(row);
return;
}
const files = outcome.files;
diskCount.textContent = String(files.length);
if (files.length === 0) {
const row = document.createElement("li");
row.className = "rel rel-none readout dim";
row.textContent = "nothing on disk yet — imports land here";
diskRows.append(row);
return;
}
for (const file of files) {
const item = document.createElement("li");
item.className = "rel disk-row";
const line = document.createElement("div");
line.className = "rel-line";
const name = document.createElement("span");
name.className = "disk-name readout";
name.textContent = fileName(file.path);
name.title = file.path;
line.append(name);
for (const attribute of probedAttributeTags(file.probed)) {
line.append(chip(attribute));
}
line.append(chip(formatSize(file.size)));
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
line.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
item.append(line);
diskRows.append(item);
}
}
/* ---- releases: the §9.3 deck, unchanged, as the closing section ---- */
function render(releases: MovieRelease[]) {
if (!current) {
return;
}
if (!paintBuckets(dom, releases, actions)) {
return;
}
setStatus(null);
setReleaseStatus(null);
}
async function load() {
const movie = current;
if (!movie) {
async function loadReleases(id: number, ticket: number) {
window.clearTimeout(pollTimer);
setReleaseStatus("reading releases…");
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
clearBuckets(dom);
setReleaseStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(id, ticket);
return;
}
render(outcome.releases);
}
/** Refetch verdicts only — the reload an override-written grab triggers. */
async function reloadReleases() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
if (outcome.kind === "results" && !paintBuckets(dom, outcome.releases, actions)) {
clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(movie, ticket);
return;
}
render(outcome.releases);
}
/**
@@ -1206,46 +1623,46 @@ function releasesMain(): ReleasesView {
* has not landed yet, or a sweep that landed and found nothing. Only the
* movie's `last_searched_at` can tell them apart.
*/
async function emptyVerdict(movie: LibraryMovie, ticket: number) {
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
async function emptyVerdict(id: number, ticket: number) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) {
setStatus(
setReleaseStatus(
`no releases found — sweep finished ${formatSweepAge(outcome.movie.last_searched_at)}; search indexers runs a new one`,
);
return;
}
if (outcome.kind === "state" && sweepExpected(outcome.movie)) {
// first sweep still in flight — visibly different from "nothing found"
watchSweep(movie, null, ticket);
watchSweep(id, null, ticket);
return;
}
setStatus("no releases indexed for this title — search indexers runs a sweep now");
setReleaseStatus("no releases indexed for this title — search indexers runs a sweep now");
}
/** Poll the movie until `last_searched_at` moves off `baseline`. */
function watchSweep(movie: LibraryMovie, baseline: string | null, ticket: number) {
function watchSweep(id: number, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
setReleaseStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || current !== movie) {
if (ticket !== sequence || movieId !== id) {
return;
}
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
void load();
await loadReleases(id, ticket);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
setReleaseStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
);
return;
@@ -1259,6 +1676,8 @@ function releasesMain(): ReleasesView {
}, SWEEP_POLL_MS);
}
/* ---- removal confirmation, unchanged from the deck ---- */
/** Tears the confirmation down without stealing focus from a caller. */
function clearRemove() {
removeWrap.hidden = true;
@@ -1300,59 +1719,6 @@ function releasesMain(): ReleasesView {
}
});
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
returnTo = container;
parentRoute = parent;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
clearRemove();
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the deck
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
@@ -1372,18 +1738,114 @@ function releasesMain(): ReleasesView {
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
setReleaseStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie, baseline, sequence);
watchSweep(movie.id, baseline, sequence);
})();
});
/* ---- open, close, layers ---- */
async function load() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
const [movie, filesOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id),
movieFiles(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || movieId !== id) {
return;
}
roots = fetchedRoots;
if (movie === null) {
setStatus("movie unavailable", "fault");
return;
}
current = movie;
clearRichDetail();
paintIdentity();
paintControls();
paintFiles(filesOutcome);
void loadMetadata(id, ticket);
await loadReleases(id, ticket);
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
// this page is one of `views`, so the loop below calls its own hide()
// and would wipe the state assigned after it — hide first, then take state
for (const sibling of views) {
sibling.hide();
}
movieId = id;
current = null;
origin = from;
returnTo = container;
parentRoute = parent;
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
clearRichDetail();
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
await load();
}
function hide() {
view.hidden = true;
movieId = null;
current = null;
sequence += 1;
window.clearTimeout(pollTimer);
clearRemove();
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
if (returnTo) {
returnTo.hidden = false;
}
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || view.hidden) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the page
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
return {
open,
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
@@ -1776,7 +2238,7 @@ interface LibraryGroup {
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): LibraryView {
@@ -1859,8 +2321,8 @@ function libraryMain(
);
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, view, { kind: "library" });
navigate({ kind: "movie", movieId: target.id });
void movieDetail.open(target.id, origin, view, { kind: "library" });
}),
);
@@ -1895,7 +2357,6 @@ function libraryMain(
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2669,7 +3130,7 @@ interface QueueGroup {
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableView[]): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
@@ -2742,8 +3203,8 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "releases", movieId: movie.id });
releases.open(movie, origin, view, { kind: "queues" });
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, view, { kind: "queues" });
};
/** Drops an emptied item without a refetch: the override IS written. */
@@ -2808,7 +3269,6 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2923,7 +3383,7 @@ function noPtRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {