The cards, the up-next card and the mark-next label printed a question mark where Plex has no number; the player's header already showed the title alone. One helper (episodeNumbers/episodeName) for every place.
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
import { useLayoutEffect } from "react";
|
|
import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
|
|
import { usePref, useSavePref } from "./usePref";
|
|
import type { TFunction } from "i18next";
|
|
import type { PlexCard, PlexTmdb } from "./api";
|
|
|
|
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
|
|
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
|
// plexDetailUi exports only components (Fast Refresh).
|
|
|
|
export function useDetailPrefs() {
|
|
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
|
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
|
// usePref seeds from the cache (no localStorage mirror).
|
|
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
|
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
|
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
|
// Slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
|
|
// it follows the system: off where the reader asked for less motion (prefers-reduced-motion — which
|
|
// Windows also reports whenever its animation effects are off). Once the reader turns it on or off
|
|
// here, that pick wins on every device: the owner never saw the crossfade, because the system signal
|
|
// used to switch it off whatever the toggle said (2026-10-03). The toggle shows the state in force.
|
|
const reduce = useMediaQuery(REDUCED_MOTION, false);
|
|
const [crossfadePick, setCrossfade] = usePref<boolean | null>("plexInfoArtCrossfade", null, {
|
|
parse: (raw) => (typeof raw === "boolean" ? raw : null),
|
|
});
|
|
const crossfade = crossfadePick ?? !reduce;
|
|
const savePref = useSavePref();
|
|
return {
|
|
artBg,
|
|
showCast,
|
|
showRelated,
|
|
crossfade,
|
|
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
|
toggleArtBg: () => setArtBg(!artBg),
|
|
toggleCast: () => setShowCast(!showCast),
|
|
toggleRelated: () => setShowRelated(!showRelated),
|
|
toggleCrossfade: () => setCrossfade(!crossfade),
|
|
};
|
|
}
|
|
|
|
// What a detail page's facts are read from: a movie's item detail and a show's detail both carry these.
|
|
export interface DetailFacts {
|
|
year?: number | null;
|
|
duration_seconds?: number | null;
|
|
content_rating?: string | null;
|
|
studio?: string | null;
|
|
tmdb?: PlexTmdb | null;
|
|
}
|
|
|
|
export type AsideSection = "facts" | "keywords" | "crew";
|
|
|
|
// The movie and the show page's poster. It keeps its 2:3 (self-start, never stretched to the row: the
|
|
// second column beside the text runs taller than the poster), and a wide hero (the @container
|
|
// panel at 64rem+) gives it a bigger rung, so it is not a stamp beside a full text column.
|
|
export const DETAIL_POSTER = "w-32 sm:w-44 @5xl:w-56";
|
|
|
|
// The detail pages' second column (plexDetailAside): which of its three sections — the TMDB facts,
|
|
// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs,
|
|
// default ON, the same on the movie and the show page), and the toggles' flips.
|
|
export function useDetailAside(f: DetailFacts) {
|
|
const [facts, setFacts] = usePref<boolean>("plexInfoFacts", true);
|
|
const [keywords, setKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
|
const [crew, setCrew] = usePref<boolean>("plexInfoCrew", true);
|
|
const has: Record<AsideSection, boolean> = {
|
|
facts:
|
|
f.year != null ||
|
|
!!f.duration_seconds ||
|
|
!!f.content_rating ||
|
|
!!f.tmdb?.budget ||
|
|
!!f.tmdb?.revenue ||
|
|
!!f.tmdb?.studios?.length ||
|
|
!!f.studio ||
|
|
!!f.tmdb?.production_countries?.length,
|
|
keywords: (f.tmdb?.keywords?.length ?? 0) > 0,
|
|
crew: (f.tmdb?.crew?.length ?? 0) > 0,
|
|
};
|
|
const on: Record<AsideSection, boolean> = { facts, keywords, crew };
|
|
const shown: Record<AsideSection, boolean> = {
|
|
facts: facts && has.facts,
|
|
keywords: keywords && has.keywords,
|
|
crew: crew && has.crew,
|
|
};
|
|
const set = { facts: setFacts, keywords: setKeywords, crew: setCrew };
|
|
return {
|
|
has,
|
|
on,
|
|
shown,
|
|
// Whether the hero's second column has anything (the keywords sit under the text).
|
|
column: shown.facts || shown.crew,
|
|
toggle: (k: AsideSection) => set[k](!on[k]),
|
|
};
|
|
}
|
|
|
|
export type DetailAsideState = ReturnType<typeof useDetailAside>;
|
|
|
|
// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only
|
|
// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the
|
|
// crossfade even on a lone movie with no collection.
|
|
export function tmdbBackdropUrl(path: string): string {
|
|
return `https://image.tmdb.org/t/p/w1280${path}`;
|
|
}
|
|
|
|
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images.
|
|
//
|
|
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
|
|
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
|
|
// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is
|
|
// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS
|
|
// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the
|
|
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
|
|
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
|
|
//
|
|
// The rotation runs only when `animate` says so — the reader's crossfade setting, which already folds in
|
|
// prefers-reduced-motion (useDetailPrefs) — and there are at least two images; `attachment:fixed` keeps it viewport-anchored
|
|
// while the content scrolls. Cleared on unmount / disable / empty pool.
|
|
const BACKDROP_FADE_MS = 1600;
|
|
const BACKDROP_INTERVAL_MS = 7000;
|
|
const BACKDROP_DIM =
|
|
"linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " +
|
|
"color-mix(in srgb, var(--bg) 64%, transparent))";
|
|
|
|
export function useCrossfadeBackdrop(
|
|
arts: (string | null | undefined)[],
|
|
enabled: boolean,
|
|
animate = true,
|
|
) {
|
|
// Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change.
|
|
const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|");
|
|
useLayoutEffect(() => {
|
|
const root = document.getElementById("root");
|
|
if (!root) return;
|
|
const paint = (url: string) => {
|
|
root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`;
|
|
root.style.backgroundSize = "cover";
|
|
root.style.backgroundPosition = "center 20%";
|
|
root.style.backgroundRepeat = "no-repeat";
|
|
root.style.backgroundAttachment = "fixed";
|
|
// #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's
|
|
// own background (the settled art) rather than behind it — without this the overlay is invisible.
|
|
root.style.isolation = "isolate";
|
|
};
|
|
const clear = () => {
|
|
root.style.backgroundImage = "";
|
|
root.style.backgroundSize = "";
|
|
root.style.backgroundPosition = "";
|
|
root.style.backgroundRepeat = "";
|
|
root.style.backgroundAttachment = "";
|
|
root.style.isolation = "";
|
|
};
|
|
|
|
const pool = key ? key.split("|") : [];
|
|
if (!enabled || pool.length === 0) {
|
|
clear();
|
|
return clear; // and clear again on unmount / dep-change (idempotent)
|
|
}
|
|
paint(pool[0]!);
|
|
|
|
// Static case (one image / crossfade off): still MUST clear on unmount — otherwise #root keeps the
|
|
// painted backdrop after leaving the page (e.g. switching to another module).
|
|
if (!animate || pool.length < 2) return clear;
|
|
|
|
const layer = document.createElement("div");
|
|
Object.assign(layer.style, {
|
|
position: "fixed",
|
|
inset: "0",
|
|
zIndex: "-1",
|
|
backgroundSize: "cover",
|
|
backgroundPosition: "center 20%",
|
|
backgroundRepeat: "no-repeat",
|
|
backgroundAttachment: "fixed",
|
|
opacity: "0",
|
|
pointerEvents: "none",
|
|
});
|
|
root.appendChild(layer);
|
|
|
|
let idx = 0;
|
|
let fadeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const tick = () => {
|
|
const next = (idx + 1) % pool.length;
|
|
layer.style.transition = "none";
|
|
layer.style.opacity = "0";
|
|
layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`;
|
|
requestAnimationFrame(() => {
|
|
layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`;
|
|
layer.style.opacity = "1";
|
|
});
|
|
fadeTimer = setTimeout(() => {
|
|
paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change)
|
|
layer.style.transition = "none";
|
|
layer.style.opacity = "0";
|
|
idx = next;
|
|
}, BACKDROP_FADE_MS);
|
|
};
|
|
const interval = setInterval(tick, BACKDROP_INTERVAL_MS);
|
|
return () => {
|
|
clearInterval(interval);
|
|
if (fadeTimer) clearTimeout(fadeTimer);
|
|
layer.remove();
|
|
clear();
|
|
};
|
|
}, [key, enabled, animate]);
|
|
}
|
|
|
|
// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode.
|
|
export function epLabel(ep?: PlexCard | null): string | undefined {
|
|
if (!ep) return undefined;
|
|
const n = episodeNumbers(ep);
|
|
return n ? `S${n.season} · E${n.episode}` : ep.title;
|
|
}
|
|
|
|
type Numbered = { season_number?: number | null; episode_number?: number | null };
|
|
|
|
/** An episode's season and episode number, or null when Plex has no number for one of them (a
|
|
* special). Every place that names an episode asks this, so a numberless one is named by its title
|
|
* alone everywhere: a template literal printed the missing number as "Enull", JSX as "E", and a
|
|
* `?? "?"` as "E?", which told the viewer nothing. */
|
|
export function episodeNumbers(ep: Numbered): { season: number; episode: number } | null {
|
|
if (ep.season_number == null || ep.episode_number == null) return null;
|
|
return { season: ep.season_number, episode: ep.episode_number };
|
|
}
|
|
|
|
/** An episode named in a line of its own ("Next: …", "Mark … watched"): "S1·E4", or its title. */
|
|
export function episodeName(ep: Numbered & { title: string }): string {
|
|
const n = episodeNumbers(ep);
|
|
return n ? `S${n.season}·E${n.episode}` : ep.title;
|
|
}
|
|
|
|
/** An episode's line under its show's name: "S1·E4 — Title", or the title alone (see above). */
|
|
export function episodeLine(ep: Numbered & { title: string }): string {
|
|
const n = episodeNumbers(ep);
|
|
return n ? `S${n.season}·E${n.episode} — ${ep.title}` : ep.title;
|
|
}
|
|
|
|
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
|
|
// English there. A generic name is rebuilt from the number in the reader's language; a season
|
|
// with a name of its own ("Book One: Water") keeps it.
|
|
const GENERIC_SEASON = /^(season \d+|specials)$/i;
|
|
|
|
export function seasonTitle(
|
|
se: { title?: string | null; season_number?: number | null },
|
|
t: TFunction,
|
|
): string {
|
|
const n = se.season_number;
|
|
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
|
|
if (n === 0) return t("plex.specials");
|
|
if (n != null) return t("plex.seasonTitle", { n });
|
|
return se.title || t("plex.playlist.seasonUnknown");
|
|
}
|