fix(plex): series-page UX fixes from UAT (7 items)

1. Series detail pages now match the movie info page: the customize menu lives in the
   hero panel (top-right), the Cast/Seasons/Related strips sit in glassy panels, and the
   menu is dynamic — Related shows can be toggled (new plexInfoRelated pref).
2. The Plex search box now survives F5 (persisted per-account like the filters).
3. Show + season hero posters get a hover Play/Resume overlay.
4. Card top-right watched toggle can now UN-watch: the watched/in-progress badges were
   intercepting the click (no pointer-events-none), so the toggle underneath never fired.
5. Episode card: the watched check no longer jumps on hover (badge now shares the
   toggle's box); every card's title/meta gets its own glassy translucent background and
   the Add-to-playlist button is always visible (not hover-only).
6. Season-card 'In progress' label is readable again (solid dark badge over the poster).
7. On a season page the show title is clickable → back to the show (history-correct).
This commit is contained in:
npeter83 2026-07-11 01:34:30 +02:00
parent 46d5572e47
commit 186fdbb0e5
7 changed files with 144 additions and 71 deletions

View file

@ -268,7 +268,7 @@ export default function App() {
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useState("");
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);

View file

@ -442,7 +442,7 @@ function PlexPosterCard({
)}
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
{card.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.watched")}
</span>
)}
@ -453,7 +453,7 @@ function PlexPosterCard({
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
@ -478,12 +478,13 @@ function PlexPosterCard({
</div>
<div
onClick={onOpen}
className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
{card.title}
</div>
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</div>
</div>
);
}
@ -665,12 +666,12 @@ function SeasonCard({
</button>
)}
{watched && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.watched")}
</span>
)}
{se.status === "in_progress" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
@ -678,7 +679,11 @@ function SeasonCard({
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<div onClick={onOpen} className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent">
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
{se.title}
</div>
</div>
@ -688,7 +693,10 @@ function SeasonCard({
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
const { t } = useTranslation();
return (
<section className="mb-8">
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{cast.map((c, i) => {
@ -730,7 +738,10 @@ function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (pat
function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) {
const { t } = useTranslation();
return (
<section className="mb-8">
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{related.map((r) => (
@ -812,7 +823,11 @@ function EpisodeCard({
</button>
)}
{ep.status === "watched" && (
<span className={`absolute top-1.5 right-1.5 ${onToggleWatched ? "group-hover:opacity-0" : ""}`}>
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched ? "group-hover:opacity-0" : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</span>
)}
@ -822,7 +837,10 @@ function EpisodeCard({
</div>
)}
</div>
<div className="mt-1.5 flex items-start gap-1.5">
<div
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">
{withShowTitle && ep.show_title && (
<div className="text-[11px] text-muted truncate">
@ -843,7 +861,7 @@ function EpisodeCard({
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
className="shrink-0 rounded p-1 text-muted transition hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
@ -881,7 +899,7 @@ function PlexShowView({
const showLib = show?.library ?? undefined;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
useArtBackdrop(show?.art, artBg);
async function markAll(w: boolean) {
setBusy(true);
@ -900,16 +918,7 @@ function PlexShowView({
}
return (
<div className="relative p-4 max-w-[1200px] mx-auto">
<div className="absolute right-4 top-4 z-10">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={(show?.cast.length ?? 0) > 0}
/>
</div>
<div className="p-4 max-w-[1200px] mx-auto">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? (
@ -917,12 +926,36 @@ function PlexShowView({
) : (
<>
{/* Hero */}
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
/>
</div>
<button
onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
disabled={!show.resume}
className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
title={show.status === "new" ? t("plex.play") : t("plex.resume")}
>
<img
src={show.thumb}
alt=""
className="w-32 sm:w-44 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
/>
{show.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
@ -996,9 +1029,13 @@ function PlexShowView({
</div>
</div>
{/* Seasons */}
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 mb-8">
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => (
<SeasonCard
key={se.id}
@ -1018,9 +1055,10 @@ function PlexShowView({
/>
))}
</div>
</section>
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
{d.related.length > 0 && showRelated && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
</>
)}
@ -1078,8 +1116,15 @@ function PlexSeasonView({
}
return (
<div className="relative p-4 max-w-[1200px] mx-auto">
<div className="absolute right-4 top-4 z-10">
<div className="p-4 max-w-[1200px] mx-auto">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
@ -1088,20 +1133,27 @@ function PlexSeasonView({
hasCast={false}
/>
</div>
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<button
onClick={() => se.resume && onPlay(se.resume.id, seasonKeys)}
disabled={!se.resume}
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
>
<img
src={se.thumb}
alt=""
className="w-28 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
/>
{se.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<p className="text-sm text-muted">{d.show.title}</p>
<button onClick={onBack} className="text-sm text-muted hover:text-accent transition text-left">
{d.show.title}
</button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">{t("plex.series.episodeCount", { count: se.episode_count })}</p>
<div className="mt-4 flex flex-wrap gap-2">

View file

@ -181,6 +181,7 @@
"customize": "Ansicht anpassen",
"prefArtBg": "Dezenter Hintergrund",
"prefCast": "Besetzung",
"prefRelated": "Ähnliche Serien",
"stripSource": {
"collection": "Sammlungen",
"imdb": "IMDb",

View file

@ -181,6 +181,7 @@
"customize": "Customize view",
"prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew",
"prefRelated": "Related shows",
"stripSource": {
"collection": "Collections",
"imdb": "IMDb",

View file

@ -181,6 +181,7 @@
"customize": "Nézet testreszabása",
"prefArtBg": "Halvány háttér",
"prefCast": "Szereplők",
"prefRelated": "Kapcsolódó sorozatok",
"stripSource": {
"collection": "Kollekciók",
"imdb": "IMDb",

View file

@ -15,9 +15,11 @@ export function useDetailPrefs() {
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
plexInfoArtBg?: boolean;
plexInfoCast?: boolean;
plexInfoRelated?: boolean;
};
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
const save = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
@ -27,6 +29,7 @@ export function useDetailPrefs() {
return {
artBg,
showCast,
showRelated,
toggleArtBg: () => {
const next = !artBg;
setArtBg(next);
@ -37,6 +40,11 @@ export function useDetailPrefs() {
setShowCast(next);
save({ plexInfoCast: next });
},
toggleRelated: () => {
const next = !showRelated;
setShowRelated(next);
save({ plexInfoRelated: next });
},
};
}
@ -75,12 +83,18 @@ export function DetailCustomizeMenu({
showCast,
onToggleCast,
hasCast,
showRelated,
onToggleRelated,
hasRelated,
}: {
artBg: boolean;
onToggleArtBg: () => void;
showCast: boolean;
onToggleCast: () => void;
hasCast: boolean;
showRelated?: boolean;
onToggleRelated?: () => void;
hasRelated?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@ -105,6 +119,9 @@ export function DetailCustomizeMenu({
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
{hasRelated && onToggleRelated && (
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
)}
</div>
)}
</div>

View file

@ -27,6 +27,7 @@ export const LS = {
plexShow: "siftlode.plexShow",
plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",