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:
2026-10-02 02:13:17 +02:00
parent 98d0ca6eeb
commit 78fdcda6bb
16 changed files with 1526 additions and 867 deletions
+185 -390
View File
@@ -1,14 +1,19 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format";
import { formatCompact, formatDecimal, formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
useDetailPrefs,
} from "../lib/plexDetail";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -21,13 +26,12 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import clsx from "clsx";
import { PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { toggleWatchState } from "../lib/plexWatch";
import { regionName, vocab } from "../lib/vocab";
import { vocab } from "../lib/vocab";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -143,174 +147,6 @@ function RatingsRow({
);
}
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
function FactsPanel({
detail,
onFilter,
}: {
detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = detail.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
// The same values filter here as in the header above (year, certificate, studios).
const year = detail.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 (detail.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(detail.duration_seconds, lang),
});
const cert = detail.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 (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<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>
</div>
);
}
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
function KeywordsPanel({
keywords,
onFilter,
}: {
keywords: string[];
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
if (keywords.length === 0) return null;
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<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>
</div>
);
}
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
// their person page. Complements the hero's Director line (which comes from Plex directly).
function CrewPanel({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* In the main column (layout B): as many name columns as its width holds. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,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>
</div>
);
}
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
@@ -372,10 +208,8 @@ export default function PlexInfo({
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
// The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
const aside = useDetailAside(detail);
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -462,20 +296,7 @@ export default function PlexInfo({
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
const asideShown = !overlay && aside.column;
const infoTitle =
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
@@ -483,7 +304,7 @@ export default function PlexInfo({
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : `${SURFACE.panel} p-4 sm:p-6`}>
<div className={overlay ? "" : `${SURFACE.panel} @container p-4 sm:p-6`}>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
@@ -510,27 +331,7 @@ export default function PlexInfo({
hasCast={detail.cast.length > 0}
extra={
<>
{hasFacts && (
<PrefToggle
label={t("plex.info.prefFacts")}
on={showFacts}
onClick={() => setShowFacts(!showFacts)}
/>
)}
{hasKeywords && (
<PrefToggle
label={t("plex.info.prefKeywords")}
on={showKeywords}
onClick={() => setShowKeywords(!showKeywords)}
/>
)}
{hasCrew && (
<PrefToggle
label={t("plex.info.prefCrew")}
on={showCrew}
onClick={() => setShowCrew(!showCrew)}
/>
)}
{!overlay && <DetailAsideToggles aside={aside} />}
{presentSources.map((src) => (
<PrefToggle
key={src}
@@ -554,171 +355,192 @@ export default function PlexInfo({
)}
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
/>
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
<div
className={
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
}
>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
tag
onDark={overlay}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
tag
onDark={overlay}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{vocab(t, "certificate", detail.content_rating)}
</Filterable>
)}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/>
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
key={g}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
title={
onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined
}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{g}
{detail.year}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
)}
{detail.content_rating && (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{d}
{vocab(t, "certificate", detail.content_rating)}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
{inProgress ? t("plex.resume") : t("plex.play")}
</Button>
)}
{detail.status === "watched" ? (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.markUnwatched")}
</Button>
) : (
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
{t("plex.info.markWatched")}
</Button>
)}
{inProgress && (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.clearResume")}
</Button>
)}
{detail.kind === "movie" && (
<Button
variant="glass"
onClick={() => setPlaylistOpen(true)}
icon={<ListPlus />}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{t("plex.playlistAdd.manage")}
</Button>
)}
{isAdmin && detail.kind === "movie" && library && (
<Button
variant="glass"
onClick={() => setEditorOpen(true)}
icon={<FolderPlus />}
>
{t("plex.collEditor.manage")}
</Button>
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
)}
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/>
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
{inProgress ? t("plex.resume") : t("plex.play")}
</Button>
)}
{detail.status === "watched" ? (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.markUnwatched")}
</Button>
) : (
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
{t("plex.info.markWatched")}
</Button>
)}
{inProgress && (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.clearResume")}
</Button>
)}
{detail.kind === "movie" && (
<Button
variant="glass"
onClick={() => setPlaylistOpen(true)}
icon={<ListPlus />}
>
{t("plex.playlistAdd.manage")}
</Button>
)}
{isAdmin && detail.kind === "movie" && library && (
<Button
variant="glass"
onClick={() => setEditorOpen(true)}
icon={<FolderPlus />}
>
{t("plex.collEditor.manage")}
</Button>
)}
</div>
)}
{!overlay && aside.shown.keywords && (
<DetailKeywords
keywords={detail.tmdb?.keywords ?? []}
onFilter={onFilter}
className="mt-5"
/>
)}
</div>
</div>
{asideShown && (
<DetailAside
facts={detail}
shown={aside.shown}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
)}
</div>
</div>
{/* End hero panel. */}
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
strips first (the owner's most used), then the crew, then the cast — beside a narrow
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
toggleable, so the layout is built from what is ON: a column with nothing in it is not
rendered and the other takes the full width; inside a column the sections stack, so a
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
the running player) is the same stack, lean. */}
{(() => {
const collections = (detail.collections ?? []).filter(
(col) => !hiddenSources.includes(col.source),
@@ -845,38 +667,11 @@ export default function PlexInfo({
{castRow}
</>
);
const crewBox = showCrew && hasCrew && (
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
);
const factsBox = showFacts && hasFacts && (
<FactsPanel detail={detail} onFilter={onFilter} />
);
const keywordsBox = showKeywords && hasKeywords && (
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
);
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
const hasSide = !!factsBox || !!keywordsBox;
if (!hasMain && !hasSide) return null;
if (strips.length === 0 && !castRow) return null;
return (
<div
className={clsx(
"grid items-start gap-3",
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
)}
>
{hasMain && (
<div className="flex min-w-0 flex-col gap-3">
{strips}
{crewBox}
{castRow}
</div>
)}
{hasSide && (
<div className="flex min-w-0 flex-col gap-3">
{factsBox}
{keywordsBox}
</div>
)}
<div className="flex min-w-0 flex-col gap-3">
{strips}
{castRow}
</div>
);
})()}