diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4ec3fc5a..96e046c9 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,77 +1,39 @@ -import { - lazy, - Suspense, - useEffect, - useLayoutEffect, - useRef, - useState, - type CSSProperties, - type ReactNode, -} from "react"; -import { qk } from "../lib/queryKeys"; -import { healPlexField } from "../lib/plexSearch"; -import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; -import SortControl from "./SortControl"; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { plexLang } from "../i18n"; -import { pickRatingBadge } from "../lib/plexRating"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData, } from "@tanstack/react-query"; -import { - ArrowLeft, - Check, - CheckCheck, - CheckCircle2, - Film, - Info, - Layers, - ListPlus, - Play, - RotateCcw, - Star, - Tv2, - User, - X, - type LucideIcon, -} from "lucide-react"; +import { User, X } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { healPlexField } from "../lib/plexSearch"; +import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexCard, - type PlexCastMember, type PlexFilters, - type PlexPersonDetail, - type PlexSeasonDetail, type PlexUnifiedResult, } from "../lib/api"; -import { formatRuntime } from "../lib/format"; -import { notifyActionError } from "../lib/actionErrors"; +import { plexLang } from "../i18n"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview, type SubviewUrl } from "../lib/history"; -import { - DetailCustomizeMenu, - Filterable, - useArtBackdrop, - useDetailPrefs, -} from "../lib/plexDetailUi"; -import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; -import PlexCollectionEditor from "./PlexCollectionEditor"; -import { LoadingState, StateMessage } from "./QueryState"; +import { toggleWatchState } from "../lib/plexWatch"; +import SortControl from "./SortControl"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; +import { StateMessage } from "./QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; +import { EpisodeCard, PlexPosterCard } from "./plexCards"; +import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); -const PlexInfo = lazy(() => import("./PlexInfo")); const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); type Sub = @@ -130,30 +92,11 @@ const PLEX_SUB_URL: SubviewUrl = { // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the // search term comes from the shared top Header search box (q). A show drill-down rides history -// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces -// for now. Rendered by App for page==="plex" (its own nav module). +// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own +// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews. const PAGE = 40; -// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache -// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then -// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the -// browse grid and the person page, each of which owns a different cache shape. -async function toggleWatchState( - card: PlexCard, - t: TFunction, - handlers: { optimistic: (next: string) => void; resync: () => void }, -) { - const next = card.status === "watched" ? "new" : "watched"; - handlers.optimistic(next); - try { - await api.plexSetState(card.id, next); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - handlers.resync(); -} - export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); @@ -454,7 +397,7 @@ export default function PlexBrowse() {

{browseQ.isLoading - ? " " + ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })} @@ -593,1158 +536,3 @@ export default function PlexBrowse() {

); } - -// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage -// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; -// all three stay visually distinct. -const PLAYABLE_TINT: Record = { - direct: "bg-success", - remux: "bg-warn", - transcode: "bg-danger", -}; - -function PlexPosterCard({ - card, - onOpen, - onInfo, - onToggleWatched, -}: { - card: PlexCard; - onOpen: () => void; - onInfo: () => void; - onToggleWatched: (c: PlexCard) => void; -}) { - const { t } = useTranslation(); - const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; - const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down - const pct = - inProgress && card.duration_seconds - ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) - : 0; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" - > - - {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} -
-
- - - {card.type === "show" - ? t("plex.openShow") - : inProgress - ? t("plex.resume") - : t("plex.play")} - -
-
- {/* Quick watched toggle (movies/episodes), on hover. */} - {isPlayable && ( - - )} - {/* Media info page (movies/episodes). */} - {isPlayable && ( - - )} - {/* Watched badge when not hovering (the toggle replaces it on hover). */} - {card.status === "watched" && ( - - {t("plex.watched")} - - )} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} - {/* Aggregate in-progress badge for a partially-watched show. */} - {card.type === "show" && card.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - {card.playable && ( - - )} - {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} - - {card.type === "show" ? : } - - {pct > 0 && ( -
-
-
- )} -
-
-
- {card.title} -
-
- {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")} -
-
-
- ); -} - -function PlexInfoView({ - id, - onBack, - onPlay, - onPlayItem, - onFilter, - onOpenPerson, -}: { - id: string; - onBack: () => void; - onPlay: () => void; - onPlayItem: (id: string) => void; - onFilter: (patch: Partial) => void; - onOpenPerson: (name: string) => void; -}) { - const { t } = useTranslation(); - const lang = plexLang(); - const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); - return ( -
-
- -
- {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !q.data ? ( - - ) : ( - {t("plex.loading")}

}> - -
- )} -
- ); -} - -// --- 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 -// shows the person's headshot + name, a "filter the library by them" action, and their titles as two -// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape. -export function PlexPersonView({ - name, - onBack, - onOpenCard, - onInfoCard, - onFilter, -}: { - name: string; - onBack: () => void; - onOpenCard: (card: PlexCard) => void; - onInfoCard: (card: PlexCard) => void; - onFilter: (patch: Partial) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); - - // Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload - // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared - // helper; also invalidate the library so its grid agrees. - const toggle = (card: PlexCard) => - toggleWatchState(card, t, { - optimistic: (next) => - qc.setQueryData(qk.plexPerson(name), (old) => - old - ? { - ...old, - movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)), - shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)), - } - : old, - ), - resync: () => { - qc.invalidateQueries({ queryKey: qk.plexPerson(name) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - }, - }); - - const section = (titleKey: string, cards: PlexCard[]) => - cards.length > 0 ? ( -
-

{t(titleKey, { count: cards.length })}

-
- {cards.map((c) => ( - onOpenCard(c)} - onInfo={() => onInfoCard(c)} - onToggleWatched={toggle} - /> - ))} -
-
- ) : null; - - const d = q.data; - const empty = !!d && d.movies.length === 0 && d.shows.length === 0; - return ( -
-
- -
- {q.isError && !d ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d ? ( - - ) : ( -
- {/* Hero: headshot + name + a "filter the library by this person" action. */} -
-
- {d.photo ? ( - - ) : ( -
- -
- )} -
-
-

{d.name}

-

- {t("plex.person.credits", { - movies: d.movies.length, - shows: d.shows.length, - })} -

- -
-
- {empty ? ( -

{t("plex.person.empty")}

- ) : ( - <> - {section("plex.person.movies", d.movies)} - {section("plex.person.shows", d.shows)} - - )} -
- )} -
- ); -} - -// --- Series drill-down: show detail page → season subpage → player ------------------------------- -// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the -// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's -// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start), -// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only — -// Add the whole show to a collection. - -function epLabel(ep?: PlexCard | null): string | undefined { - if (!ep) return undefined; - if (ep.season_number != null && ep.episode_number != null) - return `S${ep.season_number} · E${ep.episode_number}`; - return ep.title; -} - -function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { - return ( - - ); -} - -function ActionBtn({ - onClick, - icon: Icon, - label, - sub, - primary, - disabled, -}: { - onClick: () => void; - icon: LucideIcon; - label: string; - sub?: string; - primary?: boolean; - disabled?: boolean; -}) { - return ( - - ); -} - -function SeasonCard({ - se, - onOpen, - onToggleWatched, - onAdd, -}: { - se: PlexSeasonDetail; - onOpen: () => void; - onToggleWatched?: () => void; - onAdd?: () => void; -}) { - const { t } = useTranslation(); - const watched = se.status === "watched"; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" - > - -
-
- - {t("plex.openShow")} -
-
- {/* Quick watched toggle (whole season), on hover. */} - {onToggleWatched && ( - - )} - {/* Add whole season to a playlist, on hover. */} - {onAdd && ( - - )} - {watched && ( - - {t("plex.watched")} - - )} - {se.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - - {t("plex.series.episodeCount", { count: se.episode_count })} - -
-
- {se.title} -
-
- ); -} - -export function CastStrip({ - cast, - onFilter, - onOpenPerson, -}: { - cast: PlexCastMember[]; - onFilter?: (patch: Partial) => void; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - const clickable = !!(onOpenPerson || onFilter); - return ( -
-

{t("plex.info.cast")}

-
- {cast.map((c, i) => { - const inner = ( - <> -
- {c.thumb ? ( - - ) : ( -
- {c.name.charAt(0)} -
- )} -
-
- {c.name} -
- {c.role &&
{c.role}
} - - ); - return clickable ? ( - - ) : ( -
- {inner} -
- ); - })} -
-
- ); -} - -function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) { - const { t } = useTranslation(); - return ( -
-

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

-
- {related.map((r) => ( - - ))} -
-
- ); -} - -function EpisodeCard({ - ep, - onPlay, - onAdd, - onToggleWatched, - withShowTitle, -}: { - ep: PlexCard; - onPlay: () => void; - onAdd?: () => void; - onToggleWatched?: () => void; - withShowTitle?: boolean; -}) { - const { t } = useTranslation(); - const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; - const pct = - inProgress && ep.duration_seconds - ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) - : 0; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onPlay(); - } - }} - className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" - > - -
- -
- {/* Quick watched toggle (single episode), on hover. */} - {onToggleWatched && ( - - )} - {ep.status === "watched" && ( - - - - )} - {pct > 0 && ( -
-
-
- )} -
-
-
- {withShowTitle && ep.show_title && ( -
- {ep.show_title} - {ep.season_number != null && ep.episode_number != null - ? ` · S${ep.season_number}·E${ep.episode_number}` - : ""} -
- )} -
- {!withShowTitle && {ep.episode_number}.} - {ep.title} -
-
{formatRuntime(ep.duration_seconds)}
-
- {onAdd && ( - - )} -
-
- ); -} - -/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were - * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, - * and the flex info column; the caller passes the customize menu + the title/metadata/actions as - * `children`. Poster is slightly larger on the show page (default) than the season page. */ -function DetailHero({ - thumb, - posterTitle, - posterDisabled, - showPlayOverlay, - onPosterClick, - posterSizeClass = "w-32 sm:w-44", - customizeMenu, - children, -}: { - thumb: string; - posterTitle: string; - posterDisabled: boolean; - showPlayOverlay: boolean; - onPosterClick: () => void; - posterSizeClass?: string; - customizeMenu: ReactNode; - children: ReactNode; -}) { - return ( -
-
{customizeMenu}
- -
{children}
-
- ); -} - -/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both - * the show and season heroes; the labels differ, so the caller passes both. */ -function WatchedToggle({ - watched, - busy, - onToggle, - watchedLabel, - unwatchedLabel, -}: { - watched: boolean; - busy?: boolean; - onToggle: () => void; - watchedLabel: string; - unwatchedLabel: string; -}) { - return ( - - ); -} - -function PlexShowView({ - showId, - onBack, - onPlay, - onOpenSeason, - onOpenShow, - onFilter, - onOpenPerson, -}: { - showId: string; - onBack: () => void; - onPlay: (epRk: string, queue: string[]) => void; - onOpenSeason: (seasonId: string) => void; - onOpenShow: (id: string) => void; - onFilter?: (patch: Partial) => void; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; - const lang = plexLang(); - const q = useQuery({ - queryKey: qk.plexShow(showId, lang), - queryFn: () => api.plexShow(showId, lang), - }); - const d = q.data; - const [addTarget, setAddTarget] = useState(null); - const [collOpen, setCollOpen] = useState(false); - const [busy, setBusy] = useState(false); - - const show = d?.show; - 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, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); - useArtBackdrop(show?.art, artBg); - async function markAll(w: boolean) { - setBusy(true); - try { - await api.plexShowState(showId, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } finally { - setBusy(false); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - async function markSeason(seasonRk: string, w: boolean) { - try { - await api.plexSeasonState(seasonRk, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - - return ( -
- - - {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d || !show ? ( - - ) : ( - <> - {/* Hero */} - show.resume && onPlay(show.resume.id, allKeys)} - customizeMenu={ - 0} - showRelated={showRelated} - onToggleRelated={toggleRelated} - hasRelated={d.related.length > 0} - /> - } - > -

{show.title}

-
- {show.year != null && ( - onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } - > - {show.year} - - )} - {show.content_rating && ( - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - · {show.content_rating} - - )} - {show.season_count != null && ( - · {t("plex.seasons", { count: show.season_count })} - )} - {(() => { - // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show - // with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a - // rating instead of dropping the badge entirely. - const badge = pickRatingBadge(show.imdb_rating, show.rating); - if (!badge) return null; - const { score } = badge; - return ( - - ); - })()} -
- {show.genres.length > 0 && ( -
- {show.genres.map((g) => ( - onFilter({ genres: [g] }) : undefined} - > - {g} - - ))} -
- )} - {show.summary && ( -

{show.summary}

- )} -
- {show.resume && ( - onPlay(show.resume!.id, allKeys)} - icon={Play} - label={show.status === "new" ? t("plex.play") : t("plex.resume")} - sub={epLabel(show.resume)} - /> - )} - {show.first && show.status !== "new" && ( - onPlay(show.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - watchedLabel={t("plex.series.markShowUnwatched")} - unwatchedLabel={t("plex.series.markShowWatched")} - /> - {allKeys.length > 0 && ( - - setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) - } - icon={ListPlus} - label={t("plex.playlist.addShow")} - /> - )} - {isAdmin && showLib && ( - setCollOpen(true)} - icon={Layers} - label={t("plex.series.addShowCollection")} - /> - )} -
-
- - {/* Seasons — in a glassy panel like the movie info-page strips. */} -
-

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

-
- {d.seasons.map((se) => ( - 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 && showCast && ( - - )} - {d.related.length > 0 && showRelated && ( - - )} - - )} - - {addTarget && setAddTarget(null)} />} - {collOpen && show && showLib && ( - setCollOpen(false)} - onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} - /> - )} -
- ); -} - -function PlexSeasonView({ - showId, - seasonId, - onBack, - onPlay, -}: { - showId: string; - seasonId: string; - onBack: () => void; - onPlay: (epRk: string, queue: string[]) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const lang = plexLang(); - const q = useQuery({ - queryKey: qk.plexShow(showId, lang), - queryFn: () => api.plexShow(showId, lang), - }); - const d = q.data; - const se = d?.seasons.find((s) => s.id === seasonId); - const [addTarget, setAddTarget] = useState(null); - const [busy, setBusy] = useState(false); - - const seasonKeys = se?.episodes.map((e) => e.id) ?? []; - // Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls - // straight into the next season's first — the same cross-season order the show page already uses. - // seasonKeys stays only for the season-scoped "add to playlist" action. - const allKeys = (d?.seasons ?? []).flatMap((s) => s.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 { - await api.plexSeasonState(seasonId, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } finally { - setBusy(false); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - async function markEpisode(ep: PlexCard) { - try { - await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched"); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - - return ( -
- - - {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d || !se ? ( - - ) : ( - <> - se.resume && onPlay(se.resume.id, allKeys)} - posterSizeClass="w-28 sm:w-40" - customizeMenu={ - - } - > - -

{se.title}

-

- {t("plex.series.episodeCount", { count: se.episode_count })} -

-
- {se.resume && ( - onPlay(se.resume!.id, allKeys)} - icon={Play} - label={se.status === "new" ? t("plex.play") : t("plex.resume")} - sub={epLabel(se.resume)} - /> - )} - {se.first && se.status !== "new" && ( - onPlay(se.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - watchedLabel={t("plex.series.markSeasonUnwatched")} - unwatchedLabel={t("plex.series.markSeasonWatched")} - /> - {seasonKeys.length > 0 && ( - - setAddTarget({ - kind: "group", - ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, - }) - } - icon={ListPlus} - label={t("plex.playlist.addSeason")} - /> - )} -
-
- -
- {se.episodes.map((ep) => ( - onPlay(ep.id, allKeys)} - onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} - onToggleWatched={() => markEpisode(ep)} - /> - ))} -
- - )} - - {addTarget && setAddTarget(null)} />} -
- ); -} diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 6a70be91..f91c2b0e 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({ plexFilterCount: () => 0, })); -import { CastStrip, PlexPersonView } from "./PlexBrowse"; +import { CastStrip } from "./plexCards"; +import { PlexPersonView } from "./plexViews"; afterEach(cleanup); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx new file mode 100644 index 00000000..1e2b7b84 --- /dev/null +++ b/frontend/src/components/plexCards.tsx @@ -0,0 +1,463 @@ +import { useTranslation } from "react-i18next"; +import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; +import { type CSSProperties } from "react"; +import { formatRuntime } from "../lib/format"; +import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; + +// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared +// state). Rendered by the browse grid, the person page, and the series show/season views. + +// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage +// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; +// all three stay visually distinct. +const PLAYABLE_TINT: Record = { + direct: "bg-success", + remux: "bg-warn", + transcode: "bg-danger", +}; + +export function PlexPosterCard({ + card, + onOpen, + onInfo, + onToggleWatched, +}: { + card: PlexCard; + onOpen: () => void; + onInfo: () => void; + onToggleWatched: (c: PlexCard) => void; +}) { + const { t } = useTranslation(); + const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; + const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down + const pct = + inProgress && card.duration_seconds + ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) + : 0; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" + > + + {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} +
+
+ + + {card.type === "show" + ? t("plex.openShow") + : inProgress + ? t("plex.resume") + : t("plex.play")} + +
+
+ {/* Quick watched toggle (movies/episodes), on hover. */} + {isPlayable && ( + + )} + {/* Media info page (movies/episodes). */} + {isPlayable && ( + + )} + {/* Watched badge when not hovering (the toggle replaces it on hover). */} + {card.status === "watched" && ( + + {t("plex.watched")} + + )} + {card.type === "show" && card.season_count != null && ( + + {t("plex.seasons", { count: card.season_count })} + + )} + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + {card.playable && ( + + )} + {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} + + {card.type === "show" ? : } + + {pct > 0 && ( +
+
+
+ )} +
+
+
+ {card.title} +
+
+ {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")} +
+
+
+ ); +} + +export function SeasonCard({ + se, + onOpen, + onToggleWatched, + onAdd, +}: { + se: PlexSeasonDetail; + onOpen: () => void; + onToggleWatched?: () => void; + onAdd?: () => void; +}) { + const { t } = useTranslation(); + const watched = se.status === "watched"; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" + > + +
+
+ + {t("plex.openShow")} +
+
+ {/* Quick watched toggle (whole season), on hover. */} + {onToggleWatched && ( + + )} + {/* Add whole season to a playlist, on hover. */} + {onAdd && ( + + )} + {watched && ( + + {t("plex.watched")} + + )} + {se.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + + {t("plex.series.episodeCount", { count: se.episode_count })} + +
+
+ {se.title} +
+
+ ); +} + +export function CastStrip({ + cast, + onFilter, + onOpenPerson, +}: { + cast: PlexCastMember[]; + onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + const clickable = !!(onOpenPerson || onFilter); + return ( +
+

{t("plex.info.cast")}

+
+ {cast.map((c, i) => { + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)} +
+ )} +
+
+ {c.name} +
+ {c.role &&
{c.role}
} + + ); + return clickable ? ( + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); +} + +export function RelatedStrip({ + related, + onOpen, +}: { + related: PlexCard[]; + onOpen: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+

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

+
+ {related.map((r) => ( + + ))} +
+
+ ); +} + +export function EpisodeCard({ + ep, + onPlay, + onAdd, + onToggleWatched, + withShowTitle, +}: { + ep: PlexCard; + onPlay: () => void; + onAdd?: () => void; + onToggleWatched?: () => void; + withShowTitle?: boolean; +}) { + const { t } = useTranslation(); + const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + const pct = + inProgress && ep.duration_seconds + ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) + : 0; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onPlay(); + } + }} + className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" + > + +
+ +
+ {/* Quick watched toggle (single episode), on hover. */} + {onToggleWatched && ( + + )} + {ep.status === "watched" && ( + + + + )} + {pct > 0 && ( +
+
+
+ )} +
+
+
+ {withShowTitle && ep.show_title && ( +
+ {ep.show_title} + {ep.season_number != null && ep.episode_number != null + ? ` · S${ep.season_number}·E${ep.episode_number}` + : ""} +
+ )} +
+ {!withShowTitle && {ep.episode_number}.} + {ep.title} +
+
{formatRuntime(ep.duration_seconds)}
+
+ {onAdd && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx new file mode 100644 index 00000000..c89071eb --- /dev/null +++ b/frontend/src/components/plexViews.tsx @@ -0,0 +1,615 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api"; +import { plexLang } from "../i18n"; +import { pickRatingBadge } from "../lib/plexRating"; +import { notifyActionError } from "../lib/actionErrors"; +import { toggleWatchState } from "../lib/plexWatch"; +import { + ActionBtn, + BackBtn, + DetailCustomizeMenu, + DetailHero, + epLabel, + Filterable, + useArtBackdrop, + useDetailPrefs, + WatchedToggle, +} from "../lib/plexDetailUi"; +import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; +import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; +import PlexCollectionEditor from "./PlexCollectionEditor"; +import { LoadingState, StateMessage } from "./QueryState"; + +// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with +// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, +// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared +// detail-page UI from lib/plexDetailUi. + +// Lazy: the rich movie info panel loads only when an info page is opened. +const PlexInfo = lazy(() => import("./PlexInfo")); + +export function PlexInfoView({ + id, + onBack, + onPlay, + onPlayItem, + onFilter, + onOpenPerson, +}: { + id: string; + onBack: () => void; + onPlay: () => void; + onPlayItem: (id: string) => void; + onFilter: (patch: Partial) => void; + onOpenPerson: (name: string) => void; +}) { + const { t } = useTranslation(); + const lang = plexLang(); + const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); + return ( +
+
+ +
+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !q.data ? ( + + ) : ( + {t("plex.loading")}

}> + +
+ )} +
+ ); +} + +// --- 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 +// shows the person's headshot + name, a "filter the library by them" action, and their titles as two +// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape. +export function PlexPersonView({ + name, + onBack, + onOpenCard, + onInfoCard, + onFilter, +}: { + name: string; + onBack: () => void; + onOpenCard: (card: PlexCard) => void; + onInfoCard: (card: PlexCard) => void; + onFilter: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); + + // Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload + // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared + // helper; also invalidate the library so its grid agrees. + const toggle = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => + qc.setQueryData(qk.plexPerson(name), (old) => + old + ? { + ...old, + movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)), + shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)), + } + : old, + ), + resync: () => { + qc.invalidateQueries({ queryKey: qk.plexPerson(name) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + }, + }); + + const section = (titleKey: string, cards: PlexCard[]) => + cards.length > 0 ? ( +
+

{t(titleKey, { count: cards.length })}

+
+ {cards.map((c) => ( + onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggle} + /> + ))} +
+
+ ) : null; + + const d = q.data; + const empty = !!d && d.movies.length === 0 && d.shows.length === 0; + return ( +
+
+ +
+ {q.isError && !d ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( +
+ {/* Hero: headshot + name + a "filter the library by this person" action. */} +
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+

+ {t("plex.person.credits", { + movies: d.movies.length, + shows: d.shows.length, + })} +

+ +
+
+ {empty ? ( +

{t("plex.person.empty")}

+ ) : ( + <> + {section("plex.person.movies", d.movies)} + {section("plex.person.shows", d.shows)} + + )} +
+ )} +
+ ); +} + +// --- Series drill-down: show detail page → season subpage → player ------------------------------- +// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the +// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's +// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start), +// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only — +// Add the whole show to a collection. + +export function PlexShowView({ + showId, + onBack, + onPlay, + onOpenSeason, + onOpenShow, + onFilter, + onOpenPerson, +}: { + showId: string; + onBack: () => void; + onPlay: (epRk: string, queue: string[]) => void; + onOpenSeason: (seasonId: string) => void; + onOpenShow: (id: string) => void; + onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexShow(showId, lang), + queryFn: () => api.plexShow(showId, lang), + }); + const d = q.data; + const [addTarget, setAddTarget] = useState(null); + const [collOpen, setCollOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const show = d?.show; + 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, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); + useArtBackdrop(show?.art, artBg); + async function markAll(w: boolean) { + setBusy(true); + try { + await api.plexShowState(showId, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + async function markSeason(seasonRk: string, w: boolean) { + try { + await api.plexSeasonState(seasonRk, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + + return ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !show ? ( + + ) : ( + <> + {/* Hero */} + show.resume && onPlay(show.resume.id, allKeys)} + customizeMenu={ + 0} + showRelated={showRelated} + onToggleRelated={toggleRelated} + hasRelated={d.related.length > 0} + /> + } + > +

{show.title}

+
+ {show.year != null && ( + onFilter({ yearMin: show.year, yearMax: show.year }) + : undefined + } + > + {show.year} + + )} + {show.content_rating && ( + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + · {show.content_rating} + + )} + {show.season_count != null && ( + · {t("plex.seasons", { count: show.season_count })} + )} + {(() => { + // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show + // with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a + // rating instead of dropping the badge entirely. + const badge = pickRatingBadge(show.imdb_rating, show.rating); + if (!badge) return null; + const { score } = badge; + return ( + + ); + })()} +
+ {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + onPlay(show.resume!.id, allKeys)} + icon={Play} + label={show.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(show.resume)} + /> + )} + {show.first && show.status !== "new" && ( + onPlay(show.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markShowUnwatched")} + unwatchedLabel={t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) + } + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && showLib && ( + setCollOpen(true)} + icon={Layers} + label={t("plex.series.addShowCollection")} + /> + )} +
+
+ + {/* Seasons — in a glassy panel like the movie info-page strips. */} +
+

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

+
+ {d.seasons.map((se) => ( + 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 && showCast && ( + + )} + {d.related.length > 0 && showRelated && ( + + )} + + )} + + {addTarget && setAddTarget(null)} />} + {collOpen && show && showLib && ( + setCollOpen(false)} + onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} + /> + )} +
+ ); +} + +export function PlexSeasonView({ + showId, + seasonId, + onBack, + onPlay, +}: { + showId: string; + seasonId: string; + onBack: () => void; + onPlay: (epRk: string, queue: string[]) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexShow(showId, lang), + queryFn: () => api.plexShow(showId, lang), + }); + const d = q.data; + const se = d?.seasons.find((s) => s.id === seasonId); + const [addTarget, setAddTarget] = useState(null); + const [busy, setBusy] = useState(false); + + const seasonKeys = se?.episodes.map((e) => e.id) ?? []; + // Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls + // straight into the next season's first — the same cross-season order the show page already uses. + // seasonKeys stays only for the season-scoped "add to playlist" action. + const allKeys = (d?.seasons ?? []).flatMap((s) => s.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 { + await api.plexSeasonState(seasonId, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + async function markEpisode(ep: PlexCard) { + try { + await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched"); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + + return ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !se ? ( + + ) : ( + <> + se.resume && onPlay(se.resume.id, allKeys)} + posterSizeClass="w-28 sm:w-40" + customizeMenu={ + + } + > + +

{se.title}

+

+ {t("plex.series.episodeCount", { count: se.episode_count })} +

+
+ {se.resume && ( + onPlay(se.resume!.id, allKeys)} + icon={Play} + label={se.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(se.resume)} + /> + )} + {se.first && se.status !== "new" && ( + onPlay(se.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markSeasonUnwatched")} + unwatchedLabel={t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ + kind: "group", + ratingKeys: seasonKeys, + title: `${d.show.title} — ${se.title}`, + }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )} +
+
+ +
+ {se.episodes.map((ep) => ( + onPlay(ep.id, allKeys)} + onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + onToggleWatched={() => markEpisode(ep)} + /> + ))} +
+ + )} + + {addTarget && setAddTarget(null)} />} +
+ ); +} diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 328bf66c..cf4b0199 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,8 +1,16 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { SlidersHorizontal } from "lucide-react"; +import { + ArrowLeft, + Check, + CheckCheck, + Play, + SlidersHorizontal, + type LucideIcon, +} from "lucide-react"; import { useDismiss } from "./useDismiss"; import { usePref, useSavePref } from "./usePref"; +import type { PlexCard } 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 @@ -184,3 +192,130 @@ export function PrefToggle({ ); } + +// --- Series detail-page shared UI (the show page + the season subpage share these) ------------------- +// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim. + +export function epLabel(ep?: PlexCard | null): string | undefined { + if (!ep) return undefined; + if (ep.season_number != null && ep.episode_number != null) + return `S${ep.season_number} · E${ep.episode_number}`; + return ep.title; +} + +export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { + return ( + + ); +} + +export function ActionBtn({ + onClick, + icon: Icon, + label, + sub, + primary, + disabled, +}: { + onClick: () => void; + icon: LucideIcon; + label: string; + sub?: string; + primary?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were + * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, + * and the flex info column; the caller passes the customize menu + the title/metadata/actions as + * `children`. Poster is slightly larger on the show page (default) than the season page. */ +export function DetailHero({ + thumb, + posterTitle, + posterDisabled, + showPlayOverlay, + onPosterClick, + posterSizeClass = "w-32 sm:w-44", + customizeMenu, + children, +}: { + thumb: string; + posterTitle: string; + posterDisabled: boolean; + showPlayOverlay: boolean; + onPosterClick: () => void; + posterSizeClass?: string; + customizeMenu: ReactNode; + children: ReactNode; +}) { + return ( +
+
{customizeMenu}
+ +
{children}
+
+ ); +} + +/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both + * the show and season heroes; the labels differ, so the caller passes both. */ +export function WatchedToggle({ + watched, + busy, + onToggle, + watchedLabel, + unwatchedLabel, +}: { + watched: boolean; + busy?: boolean; + onToggle: () => void; + watchedLabel: string; + unwatchedLabel: string; +}) { + return ( + + ); +} diff --git a/frontend/src/lib/plexWatch.ts b/frontend/src/lib/plexWatch.ts new file mode 100644 index 00000000..2ba8afd0 --- /dev/null +++ b/frontend/src/lib/plexWatch.ts @@ -0,0 +1,22 @@ +import type { TFunction } from "i18next"; +import { api, type PlexCard } from "./api"; +import { notifyActionError } from "./actionErrors"; + +// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache +// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then +// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the +// browse grid and the person page, each of which owns a different cache shape. +export async function toggleWatchState( + card: PlexCard, + t: TFunction, + handlers: { optimistic: (next: string) => void; resync: () => void }, +) { + const next = card.status === "watched" ? "new" : "watched"; + handlers.optimistic(next); + try { + await api.plexSetState(card.id, next); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + handlers.resync(); +}