feat(web): movie detail page at /movies/{id}
This commit is contained in:
+600
-140
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user