feat(plex): the person page in two columns; a bio on its way holds its place (E30 S6)
The biography sat at reading width in the left half of a full-width panel with nothing on the right. Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width. One column where the panel is narrow (200% zoom, the largest text size). While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so another person starts fresh.
This commit is contained in:
@@ -468,7 +468,9 @@ export default function PlexBrowse() {
|
|||||||
if (sub.view.kind === "person") {
|
if (sub.view.kind === "person") {
|
||||||
const personName = sub.view.name;
|
const personName = sub.view.name;
|
||||||
return (
|
return (
|
||||||
|
// Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio).
|
||||||
<PlexPersonView
|
<PlexPersonView
|
||||||
|
key={personName}
|
||||||
name={personName}
|
name={personName}
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onOpenCard={onCard}
|
onOpenCard={onCard}
|
||||||
|
|||||||
@@ -186,3 +186,68 @@ describe("PlexPersonView — the reader's language (E30)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||||
|
const view = (
|
||||||
|
<PlexPersonView
|
||||||
|
name="Jane Doe"
|
||||||
|
onBack={() => {}}
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
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<HTMLParagraphElement>(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 (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-8"}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{(clipped || expanded) && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="mt-1 text-xs"
|
||||||
|
>
|
||||||
|
{expanded ? t("plex.person.less") : t("plex.person.more")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
// --- 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
|
// 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
|
// 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<PlexCard>();
|
const menu = useContextMenu<PlexCard>();
|
||||||
const lang = plexLang(i18n.language);
|
const lang = plexLang(i18n.language);
|
||||||
const personKey = qk.plexPerson(name, lang);
|
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
|
// 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.
|
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
||||||
const { artBg, crossfade } = useDetailPrefs();
|
const { artBg, crossfade } = useDetailPrefs();
|
||||||
@@ -230,83 +288,106 @@ export function PlexPersonView({
|
|||||||
<LoadingState label={t("plex.loading")} />
|
<LoadingState label={t("plex.loading")} />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
|
{/* Hero (E30 S6, owner pick "A"): the person — headshot, facts, a clamped bio — on the left,
|
||||||
<section className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}>
|
what they are known for on the right, so the bio doesn't sit in a full-width panel with an
|
||||||
<div className="flex items-start gap-4">
|
empty half beside it. One column where the panel is narrow. */}
|
||||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
<section className={`${SURFACE.panel} @container p-4 sm:p-5 mb-6`}>
|
||||||
{d.photo ? (
|
<div
|
||||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
className={
|
||||||
) : (
|
d.known_for.length >= KNOWN_FOR_HALF
|
||||||
<div className="grid h-full w-full place-items-center opacity-40">
|
? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
|
||||||
<User className="h-8 w-8" />
|
: d.known_for.length > 0
|
||||||
|
? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_auto]"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||||
|
{d.photo ? (
|
||||||
|
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
||||||
<div className="min-w-0 flex-1">
|
{d.known_for_department && (
|
||||||
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
<p className="text-xs font-medium text-accent">
|
||||||
{d.known_for_department && (
|
{vocab(t, "department", d.known_for_department)}
|
||||||
<p className="text-xs font-medium text-accent">
|
</p>
|
||||||
{vocab(t, "department", d.known_for_department)}
|
)}
|
||||||
</p>
|
<p className="mt-1 mb-2 text-xs text-muted">
|
||||||
)}
|
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||||
<p className="mt-1 mb-2 text-xs text-muted">
|
|
||||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
|
||||||
</p>
|
|
||||||
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
|
||||||
{(d.birthday || d.place_of_birth) && (
|
|
||||||
<p>
|
|
||||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
|
||||||
{[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
||||||
{d.deathday && (
|
{(d.birthday || d.place_of_birth) && (
|
||||||
<p>
|
<p>
|
||||||
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
|
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||||
{formatDate(d.deathday, lang)}
|
{[formatDate(d.birthday, lang), d.place_of_birth]
|
||||||
</p>
|
.filter(Boolean)
|
||||||
)}
|
.join(" · ")}
|
||||||
{d.also_known_as.length > 0 && (
|
</p>
|
||||||
<p className="truncate">
|
)}
|
||||||
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
{d.deathday && (
|
||||||
{d.also_known_as.slice(0, 4).join(", ")}
|
<p>
|
||||||
</p>
|
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
|
||||||
)}
|
{formatDate(d.deathday, lang)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{d.also_known_as.length > 0 && (
|
||||||
|
<p className="truncate">
|
||||||
|
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
||||||
|
{d.also_known_as.slice(0, 4).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
||||||
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
{d.biography ? (
|
||||||
{t("plex.person.filterBy", { name: d.name })}
|
<PersonBio text={d.biography} />
|
||||||
</Chip>
|
) : (
|
||||||
</div>
|
d.enriching &&
|
||||||
</div>
|
q.dataUpdatedAt - openedAt < BIO_WAIT_MS && (
|
||||||
{d.biography && (
|
<LoadingState
|
||||||
<p className="measure mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
shape="text"
|
||||||
{d.biography}
|
label={t("plex.person.bioLoading")}
|
||||||
</p>
|
className="mt-4"
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
|
|
||||||
{d.known_for.length > 0 && (
|
|
||||||
<section className="mb-5">
|
|
||||||
<Carousel
|
|
||||||
title={t("plex.person.knownFor")}
|
|
||||||
ariaLabel={t("plex.person.knownFor")}
|
|
||||||
keyboardNav
|
|
||||||
>
|
|
||||||
{d.known_for.map((c, i) => (
|
|
||||||
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
|
||||||
<PlexPosterCard
|
|
||||||
shelf
|
|
||||||
card={c}
|
|
||||||
navIndex={i}
|
|
||||||
onOpen={() => onOpenCard(c)}
|
|
||||||
onToggleWatched={toggle}
|
|
||||||
{...cardMenu(c)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
))}
|
)}
|
||||||
</Carousel>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
|
||||||
|
{d.known_for.length > 0 && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Carousel
|
||||||
|
title={t("plex.person.knownFor")}
|
||||||
|
ariaLabel={t("plex.person.knownFor")}
|
||||||
|
keyboardNav
|
||||||
|
>
|
||||||
|
{d.known_for.map((c, i) => (
|
||||||
|
<div key={c.id} className="grid w-32 shrink-0 grid-cols-1">
|
||||||
|
<PlexPosterCard
|
||||||
|
shelf
|
||||||
|
card={c}
|
||||||
|
navIndex={i}
|
||||||
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onToggleWatched={toggle}
|
||||||
|
{...cardMenu(c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
|
||||||
|
|
||||||
export function Skeleton({ className }: { className?: string }) {
|
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<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8 };
|
const COUNT: Record<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8, text: 5 };
|
||||||
|
|
||||||
/** The placeholder for a page's first load, by the shape of its content. */
|
/** The placeholder for a page's first load, by the shape of its content. */
|
||||||
export function SkeletonShapes({ shape }: { shape: SkeletonShape }) {
|
export function SkeletonShapes({ shape }: { shape: SkeletonShape }) {
|
||||||
@@ -46,6 +47,15 @@ export function SkeletonShapes({ shape }: { shape: SkeletonShape }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
if (shape === "text")
|
||||||
|
// A paragraph still on its way (a biography): lines at prose height, the last one short.
|
||||||
|
return (
|
||||||
|
<div className="measure flex w-full flex-col gap-2.5 py-1">
|
||||||
|
{n.map((i) => (
|
||||||
|
<Skeleton key={i} className={`h-3 ${i === n.length - 1 ? "w-2/5" : "w-full"}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
if (shape === "list")
|
if (shape === "list")
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
|||||||
@@ -352,7 +352,10 @@
|
|||||||
"born": "Born",
|
"born": "Born",
|
||||||
"died": "Died",
|
"died": "Died",
|
||||||
"aka": "Also known as",
|
"aka": "Also known as",
|
||||||
"knownFor": "Known for"
|
"knownFor": "Known for",
|
||||||
|
"bioLoading": "Loading the biography",
|
||||||
|
"more": "More",
|
||||||
|
"less": "Less"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"label": "View",
|
"label": "View",
|
||||||
|
|||||||
@@ -352,7 +352,10 @@
|
|||||||
"born": "Született",
|
"born": "Született",
|
||||||
"died": "Elhunyt",
|
"died": "Elhunyt",
|
||||||
"aka": "Más néven",
|
"aka": "Más néven",
|
||||||
"knownFor": "Ismert szerepei"
|
"knownFor": "Ismert szerepei",
|
||||||
|
"bioLoading": "Az életrajz betöltése",
|
||||||
|
"more": "Több",
|
||||||
|
"less": "Kevesebb"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"label": "Nézet",
|
"label": "Nézet",
|
||||||
|
|||||||
Reference in New Issue
Block a user