diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 33f7fae7..d71ff5e7 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -468,7 +468,9 @@ export default function PlexBrowse() { if (sub.view.kind === "person") { const personName = sub.view.name; return ( + // Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio). { } }); }); + +describe("PlexPersonView — a bio on its way (E30 S6)", () => { + const view = ( + {}} + onOpenCard={() => {}} + onRestartCard={() => {}} + onInfoCard={() => {}} + onFilter={() => {}} + /> + ); + const person = (over: object) => ({ + name: "Jane Doe", + photo: null, + also_known_as: [], + known_for: [], + movies: [card("m1", "Movie One")], + shows: [], + ...over, + }); + + it("holds the bio's place and asks again until it lands", async () => { + plexPerson.mockReset(); + plexPerson + .mockResolvedValueOnce(person({ biography: null, enriching: true })) + .mockResolvedValue(person({ biography: "Landed bio." })); + wrap(view); + await screen.findByText("Movie One"); + expect(screen.getByRole("status").textContent).toContain("plex.person.bioLoading"); + await screen.findByText("Landed bio.", undefined, { timeout: 3000 }); + expect(screen.queryByRole("status")).toBeNull(); + expect(plexPerson).toHaveBeenCalledTimes(2); + }); + + it("does not ask again for a person whose bio is not coming", async () => { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: null, enriching: false })); + wrap(view); + await screen.findByText("Movie One"); + await new Promise((r) => setTimeout(r, 1800)); + expect(plexPerson).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("offers More only when the bio is cut, and Less once open", async () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160); + try { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." })); + wrap(view); + fireEvent.click(await screen.findByText("plex.person.more")); + expect(screen.getByText("plex.person.less").getAttribute("aria-expanded")).toBe("true"); + } finally { + tall.mockRestore(); + box.mockRestore(); + } + cleanup(); + plexPerson.mockResolvedValue(person({ biography: "Short." })); + wrap(view); + await screen.findByText("Short."); + expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 20c4af6a..3f7d3d09 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from "react"; +import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -119,6 +119,53 @@ export function PlexInfoView({ ); } +// How often, and for how long, a person page asks again for a bio that is on its way (E30 S6). The +// background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one. +const BIO_POLL_MS = 1500; +// From this many "Known for" titles the rail gets half the hero; fewer (about one person in five) get +// just their posters' width, so a lone poster doesn't sit in an empty half (E30 S6, measured). +const KNOWN_FOR_HALF = 4; +const BIO_WAIT_MS = 20_000; + +/** A person's biography, clamped to a few lines with a More / Less toggle — offered only when the + * text is actually cut (measured, so a short bio carries no dead button). */ +function PersonBio({ text }: { text: string }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [clipped, setClipped] = useState(false); + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el || expanded) return; + // Re-measured on a resize: the text-size slider and the window both change where the cut falls. + const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [text, expanded]); + return ( +
+

+ {text} +

+ {(clipped || expanded) && ( + + )} +
+ ); +} + // --- Actor detail: a cast/crew member's filmography (movies + shows in one place) --------------- // The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it // shows the person's headshot + name, a "filter the library by them" action, and their titles as two @@ -146,7 +193,18 @@ export function PlexPersonView({ const menu = useContextMenu(); const lang = plexLang(i18n.language); const personKey = qk.plexPerson(name, lang); - const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) }); + // A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30 + // S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not + // forever: past BIO_WAIT_MS a TMDB that is down stops being waited on (the next open tries again). + const [openedAt] = useState(() => Date.now()); + const q = useQuery({ + queryKey: personKey, + queryFn: () => api.plexPerson(name, lang), + refetchInterval: (query) => + query.state.data?.enriching && query.state.dataUpdatedAt - openedAt < BIO_WAIT_MS + ? BIO_POLL_MS + : false, + }); // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. const { artBg, crossfade } = useDetailPrefs(); @@ -230,83 +288,106 @@ export function PlexPersonView({ ) : (
- {/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */} -
-
-
- {d.photo ? ( - - ) : ( -
- + {/* Hero (E30 S6, owner pick "A"): the person — headshot, facts, a clamped bio — on the left, + what they are known for on the right, so the bio doesn't sit in a full-width panel with an + empty half beside it. One column where the panel is narrow. */} +
+
= KNOWN_FOR_HALF + ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" + : d.known_for.length > 0 + ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_auto]" + : undefined + } + > +
+
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )}
- )} -
-
- {d.name} - {d.known_for_department && ( -

- {vocab(t, "department", d.known_for_department)} -

- )} -

- {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })} -

-
- {(d.birthday || d.place_of_birth) && ( -

- {t("plex.person.born")}:{" "} - {[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")} +

+ {d.name} + {d.known_for_department && ( +

+ {vocab(t, "department", d.known_for_department)} +

+ )} +

+ {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}

- )} - {d.deathday && ( -

- {t("plex.person.died")}:{" "} - {formatDate(d.deathday, lang)} -

- )} - {d.also_known_as.length > 0 && ( -

- {t("plex.person.aka")}:{" "} - {d.also_known_as.slice(0, 4).join(", ")} -

- )} +
+ {(d.birthday || d.place_of_birth) && ( +

+ {t("plex.person.born")}:{" "} + {[formatDate(d.birthday, lang), d.place_of_birth] + .filter(Boolean) + .join(" · ")} +

+ )} + {d.deathday && ( +

+ {t("plex.person.died")}:{" "} + {formatDate(d.deathday, lang)} +

+ )} + {d.also_known_as.length > 0 && ( +

+ {t("plex.person.aka")}:{" "} + {d.also_known_as.slice(0, 4).join(", ")} +

+ )} +
+ onFilter({ actors: [d.name] })} className="font-medium"> + {t("plex.person.filterBy", { name: d.name })} + +
- onFilter({ actors: [d.name] })} className="font-medium"> - {t("plex.person.filterBy", { name: d.name })} - -
-
- {d.biography && ( -

- {d.biography} -

- )} -
- - {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} - {d.known_for.length > 0 && ( -
- - {d.known_for.map((c, i) => ( -
- onOpenCard(c)} - onToggleWatched={toggle} - {...cardMenu(c)} + {d.biography ? ( + + ) : ( + d.enriching && + q.dataUpdatedAt - openedAt < BIO_WAIT_MS && ( + -
- ))} -
-
- )} + ) + )} +
+ + {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} + {d.known_for.length > 0 && ( +
+ + {d.known_for.map((c, i) => ( +
+ onOpenCard(c)} + onToggleWatched={toggle} + {...cardMenu(c)} + /> +
+ ))} +
+
+ )} +
+
{empty ? (

{t("plex.person.empty")}

diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx index a6a06ab3..2919db0e 100644 --- a/frontend/src/components/ui/Skeleton.tsx +++ b/frontend/src/components/ui/Skeleton.tsx @@ -3,7 +3,8 @@ import { SURFACE } from "./surfaces"; // B8 (docs/DESIGN.md): a first load of a grid, list or table shows the SHAPE of what is coming, so // nothing jumps when the data lands. One block primitive (a tonal fill, the Badge's 8% of the text -// colour, pulsing unless the user asked for less motion) and the four page shapes built from it. The +// colour, pulsing unless the user asked for less motion) and the page shapes built from it (plus a +// paragraph's, for prose that arrives after its page). The // shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role. export function Skeleton({ className }: { className?: string }) { @@ -15,9 +16,9 @@ export function Skeleton({ className }: { className?: string }) { ); } -export type SkeletonShape = "grid" | "poster" | "list" | "table"; +export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text"; -const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8 }; +const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8, text: 5 }; /** The placeholder for a page's first load, by the shape of its content. */ export function SkeletonShapes({ shape }: { shape: SkeletonShape }) { @@ -46,6 +47,15 @@ export function SkeletonShapes({ shape }: { shape: SkeletonShape }) { ))}
); + if (shape === "text") + // A paragraph still on its way (a biography): lines at prose height, the last one short. + return ( +
+ {n.map((i) => ( + + ))} +
+ ); if (shape === "list") return (
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 20c5eeb5..35e6bd99 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -352,7 +352,10 @@ "born": "Born", "died": "Died", "aka": "Also known as", - "knownFor": "Known for" + "knownFor": "Known for", + "bioLoading": "Loading the biography", + "more": "More", + "less": "Less" }, "view": { "label": "View", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 938a34fb..eee078dc 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -352,7 +352,10 @@ "born": "Született", "died": "Elhunyt", "aka": "Más néven", - "knownFor": "Ismert szerepei" + "knownFor": "Ismert szerepei", + "bioLoading": "Az életrajz betöltése", + "more": "Több", + "less": "Kevesebb" }, "view": { "label": "Nézet",