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"; import { msg, raw, sayNotif, type NotifText } from "./notifications"; // 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("plexInfoArtBg", true); const [showCast, setShowCast] = usePref("plexInfoCast", true); const [showRelated, setShowRelated] = usePref("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("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("plexInfoFacts", true); const [keywords, setKeywords] = usePref("plexInfoKeywords", true); const [crew, setCrew] = usePref("plexInfoCrew", true); const has: Record = { 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 = { facts, keywords, crew }; const shown: Record = { 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; // 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 | 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 { return sayNotif(seasonTitleText(se), t); } /** The season's name as a notice keeps it: said in the reader's language each time it is shown. */ export function seasonTitleText(se: { title?: string | null; season_number?: number | null; }): NotifText { const n = se.season_number; if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title); if (n === 0) return msg("plex.specials"); if (n != null) return msg("plex.seasonTitle", { n }); return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown"); }