feat(plex): sort direction, genre any/all, multi-person filters; fix back-nav + player stop

UAT follow-ups on the Plex filter epic:
- Sort now has an asc/desc toggle (sort_dir), applied to any sort field.
- Genre multi-select gains an Any/All mode (genre_mode: OR vs AND containment).
- Director/actor/studio become multi-value: people AND (titles featuring all selected),
  studios OR; clicking them on the info page stacks (unions) instead of replacing, and
  the sidebar shows each as a removable 'Active' chip.
- fix(history): clicking a metadata filter on the info page now pushes a fresh grid
  entry instead of history.back(), so browser Back returns to the info page rather than
  leaving the Plex module.
- fix(player): fully tear down the <video> + hls on unmount and guard late play() calls,
  so backing out of a just-started video no longer leaves audio playing in the background.
i18n en/hu/de (match any/all, sort direction).
This commit is contained in:
npeter83 2026-07-06 00:15:31 +02:00
parent eefd7e3abd
commit 1982cfa7b9
10 changed files with 160 additions and 60 deletions

View file

@ -155,7 +155,7 @@ export default function PlexSidebar({
</Section>
)}
{/* Sort */}
{/* Sort + direction */}
<Section label={t("plex.filter.sort")}>
<ChipRow>
{sorts.map((s) => (
@ -164,24 +164,43 @@ export default function PlexSidebar({
</Chip>
))}
</ChipRow>
<div className="mt-1.5 flex gap-1">
{(["desc", "asc"] as const).map((d) => (
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}>
{t(`plex.filter.dir.${d}`)}
</Chip>
))}
</div>
</Section>
{/* Metadata filters (movie libraries only) */}
{isMovieLib && (
<>
{/* Active people / studio — set by clicking the info page. */}
{(filters.director || filters.actor || filters.studio) && (
{/* Active people / studios — set by clicking the info page (stackable). */}
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
<Section label={t("plex.filter.active")}>
<div className="flex flex-wrap gap-1.5">
{filters.director && (
<RemovableChip label={filters.director} onRemove={() => patch({ director: null })} />
)}
{filters.actor && (
<RemovableChip label={filters.actor} onRemove={() => patch({ actor: null })} />
)}
{filters.studio && (
<RemovableChip label={filters.studio} onRemove={() => patch({ studio: null })} />
)}
{filters.directors.map((d) => (
<RemovableChip
key={`d:${d}`}
label={d}
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
label={a}
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
{filters.studios.map((s) => (
<RemovableChip
key={`s:${s}`}
label={s}
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
/>
))}
</div>
</Section>
)}
@ -200,9 +219,26 @@ export default function PlexSidebar({
</ChipRow>
</Section>
{/* Genres (from facets) */}
{/* Genres (from facets) + Any/All when more than one is picked */}
{facets && facets.genres.length > 0 && (
<Section label={t("plex.filter.genre")}>
{filters.genres.length > 1 && (
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => patch({ genreMode: m })}
className={`px-2 py-0.5 transition ${
(filters.genreMode ?? "any") === m
? "bg-accent text-accent-fg"
: "text-muted hover:bg-surface"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
)}
<ChipRow>
{facets.genres.map((g) => (
<Chip