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:
parent
eefd7e3abd
commit
1982cfa7b9
10 changed files with 160 additions and 60 deletions
|
|
@ -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" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue