From 46d5572e47f4fe45e8924d677d69d602b428ef08 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 01:01:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20series-page=20polish=20=E2=80=94?= =?UTF-8?q?=20season/episode=20quick=20actions=20+=20glassy=20art=20backdr?= =?UTF-8?q?op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Season cards (show page) gain a hover Play overlay, a clickable title, a quick watched/unwatched toggle (whole season) and an add-whole-season-to-playlist button. - Episode cards gain a hover watched/unwatched quick toggle (single episode) — in the season page and the search Episodes section. - Series show + season pages now use the same glassy HTPC look as the movie info page: a faint fixed art backdrop + a customize menu (toggle the backdrop / hide the cast row), factored into a shared lib/plexDetailUi (useDetailPrefs / useArtBackdrop / DetailCustomizeMenu), reusing the movie info prefs (plexInfoArtBg/plexInfoCast) so the toggle state is consistent across movies and series. --- frontend/src/components/PlexBrowse.tsx | 172 ++++++++++++++++++++++--- frontend/src/lib/plexDetailUi.tsx | 126 ++++++++++++++++++ 2 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/plexDetailUi.tsx diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 46dee7e..5ed5f24 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -28,6 +28,7 @@ import { } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; +import { DetailCustomizeMenu, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; @@ -205,6 +206,10 @@ export default function PlexBrowse({ await api.plexSetState(card.id, next).catch(() => {}); qc.invalidateQueries({ queryKey: ["plex-library"] }); } + async function toggleEpisodeWatched(ep: PlexCard) { + await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed). @@ -331,7 +336,13 @@ export default function PlexBrowse({

{t("plex.unified.episodes")}

{episodes.map((ep) => ( - onCard(ep)} /> + onCard(ep)} + onToggleWatched={() => toggleEpisodeWatched(ep)} + /> ))}
@@ -588,24 +599,78 @@ function ActionBtn({ ); } -function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }) { +function SeasonCard({ + se, + onOpen, + onToggleWatched, + onAdd, +}: { + se: PlexSeasonDetail; + onOpen: () => void; + onToggleWatched?: () => void; + onAdd?: () => void; +}) { const { t } = useTranslation(); + const watched = se.status === "watched"; return ( - + )} + {/* Add whole season to a playlist, on hover. */} + {onAdd && ( + + )} + {watched && ( + {t("plex.watched")} )} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -613,8 +678,10 @@ function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void } {t("plex.series.episodeCount", { count: se.episode_count })} -
{se.title}
- +
+ {se.title} +
+ ); } @@ -693,11 +760,13 @@ function EpisodeCard({ ep, onPlay, onAdd, + onToggleWatched, withShowTitle, }: { ep: PlexCard; onPlay: () => void; onAdd?: () => void; + onToggleWatched?: () => void; withShowTitle?: boolean; }) { const { t } = useTranslation(); @@ -708,9 +777,17 @@ function EpisodeCard({ : 0; return (
-
+ {/* Quick watched toggle (single episode), on hover. */} + {onToggleWatched && ( + + )} {ep.status === "watched" && ( - + )} @@ -731,7 +821,7 @@ function EpisodeCard({
)} - +
{withShowTitle && ep.show_title && ( @@ -791,6 +881,8 @@ 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(); + useArtBackdrop(show?.art, artBg); async function markAll(w: boolean) { setBusy(true); try { @@ -801,9 +893,23 @@ function PlexShowView({ qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: ["plex-library"] }); } + async function markSeason(seasonRk: string, w: boolean) { + await api.plexSeasonState(seasonRk, w).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } return ( -
+
+
+ 0} + /> +
{q.isLoading || !d || !show ? ( @@ -894,11 +1000,26 @@ function PlexShowView({

{t("plex.series.seasons")}

{d.seasons.map((se) => ( - onOpenSeason(se.id)} /> + onOpenSeason(se.id)} + onToggleWatched={() => markSeason(se.id, se.status !== "watched")} + onAdd={ + se.episodes.length > 0 + ? () => + setAddTarget({ + kind: "group", + ratingKeys: se.episodes.map((e) => e.id), + title: `${show.title} — ${se.title}`, + }) + : undefined + } + /> ))}
- {show.cast.length > 0 && } + {show.cast.length > 0 && showCast && } {d.related.length > 0 && } )} @@ -938,6 +1059,8 @@ function PlexSeasonView({ const seasonKeys = se?.episodes.map((e) => e.id) ?? []; const watched = se?.status === "watched"; + const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs(); + useArtBackdrop(d?.show.art, artBg); async function markAll(w: boolean) { setBusy(true); try { @@ -948,9 +1071,23 @@ function PlexSeasonView({ qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: ["plex-library"] }); } + async function markEpisode(ep: PlexCard) { + await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } return ( -
+
+
+ +
{q.isLoading || !d || !se ? ( @@ -1006,6 +1143,7 @@ function PlexSeasonView({ ep={ep} onPlay={() => onPlay(ep.id, seasonKeys)} onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + onToggleWatched={() => markEpisode(ep)} /> ))}
diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx new file mode 100644 index 0000000..eb75073 --- /dev/null +++ b/frontend/src/lib/plexDetailUi.tsx @@ -0,0 +1,126 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { SlidersHorizontal } from "lucide-react"; +import { useDismiss } from "./useDismiss"; +import { api } from "./api"; + +// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, +// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two +// per-user display prefs (art background / cast row — same keys as the movie info page), and the +// customize menu that toggles them. + +export function useDetailPrefs() { + const qc = useQueryClient(); + const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? {}) as { + plexInfoArtBg?: boolean; + plexInfoCast?: boolean; + }; + const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); + const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); + const save = (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, + ); + }; + return { + artBg, + showCast, + toggleArtBg: () => { + const next = !artBg; + setArtBg(next); + save({ plexInfoArtBg: next }); + }, + toggleCast: () => { + const next = !showCast; + setShowCast(next); + save({ plexInfoCast: next }); + }, + }; +} + +// Paint the item's art as a faint FIXED backdrop on the page's
scroll container (HTPC-style), +// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art. +export function useArtBackdrop(art: string | null | undefined, enabled: boolean) { + useLayoutEffect(() => { + const main = document.querySelector("main"); + if (!main) return; + const s = main.style; + const clear = () => { + s.backgroundImage = ""; + s.backgroundSize = ""; + s.backgroundPosition = ""; + s.backgroundRepeat = ""; + s.backgroundAttachment = ""; + }; + if (enabled && art) { + s.backgroundImage = + `linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` + + `color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`; + s.backgroundSize = "cover"; + s.backgroundPosition = "center 20%"; + s.backgroundRepeat = "no-repeat"; + s.backgroundAttachment = "fixed"; + } else { + clear(); + } + return clear; + }, [art, enabled]); +} + +export function DetailCustomizeMenu({ + artBg, + onToggleArtBg, + showCast, + onToggleCast, + hasCast, +}: { + artBg: boolean; + onToggleArtBg: () => void; + showCast: boolean; + onToggleCast: () => void; + hasCast: boolean; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const menuRef = useRef(null); + useDismiss(open, () => setOpen(false), [menuRef, btnRef]); + return ( +
+ + {open && ( +
+ + {hasCast && } +
+ )} +
+ ); +} + +function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { + return ( + + ); +}