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

@ -127,9 +127,21 @@ export default function PlexBrowse({ q, library, show, sort, filters, setFilters
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onFilter={(patch) => {
// Clicking a metadata chip sets that filter and returns to the (now filtered) grid.
setFilters({ ...filters, ...patch });
sub.back();
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
// info page instead of leaving the Plex module.
const merged = { ...filters } as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
const cur = merged[k];
if (Array.isArray(v) && Array.isArray(cur)) {
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
} else {
merged[k] = v;
}
}
setFilters(merged as unknown as PlexFilters);
sub.open({ kind: "grid" });
}}
/>
);

View file

@ -181,7 +181,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
{detail.studio && (
<Filterable
className={mutedCls}
onClick={onFilter ? () => onFilter({ studio: detail.studio! }) : undefined}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
@ -232,7 +232,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ director: d }) : undefined}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
@ -331,7 +331,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
return onFilter ? (
<button
key={i}
onClick={() => onFilter({ actor: c.name })}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
className="group/cast w-20 shrink-0 text-center"
>

View file

@ -53,6 +53,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
// can't start a detached <video> playing audio in the background.
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
const durationRef = useRef(0);
@ -143,7 +146,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
hls.on(Hls.Events.MANIFEST_PARSED, () => {
enableSubs();
setReady(true);
video.play().catch(() => {});
if (aliveRef.current) video.play().catch(() => {});
});
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead.
@ -157,7 +160,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
video.play().catch(() => {});
if (aliveRef.current) video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta);
};
video.addEventListener("loadedmetadata", onMeta);
@ -179,6 +182,28 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
};
}, [detail, loadSession]);
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
// play() can't leave audio playing on a detached element after leaving the player.
useEffect(() => {
return () => {
aliveRef.current = false;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const v = videoRef.current;
if (v) {
try {
v.pause();
v.removeAttribute("src");
v.load();
} catch {
/* ignore */
}
}
};
}, []);
// --- time tracking + progress checkpoints ----------------------------------------------------
useEffect(() => {
const video = videoRef.current;

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