siftlode/frontend/src/components/PlexInfo.tsx
npeter83 eefd7e3abd feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).

Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00

370 lines
15 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
Check,
ExternalLink,
Play,
RotateCcw,
SlidersHorizontal,
Star,
X,
} from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
// 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
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
type Props = {
detail: PlexItemDetail;
variant: "page" | "overlay";
onPlay?: () => void; // page: start/resume playback
onClose?: () => void; // overlay: dismiss
onStateChange?: () => void; // after a watch-state change, let the opener refresh
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
// the corresponding Plex filter (then the opener navigates to the filtered grid).
onFilter?: (patch: Partial<PlexFilters>) => void;
};
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
function Filterable({
onClick,
title,
className,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
children: ReactNode;
}) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
{children}
</button>
);
}
function fmtDur(s?: number | null): string {
if (!s) return "";
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean };
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
const [customizing, setCustomizing] = useState(false);
const savePref = (patch: Record<string, boolean>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
qc.invalidateQueries({ queryKey: ["plex-show"] });
onStateChange?.();
};
const cast = showCast ? detail.cast : [];
const overlay = variant === "overlay";
const mutedCls = overlay ? "text-white/70" : "text-muted";
return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
{/* Faint art backdrop (page only, toggleable). */}
{!overlay && artBg && detail.art && (
<div className="absolute inset-0 -z-10 overflow-hidden rounded-2xl">
<img src={detail.art} alt="" className="w-full h-full object-cover opacity-20" />
<div className="absolute inset-0 bg-gradient-to-t from-bg via-bg/85 to-bg/60" />
</div>
)}
<div className={overlay ? "p-5" : "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">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} {detail.title}
</p>
)}
</div>
<div className="relative shrink-0">
<button
onClick={() => setCustomizing((v) => !v)}
title={t("plex.info.customize")}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
>
<SlidersHorizontal className="w-4 h-4" />
</button>
{customizing && (
<div className="absolute right-0 top-full z-10 mt-1 w-52 rounded-xl border border-border bg-card p-1.5 text-sm shadow-xl">
<PrefToggle
label={t("plex.info.prefArtBg")}
on={artBg}
onClick={() => {
setArtBg((v) => !v);
savePref({ plexInfoArtBg: !artBg });
}}
/>
<PrefToggle
label={t("plex.info.prefCast")}
on={showCast}
onClick={() => {
setShowCast((v) => !v);
savePref({ plexInfoCast: !showCast });
}}
/>
</div>
)}
</div>
{overlay && onClose && (
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
)}
</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]`}
/>
<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
className={mutedCls}
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
className={mutedCls}
onClick={onFilter ? () => onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? <span className={mutedCls}>{fmtDur(detail.duration_seconds)}</span> : null}
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studio: detail.studio! }) : undefined}
>
{detail.studio}
</Filterable>
)}
{detail.imdb_rating != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) }) : undefined}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={onFilter ? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) }) : undefined}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" title={t("plex.info.openImdb")}>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
</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}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ director: d }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
))}
</p>
)}
{detail.summary && (
<p className={`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}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.markUnwatched")}
</button>
) : (
<button
onClick={() => setState("watched")}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
>
<Check className="w-4 h-4" />
{t("plex.info.markWatched")}
</button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
{t("plex.info.clearResume")}
</button>
)}
</div>
)}
</div>
</div>
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className="mt-5">
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{cast.map((c, i) => {
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-lg opacity-40">
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
onFilter ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
</div>
{c.role && (
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
{c.role}
</div>
)}
</>
);
return onFilter ? (
<button
key={i}
onClick={() => onFilter({ actor: c.name })}
title={t("plex.info.filterActor", { name: c.name })}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
</button>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
>
<span>{label}</span>
<span
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
);
}