feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlexFilters } from "../lib/api";
|
||||
import { formatMoney, formatRuntime } from "../lib/format";
|
||||
import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail";
|
||||
import { regionName, vocab } from "../lib/vocab";
|
||||
import { Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or
|
||||
// a show beside its description, inside the hero — as the person page sets its known-for rail beside
|
||||
// the bio (E30 S6) — and its keywords under the text. They used to sit under the hero (movie) or
|
||||
// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A
|
||||
// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same
|
||||
// three prefs on both pages (lib/plexDetail useDetailAside).
|
||||
|
||||
type OnFilter = (patch: Partial<PlexFilters>) => void;
|
||||
|
||||
// The customize menu's rows: one per section that has something (no toggle for an empty one).
|
||||
export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) {
|
||||
const { t } = useTranslation();
|
||||
const label: Record<AsideSection, string> = {
|
||||
facts: t("plex.info.prefFacts"),
|
||||
keywords: t("plex.info.prefKeywords"),
|
||||
crew: t("plex.info.prefCrew"),
|
||||
};
|
||||
return (["facts", "keywords", "crew"] as const)
|
||||
.filter((k) => aside.has[k])
|
||||
.map((k) => (
|
||||
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => aside.toggle(k)} />
|
||||
));
|
||||
}
|
||||
|
||||
// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is
|
||||
// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead
|
||||
// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12
|
||||
// crew, a hole under the text — the measured worst case (E31 S4c).
|
||||
export function DetailAside({
|
||||
facts,
|
||||
shown,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
facts: DetailFacts;
|
||||
shown: DetailAsideState["shown"];
|
||||
onFilter?: OnFilter;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
if (!shown.facts && !shown.crew) return null;
|
||||
return (
|
||||
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
|
||||
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
|
||||
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The structured facts (E5 S5): release / runtime / cert / budget / revenue / studios / countries, from
|
||||
// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain
|
||||
// fact stays flat text (E28 S3h, the S3d1 rule).
|
||||
function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tmdb = facts.tmdb;
|
||||
const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : [];
|
||||
const rows: { key: string; label: string; node: ReactNode }[] = [];
|
||||
// The same values filter here as in the hero's meta line (year, certificate, studios).
|
||||
const year = facts.year;
|
||||
if (year != null)
|
||||
rows.push({
|
||||
key: "year",
|
||||
label: t("plex.info.facts.release"),
|
||||
node: (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||
>
|
||||
{year}
|
||||
</Filterable>
|
||||
),
|
||||
});
|
||||
if (facts.duration_seconds)
|
||||
rows.push({
|
||||
key: "runtime",
|
||||
label: t("plex.info.facts.runtime"),
|
||||
node: formatRuntime(facts.duration_seconds, lang),
|
||||
});
|
||||
const cert = facts.content_rating;
|
||||
if (cert)
|
||||
rows.push({
|
||||
key: "cert",
|
||||
label: t("plex.info.facts.cert"),
|
||||
node: (
|
||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||
{vocab(t, "certificate", cert)}
|
||||
</Filterable>
|
||||
),
|
||||
});
|
||||
if (tmdb?.budget)
|
||||
rows.push({
|
||||
key: "budget",
|
||||
label: t("plex.info.facts.budget"),
|
||||
node: formatMoney(tmdb.budget, lang),
|
||||
});
|
||||
if (tmdb?.revenue)
|
||||
rows.push({
|
||||
key: "revenue",
|
||||
label: t("plex.info.facts.revenue"),
|
||||
node: formatMoney(tmdb.revenue, lang),
|
||||
});
|
||||
if (studios.length)
|
||||
rows.push({
|
||||
key: "studios",
|
||||
label: t("plex.info.facts.studios"),
|
||||
node: (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{studios.map((s) => (
|
||||
<Filterable
|
||||
key={s}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||
>
|
||||
{s}
|
||||
</Filterable>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
if (tmdb?.production_countries?.length)
|
||||
rows.push({
|
||||
key: "countries",
|
||||
label: t("plex.info.facts.countries"),
|
||||
node: tmdb.production_countries
|
||||
.map((c) => regionName(t, c.code, i18n.language, c.name))
|
||||
.join(", "),
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-muted">{r.label}</dt>
|
||||
<dd className="min-w-0 font-medium">{r.node}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword. Under the hero's text.
|
||||
export function DetailKeywords({
|
||||
keywords,
|
||||
onFilter,
|
||||
className,
|
||||
}: {
|
||||
keywords: string[];
|
||||
onFilter?: OnFilter;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section data-testid="detail-keywords" className={className}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((k) => (
|
||||
<Filterable
|
||||
key={k}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
||||
>
|
||||
{k}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It
|
||||
// complements the hero's Director line (which comes from Plex directly).
|
||||
function CrewSection({
|
||||
crew,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
crew: { name: string; job: string }[];
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
|
||||
{crew.map((c, i) => (
|
||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||
{onOpenPerson ? (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => onOpenPerson(c.name)}
|
||||
title={t("plex.info.viewPerson", { name: c.name })}
|
||||
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
|
||||
>
|
||||
{c.name}
|
||||
</Pressable>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{c.name}</div>
|
||||
)}
|
||||
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user