diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 1b6b094a..459aa827 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; -import { healPlexSort } from "../lib/plexSort"; +import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { @@ -59,6 +59,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; +import ActiveFilterChips from "./ActiveFilterChips"; +import { usePlexActiveFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. @@ -89,8 +91,16 @@ export default function PlexBrowse() { // search term (q) comes from the shared header box; the rail hands off a playlist to open via the // shared `playlistOpen` slot, which we consume into a history subview then clear. const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex(); - const { setScope, setFilters, setPlaylistOpen } = usePlexActions(); + const { setScope, setShow, setSort, setFilters, setPlaylistOpen } = usePlexActions(); const dq = useDebounced(q.trim(), 350); + // Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per + // narrowing, plus a Clear-all that resets filters + watch-state + sort to their defaults. + const plexChips = usePlexActiveFilters(); + const clearAllFilters = () => { + setFilters(EMPTY_PLEX_FILTERS); + setShow("all"); + setSort(PLEX_SORT_FALLBACK); + }; // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. const sortState = healPlexSort(sort, scope); @@ -333,6 +343,8 @@ export default function PlexBrowse() { + + {personCards.length > 0 && (
{personCards.map((p) => ( diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts new file mode 100644 index 00000000..4f5586f3 --- /dev/null +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next"; +import { type ActiveFilter } from "../lib/useActiveFilters"; +import { type PlexFilters } from "../lib/api"; +import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import { usePlex, usePlexActions } from "./PlexProvider"; + +// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state + +// sort) into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid. +// Plex thereby gets the same at-a-glance "what's narrowing this" row — with per-chip removal — that +// the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse +// the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so +// removing one keeps the rest (same shape as the feed's tag/channel chips). Lives here, not in lib, +// because it reads the Plex provider (lib stays provider-free, like useActiveFilters). +export function usePlexActiveFilters(): ActiveFilter[] { + const { scope, show, sort, filters } = usePlex(); + const { setShow, setSort, setFilters } = usePlexActions(); + const { t } = useTranslation(); + const patch = (p: Partial) => setFilters({ ...filters, ...p }); + const out: ActiveFilter[] = []; + + const set = (x: number | null | undefined) => x != null; + + for (const g of filters.genres) { + out.push({ + key: `genre:${g}`, + label: g, + remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }), + }); + } + for (const r of filters.contentRatings) { + out.push({ + key: `contentRating:${r}`, + label: r, + remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }), + }); + } + if (set(filters.yearMin) || set(filters.yearMax)) { + const range = + set(filters.yearMin) && set(filters.yearMax) + ? `${filters.yearMin}–${filters.yearMax}` + : set(filters.yearMin) + ? `≥${filters.yearMin}` + : `≤${filters.yearMax}`; + out.push({ + key: "year", + label: `${t("plex.filter.year")}: ${range}`, + remove: () => patch({ yearMin: null, yearMax: null }), + }); + } + if (set(filters.ratingMin)) { + out.push({ + key: "ratingMin", + label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`, + remove: () => patch({ ratingMin: null }), + }); + } + if (set(filters.durationMin) || set(filters.durationMax)) { + const lo = set(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null; + const hi = set(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null; + const range = lo != null && hi != null ? `${lo}–${hi}m` : lo != null ? `≥${lo}m` : `≤${hi}m`; + out.push({ + key: "duration", + label: `${t("plex.filter.duration")}: ${range}`, + remove: () => patch({ durationMin: null, durationMax: null }), + }); + } + if (filters.addedWithin) { + out.push({ + key: "added", + label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`, + remove: () => patch({ addedWithin: "" }), + }); + } + for (const d of filters.directors) { + out.push({ + key: `director:${d}`, + label: d, + remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }), + }); + } + for (const a of filters.actors) { + out.push({ + key: `actor:${a}`, + label: a, + remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }), + }); + } + for (const s of filters.studios) { + out.push({ + key: `studio:${s}`, + label: s, + remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }), + }); + } + if (filters.collection) { + out.push({ + key: "collection", + label: filters.collectionTitle ?? filters.collection, + remove: () => patch({ collection: null, collectionTitle: null }), + }); + } + if (show !== "all") { + out.push({ + key: "show", + label: t("plex.filter.showOpt." + show), + remove: () => setShow("all"), + }); + } + const healed = healPlexSort(sort, scope); + if (healed.key !== "title") { + out.push({ + key: "sort", + label: t("plex.sort." + healed.key), + remove: () => setSort(PLEX_SORT_FALLBACK), + }); + } + return out; +}