refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
|
||||
// episode and opens the player on it; a started show's Restart opens it from the start; its title
|
||||
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./PlexPlayer", () => ({
|
||||
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
|
||||
<div>
|
||||
player {itemId} {fromStart ? "from the start" : "resumed"}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./plexViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexViews")>()),
|
||||
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
|
||||
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const show: PlexCard = {
|
||||
id: "sh",
|
||||
type: "show",
|
||||
title: "Grimm",
|
||||
thumb: "",
|
||||
status: "in_progress",
|
||||
resumable: true,
|
||||
};
|
||||
|
||||
function mount(episodes: PlexCard[] = []) {
|
||||
vi.spyOn(api, "plexLibrary").mockResolvedValue({
|
||||
items: [show],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
episodes,
|
||||
} as never);
|
||||
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
|
||||
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexBrowse />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { next };
|
||||
}
|
||||
|
||||
describe("PlexBrowse — a show's card in the grid", () => {
|
||||
it("plays the episode the server names", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
|
||||
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
|
||||
expect(next).toHaveBeenCalledWith("sh", "en");
|
||||
});
|
||||
|
||||
it("starts that episode over from its Restart", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
|
||||
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the show's page from its title, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A matched episode's title opens its page, a pointer's door: its menu offers the same to the keyboard.
|
||||
it("opens a matched episode's page from its menu, as its title does", async () => {
|
||||
mount([{ id: "ep1", type: "episode", title: "Pilot", thumb: "", show_title: "Grimm" }]);
|
||||
const section = (await screen.findByText("plex.unified.episodes")).parentElement!;
|
||||
fireEvent.click(section.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||
expect(await screen.findByText("info page ep1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the show's page from its ⓘ, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,798 @@
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { User, X } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../../lib/nextPage";
|
||||
import { healPlexField } from "../../lib/plexSearch";
|
||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../../lib/plexSort";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
import {
|
||||
api,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
plexFilterCount,
|
||||
type PlexCard,
|
||||
type PlexFilters,
|
||||
type PlexUnifiedResult,
|
||||
} from "../../lib/api";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useHistorySubview } from "../../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
import SortControl from "../../components/ui/SortControl";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
||||
import ItemCount from "../../components/ui/ItemCount";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { usePublishPlexView } from "./plexViewContext";
|
||||
import { EpisodeCard, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||
const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||
// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own
|
||||
// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews.
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
// The "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
||||
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
||||
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
key: "lastwatched",
|
||||
dir: "desc" as const,
|
||||
});
|
||||
|
||||
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
|
||||
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
|
||||
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
|
||||
const BACKABLE_VIEWS: readonly string[] = [
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||
// 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, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||
const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
||||
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
||||
const lang = plexLang(i18n.language);
|
||||
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
|
||||
// genre-clear effect (below) can settle first (see the effect for why).
|
||||
const [committedLang, setCommittedLang] = useState(lang);
|
||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||
const plexChips = usePlexActiveFilters();
|
||||
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
|
||||
const fold = useFilterFold();
|
||||
const clearAllFilters = usePlexClearFilters();
|
||||
// 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);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
// Discover is the landing; the flat grid is the "Browse all" secondary view. A header search
|
||||
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
|
||||
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. `sub.open`
|
||||
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
|
||||
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
|
||||
const publishView = usePublishPlexView();
|
||||
useLayoutEffect(() => {
|
||||
publishView({ kind: sub.view.kind, open: sub.open });
|
||||
return () => publishView(null);
|
||||
}, [sub.view.kind, sub.open, publishView]);
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
useEffect(() => {
|
||||
if (openPlaylist != null) {
|
||||
sub.open({ kind: "playlist", id: openPlaylist });
|
||||
setPlaylistOpen(null);
|
||||
}
|
||||
}, [openPlaylist, sub, setPlaylistOpen]);
|
||||
|
||||
// Like the grid's scroll memory (below), for the info page: "Browse collection" (onFilter) leaves
|
||||
// the info view for a filtered grid, and browser Back returns to this same info page — restore
|
||||
// where the user had scrolled it (down to a collection strip) instead of snapping to the top. Reset
|
||||
// on a fresh info open so a different title's info starts at the top.
|
||||
const infoScrollRef = useRef(0);
|
||||
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
|
||||
// rule the code holds rather than one spread over the call sites. Declared above the effects that
|
||||
// use it: an effect closing over the ref before its declaration made the compiler skip PlexBrowse.
|
||||
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
|
||||
|
||||
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||
// info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it
|
||||
// doesn't re-open.
|
||||
useEffect(() => {
|
||||
if (jumpTo != null) {
|
||||
if (jumpTo.type !== "show") rememberInfoScroll(0); // a jumped-to info page starts at the top
|
||||
sub.open(
|
||||
jumpTo.type === "show"
|
||||
? { kind: "show", id: jumpTo.id }
|
||||
: jumpTo.type === "play"
|
||||
? { kind: "player", id: jumpTo.id, fromStart: jumpTo.fromStart }
|
||||
: { kind: "info", id: jumpTo.id },
|
||||
// A cross-module hand-off continues the navigation that brought us here rather than adding
|
||||
// to it. It also makes this effect idempotent, which matters more than it looks: under
|
||||
// StrictMode the effect body runs twice before `setJumpTo(null)` re-renders, and two pushes
|
||||
// of the SAME sub-view left a duplicate entry whose Back visibly did nothing.
|
||||
{ replace: jumpTo.replace },
|
||||
);
|
||||
setJumpTo(null);
|
||||
}
|
||||
}, [jumpTo, sub, setJumpTo]);
|
||||
|
||||
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
|
||||
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
|
||||
// to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
|
||||
// trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
|
||||
// adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
|
||||
const browseIntent = !!dq;
|
||||
const prevIntent = useRef(browseIntent);
|
||||
useEffect(() => {
|
||||
// A search targets titles, so jump to the title grid from any non-title landing (Discover or the
|
||||
// playlists/collections browse surfaces) when a search first appears.
|
||||
const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind);
|
||||
if (onListing && browseIntent && !prevIntent.current) {
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
prevIntent.current = browseIntent;
|
||||
}, [browseIntent, sub]);
|
||||
|
||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
||||
const scrollRef = useRef(0);
|
||||
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const epMenu = useContextMenu<PlexCard>();
|
||||
const scroller = () => document.querySelector("main");
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller();
|
||||
if (!el) return;
|
||||
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
|
||||
else if (sub.view.kind === "info" && infoScrollRef.current)
|
||||
el.scrollTop = infoScrollRef.current;
|
||||
}, [sub.view.kind]);
|
||||
|
||||
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
|
||||
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
|
||||
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
|
||||
// sheet can never promise a "Back one level" the handler declines to perform.
|
||||
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
|
||||
useKeyScope("plexBrowse", canStepBack);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
dispatch(e, "plexBrowse", {
|
||||
back: (ev) => {
|
||||
if (!canStepBack) return;
|
||||
ev.preventDefault();
|
||||
sub.back();
|
||||
},
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [sub, canStepBack]);
|
||||
|
||||
// Entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
|
||||
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
|
||||
// the scope-healed one, so the user's real preference round-trips.
|
||||
const prevShow = useRef(show);
|
||||
const preInProgressSort = useRef<SortState | null>(null);
|
||||
useEffect(() => {
|
||||
const was = prevShow.current;
|
||||
if (was !== "in_progress" && show === "in_progress") {
|
||||
preInProgressSort.current = sort;
|
||||
setSort(LASTWATCHED_SORT);
|
||||
} else if (was === "in_progress" && show !== "in_progress") {
|
||||
if (preInProgressSort.current) setSort(preInProgressSort.current);
|
||||
preInProgressSort.current = null;
|
||||
}
|
||||
prevShow.current = show;
|
||||
}, [show, sort, setSort]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
||||
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
||||
// settles), not a throwaway one with stale-language genres followed by a second.
|
||||
enabled: !!scope && sub.view.kind === "grid" && committedLang === lang,
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexLibrary({
|
||||
scope,
|
||||
q: dq || undefined,
|
||||
field: fieldState,
|
||||
sort: sortState,
|
||||
show,
|
||||
lang,
|
||||
filters,
|
||||
offset: pageParam as number,
|
||||
limit: PAGE,
|
||||
}),
|
||||
getNextPageParam: (last) => {
|
||||
const seen = last.offset + last.items.length;
|
||||
return seen < last.total ? seen : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
|
||||
// selection made in one language matches nothing against the other's column and would silently empty
|
||||
// the grid. On a language flip, drop the genre selection — and gate the grid query on the language
|
||||
// being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and
|
||||
// then a second after the clear; the query waits for both to settle into a single fetch.
|
||||
useEffect(() => {
|
||||
if (committedLang !== lang) {
|
||||
// Not adjustable during render, which is where a state reset like this would otherwise
|
||||
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
|
||||
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
|
||||
// — the guard above is false immediately afterwards.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
|
||||
setCommittedLang(lang);
|
||||
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
||||
}
|
||||
}, [lang, committedLang, filters, setFilters]);
|
||||
|
||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
||||
// grid so you SEE who you're exploring, not just chips in the rail.
|
||||
const peopleCardsQ = useQuery({
|
||||
queryKey: qk.plexPeopleCards(scope, filters.actors),
|
||||
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
||||
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const personCards = peopleCardsQ.data?.people ?? [];
|
||||
const removeActor = (name: string) =>
|
||||
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
|
||||
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
||||
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
||||
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
|
||||
// in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
|
||||
// Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
|
||||
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
|
||||
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
|
||||
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
|
||||
const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ;
|
||||
// A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt
|
||||
// on every settle and would re-request it once a round-trip while the sentinel is in view.
|
||||
const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError;
|
||||
useEffect(() => {
|
||||
if (!sentinelEl) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && mayReadAhead(hasNextPage, isFetching))
|
||||
void fetchNextPage(NEXT_PAGE);
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
io.observe(sentinelEl);
|
||||
return () => io.disconnect();
|
||||
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
|
||||
|
||||
const resolveShow = usePlayShow(
|
||||
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||
(id) => sub.open({ kind: "show", id }),
|
||||
sub.view,
|
||||
);
|
||||
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||
function playShow(card: PlexCard, fromStart: boolean) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
void resolveShow(card, fromStart);
|
||||
}
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, false);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||
sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
|
||||
function onRestartCard(card: PlexCard) {
|
||||
if (card.type === "show") return playShow(card, true);
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "player", id: card.id, fromStart: true });
|
||||
}
|
||||
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
|
||||
// and a card title's link).
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
|
||||
rememberInfoScroll(0); // fresh info page starts at the top
|
||||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
|
||||
// Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where
|
||||
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
|
||||
// later info page would wrongly restore this stale offset.
|
||||
function onOpenPerson(name: string) {
|
||||
if (sub.view.kind === "info") rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "person", name });
|
||||
}
|
||||
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
||||
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
|
||||
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
||||
function optimisticStatus(id: string, next: string) {
|
||||
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
pages: old.pages.map((pg) => ({
|
||||
...pg,
|
||||
items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)),
|
||||
episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)),
|
||||
})),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
}
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => optimisticStatus(card.id, next),
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
});
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||
menu.open,
|
||||
);
|
||||
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
|
||||
const epActions = usePlexCardActions(
|
||||
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
|
||||
epMenu.open,
|
||||
);
|
||||
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
||||
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
||||
function applyFilter(patch: Partial<PlexFilters>) {
|
||||
const merged = { ...filters } as unknown as Record<string, unknown>;
|
||||
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
|
||||
typeof x === "object" && x !== null && !Array.isArray(x);
|
||||
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 if (isPlainObject(v) && isPlainObject(cur)) {
|
||||
// Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles.
|
||||
merged[k] = { ...cur, ...v };
|
||||
} else {
|
||||
merged[k] = v;
|
||||
}
|
||||
}
|
||||
setFilters(merged as unknown as PlexFilters);
|
||||
if (patch.actors?.length || patch.directors?.length) setScope("both");
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0); // restore on Back to the info/show page
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
|
||||
if (sub.view.kind === "player") {
|
||||
// The fallback is portaled for the same reason the player itself is: it is a fixed
|
||||
// full-viewport overlay, and this tree renders inside the page scroller.
|
||||
return (
|
||||
<Suspense
|
||||
fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
|
||||
>
|
||||
<PlexPlayer
|
||||
itemId={sub.view.id}
|
||||
queue={sub.view.queue}
|
||||
fromStart={sub.view.fromStart}
|
||||
onClose={sub.back}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "playlist") {
|
||||
const pid = sub.view.id;
|
||||
return (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexPlaylistView
|
||||
playlistId={pid}
|
||||
onBack={sub.back}
|
||||
onPlay={(itemRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: itemRk, queue, fromStart })
|
||||
}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "collection") {
|
||||
const cid = sub.view.id;
|
||||
return (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexCollectionView
|
||||
collectionId={cid}
|
||||
onBack={sub.back}
|
||||
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "info") {
|
||||
const infoId = sub.view.id;
|
||||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
// Up to the show or season: Back returns to this page where it was (as from a cast row).
|
||||
onOpenShow={(showId) => {
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "show", id: showId });
|
||||
}}
|
||||
onOpenSeason={(showId, seasonId) => {
|
||||
rememberInfoScroll(scroller()?.scrollTop ?? 0);
|
||||
sub.open({ kind: "season", showId, seasonId });
|
||||
}}
|
||||
// A collection strip's title: another info page, from its top.
|
||||
onOpenInfo={(id) => {
|
||||
rememberInfoScroll(0);
|
||||
sub.open({ kind: "info", id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "person") {
|
||||
const personName = sub.view.name;
|
||||
return (
|
||||
// Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio).
|
||||
<PlexPersonView
|
||||
key={personName}
|
||||
name={personName}
|
||||
onBack={sub.back}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "show") {
|
||||
const showId = sub.view.id;
|
||||
return (
|
||||
// Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog).
|
||||
<PlexShowView
|
||||
key={showId}
|
||||
showId={showId}
|
||||
onBack={sub.back}
|
||||
onPlay={(epRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
||||
}
|
||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||
onPlayShow={playShow}
|
||||
onFilter={applyFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "season") {
|
||||
const { showId, seasonId } = sub.view;
|
||||
return (
|
||||
<PlexSeasonView
|
||||
showId={showId}
|
||||
seasonId={seasonId}
|
||||
onBack={sub.back}
|
||||
onInfoCard={onInfo}
|
||||
onPlay={(epRk, queue, fromStart) =>
|
||||
sub.open({ kind: "player", id: epRk, queue, fromStart })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the
|
||||
// applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as
|
||||
// the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill).
|
||||
const sortView = (
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sub.view.kind === "discover") {
|
||||
return (
|
||||
<div className="p-4 max-w-400 mx-auto">
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A
|
||||
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
||||
if (sub.view.kind === "playlists") {
|
||||
return (
|
||||
<div className="p-4 max-w-400 mx-auto">
|
||||
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "collections") {
|
||||
return (
|
||||
<div className="p-4 max-w-400 mx-auto">
|
||||
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showOptions = SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }));
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("plex.filter.sort"),
|
||||
options: sortOptions,
|
||||
sort: sortState,
|
||||
onChange: (next) => setSort(next ?? PLEX_SORT_FALLBACK),
|
||||
ascLabel: t("plex.filter.dir.asc"),
|
||||
descLabel: t("plex.filter.dir.desc"),
|
||||
}),
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("plex.filter.show"),
|
||||
options: showOptions,
|
||||
selected: show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
],
|
||||
plexChips.length > 0
|
||||
? [{ id: "clear", label: t("sidebar.clearAll"), onSelect: clearAllFilters }]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-400 mx-auto">
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-1 max-w-400 mx-auto">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={plexChips.length} />
|
||||
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
||||
the same idiom as the feed's Watch-state FilterSelect. */}
|
||||
{!fold.folded && (
|
||||
<FilterSelect
|
||||
label={t("plex.filter.show")}
|
||||
testId="plex-show"
|
||||
options={showOptions}
|
||||
selected={[show]}
|
||||
onChange={(next) => setShow(next[0] ?? "all")}
|
||||
/>
|
||||
)}
|
||||
{/* Folded, the band is one row: Sort + the view toggle join it on the right. */}
|
||||
{fold.folded && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the
|
||||
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
||||
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
||||
{!fold.folded && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={plexChips}
|
||||
onClearAll={clearAllFilters}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
{personCards.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{personCards.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className={`${SURFACE.card} flex items-center gap-1 py-1.5 pl-1.5 pr-2.5`}
|
||||
>
|
||||
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => onOpenPerson(p.name)}
|
||||
title={t("plex.info.viewPerson", { name: p.name })}
|
||||
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
|
||||
>
|
||||
<RemoteImage
|
||||
src={p.photo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
fallback={
|
||||
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
|
||||
{p.name}
|
||||
</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("plex.people.count", { count: p.count })}
|
||||
</div>
|
||||
</div>
|
||||
</Pressable>
|
||||
<IconButton
|
||||
onClick={() => removeActor(p.name)}
|
||||
label={t("plex.people.remove")}
|
||||
className="ml-1"
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{browseQ.isError && !browseQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : browseQ.isLoading ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 && episodes.length === 0 ? (
|
||||
dq && plexFilterCount(filters) > 0 ? (
|
||||
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
||||
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
||||
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
|
||||
{t("plex.filter.clearAll")}
|
||||
</Chip>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* Titles grid — movies + shows mixed (visually tagged). */}
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
{episodes.length > 0 && (
|
||||
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||
)}
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||
{items.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onPage={onInfo}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
||||
{episodes.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||
{episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
withShowTitle
|
||||
onPlay={() => onCard(ep)}
|
||||
onPage={() => onInfo(ep)}
|
||||
onRestart={() => onRestartCard(ep)}
|
||||
onToggleWatched={() => toggleWatched(ep)}
|
||||
onAdd={() => epActions.add(ep)}
|
||||
menuProps={epMenu.triggerProps}
|
||||
menuButtonProps={epMenu.buttonProps}
|
||||
menuOpen={epMenu.open && epMenu.target === ep}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PlexMenu menu={menu} actions={actions} />
|
||||
<PlexMenu menu={epMenu} actions={epActions} pin={false} />
|
||||
<div ref={setSentinelEl} className="h-8" />
|
||||
{isFetchingNextPage && (
|
||||
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
||||
)}
|
||||
{isFetchNextPageError && !isFetchingNextPage && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||
>
|
||||
{t("common.loadMoreFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { api } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// Admin-only "manage this movie's collections" dialog. Lists the library's EDITABLE
|
||||
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
|
||||
// create a new collection seeded with this movie. Read-only collections (smart / IMDb-TMDb auto-lists)
|
||||
// never appear here. Opened from the movie info page.
|
||||
export default function PlexCollectionEditor({
|
||||
item,
|
||||
library,
|
||||
memberOf,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
item: { id: string; title: string };
|
||||
library: string;
|
||||
memberOf: string[]; // collection ids this movie is already in
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { ref: editableFadeRef, style: editableFadeStyle } = useScrollFade();
|
||||
const { ref: othersFadeRef, style: othersFadeStyle } = useScrollFade();
|
||||
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
|
||||
const [q, setQ] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: qk.plexCollections(library),
|
||||
queryFn: () => api.plexCollections(library),
|
||||
});
|
||||
const editable = useMemo(
|
||||
() => (listQ.data?.collections ?? []).filter((c) => c.can_edit),
|
||||
[listQ.data],
|
||||
);
|
||||
// Existing Plex collections you could take over (plain, non-smart, non-auto) but haven't marked yet.
|
||||
const eligible = useMemo(
|
||||
() =>
|
||||
(listQ.data?.collections ?? []).filter(
|
||||
(c) => !c.can_edit && !c.smart && c.source === "collection",
|
||||
),
|
||||
[listQ.data],
|
||||
);
|
||||
const term = q.trim().toLowerCase();
|
||||
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(item.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
async function toggle(id: string) {
|
||||
if (busy) return;
|
||||
setBusy(id);
|
||||
const inIt = members.has(id);
|
||||
try {
|
||||
if (inIt) await api.plexCollectionRemoveItem(id, item.id);
|
||||
else await api.plexCollectionAddItem(id, item.id);
|
||||
setMembers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (inIt) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
|
||||
// simply never appeared and the user clicked again — a collection deleted on the Plex server
|
||||
// or flipped read-only failed in silence.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function markEditable(id: string) {
|
||||
if (busy) return;
|
||||
setBusy(id);
|
||||
try {
|
||||
await api.plexSetCollectionEditable(id, true);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
const title = newName.trim();
|
||||
if (!title || busy) return;
|
||||
setBusy("__new__");
|
||||
try {
|
||||
const col = await api.plexCreateCollection(library, title, item.id);
|
||||
setNewName("");
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
|
||||
// it reads in the reader's language (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function removeCollection(id: string, title: string) {
|
||||
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
|
||||
return;
|
||||
setBusy(id);
|
||||
try {
|
||||
await api.plexDeleteCollection(id);
|
||||
setMembers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
|
||||
// row stayed where it was with nothing said.
|
||||
notifyActionError(e, t("plex.collection.deleteFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="field"
|
||||
title={t("plex.collEditor.title", { title: item.title })}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* Create new */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
||||
placeholder={t("plex.collEditor.newPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || !!busy}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.collEditor.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
{editable.length > 6 && (
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("plex.collEditor.search")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Editable collections with in/out toggle */}
|
||||
<div
|
||||
ref={editableFadeRef}
|
||||
style={editableFadeStyle}
|
||||
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{listQ.isLoading ? (
|
||||
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted">{t("plex.collEditor.none")}</p>
|
||||
) : (
|
||||
shown.map((c) => {
|
||||
const inIt = members.has(c.id);
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
|
||||
>
|
||||
<Pressable
|
||||
role="checkbox"
|
||||
aria-checked={inIt}
|
||||
onClick={() => toggle(c.id)}
|
||||
disabled={!!busy}
|
||||
className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${
|
||||
inIt ? "text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="many" on={inIt} />
|
||||
<span className="truncate">{c.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||
</span>
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={() => removeCollection(c.id, c.title)}
|
||||
disabled={!!busy}
|
||||
>
|
||||
{t("plex.collEditor.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Take over an existing plain Plex collection (mark it "mine to edit"). */}
|
||||
{eligible.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
|
||||
{t("plex.collEditor.others", { count: eligible.length })}
|
||||
</summary>
|
||||
<div
|
||||
ref={othersFadeRef}
|
||||
style={othersFadeStyle}
|
||||
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{eligible.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{c.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
onClick={() => markEditable(c.id)}
|
||||
disabled={!!busy}
|
||||
>
|
||||
{t("plex.collEditor.makeEditable")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-muted">{t("plex.collEditor.note")}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour a review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
|
||||
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
|
||||
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
|
||||
import { setLanguage } from "../../i18n";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
// Poster grids / carousels wire a ResizeObserver; jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
// …and the Sort control's popover watches its anchor with an IntersectionObserver.
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// the real instance: these tests switch the language with setLanguage
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
// Spy notify without dropping the module's other exports (the render subtree uses them).
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
type PlexCard,
|
||||
type PlexCollectionDetail,
|
||||
type PlexShowDetail,
|
||||
} from "../../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
|
||||
const card = (id: string, type: "movie" | "show"): PlexCard =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
const detail = (items: PlexCard[]): PlexCollectionDetail =>
|
||||
({
|
||||
id: "col1",
|
||||
title: "My Collection",
|
||||
thumb: "/api/plex/image/col1",
|
||||
year_min: null,
|
||||
year_max: null,
|
||||
content_rating: null,
|
||||
child_count: items.length,
|
||||
total_runtime_s: 0,
|
||||
items,
|
||||
can_edit: false,
|
||||
editable: false,
|
||||
}) as unknown as PlexCollectionDetail;
|
||||
|
||||
const showDetail = (episodeIds: string[][]): PlexShowDetail =>
|
||||
({
|
||||
seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })),
|
||||
}) as unknown as PlexShowDetail;
|
||||
|
||||
function mount(items: PlexCard[], onPlay = vi.fn()) {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onPlay;
|
||||
}
|
||||
|
||||
describe("PlexCollectionView — Play-all", () => {
|
||||
it("queues a leaf member as itself", async () => {
|
||||
const onPlay = mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a show member into its episodes in season→episode order", async () => {
|
||||
const spyShow = vi
|
||||
.spyOn(api, "plexShow")
|
||||
.mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]]));
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(spyShow).toHaveBeenCalledWith("show1", "en");
|
||||
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
|
||||
});
|
||||
|
||||
it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => {
|
||||
vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
|
||||
),
|
||||
);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Bug hunt: the Sort control put the sort into the detail query's key, and two things
|
||||
// on this page did not follow it.
|
||||
describe("PlexCollectionView — after the sort joined the query key", () => {
|
||||
it("flips a poster's watched state on the page it was clicked on", async () => {
|
||||
// The optimistic write kept the key's OLD spelling — an entry nothing reads — and the resync
|
||||
// only touches the library root, so the click did nothing visible until a reload.
|
||||
// The request never answers, so the resync never runs: only the optimistic write can flip it.
|
||||
vi.spyOn(api, "plexSetState").mockReturnValue(new Promise(() => {}));
|
||||
mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
|
||||
expect(await screen.findByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts a toggle that failed back the way it was", async () => {
|
||||
// With the flip landing on the entry the page reads, the resync is what corrects it — and it only
|
||||
// touched the library root, so a failed toggle stayed "watched" beside its own error toast.
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new HttpError(422, "nope"));
|
||||
mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
|
||||
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByRole("button", { name: "plex.markWatched" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "plex.markUnwatched" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page, and the open Sort panel, on screen while the new order loads", async () => {
|
||||
// Every sort change was a key with no data yet: the whole page — the control and its open panel
|
||||
// included — was swapped for a spinner, so key and direction could not be set in one visit.
|
||||
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "plexCollection")
|
||||
.mockResolvedValueOnce(detail([card("movie1", "movie"), card("movie2", "movie")]))
|
||||
.mockReturnValueOnce(new Promise(() => {})); // the re-sorted page is still on its way
|
||||
mount([card("movie1", "movie"), card("movie2", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^plex\.filter\.sort:/ }));
|
||||
// Popover rows are portalled and only exist while it is open, so `hidden: true`.
|
||||
const other = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
|
||||
(o) => o.getAttribute("aria-checked") !== "true",
|
||||
);
|
||||
fireEvent.click(other!);
|
||||
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByText("plex.loading")).toBeNull();
|
||||
expect(screen.getAllByRole("menuitemradio", { hidden: true }).length).toBeGreaterThan(1);
|
||||
expect(screen.getByText("plex.collection.playAll")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show the previous collection while stepping to another one", async () => {
|
||||
// Keeping the page on screen is for a re-sort of THIS collection only.
|
||||
vi.spyOn(api, "plexCollection")
|
||||
.mockResolvedValueOnce(detail([card("movie1", "movie")]))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = (id: string) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId={id}
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(view("col1"));
|
||||
await screen.findByText("plex.collection.playAll");
|
||||
rerender(view("col2"));
|
||||
expect(await screen.findByText("plex.loading")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.collection.playAll")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexCollectionView — the reader's language", () => {
|
||||
it("asks for the members, and a show it expands, in Hungarian", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
const spyShow = vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([["s1e1"]]));
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.plexCollection).mock.calls[0]?.[2]).toBe("hu");
|
||||
expect(spyShow).toHaveBeenCalledWith("show1", "hu");
|
||||
} finally {
|
||||
setLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexCollectionView — the summary", () => {
|
||||
it("offers no More under a summary that fits", async () => {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue({
|
||||
...detail([card("m1", "movie")]),
|
||||
summary: "One short line.",
|
||||
} as PlexCollectionDetail);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={vi.fn()}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("One short line.");
|
||||
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../../lib/api";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { showEpisodeKeys } from "../../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../../lib/plexSort";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import SortControl from "../../components/ui/SortControl";
|
||||
import { type SortState } from "../../lib/columnSort";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi";
|
||||
import { PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { useReturnFocus } from "../../components/ui/useReturnFocus";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
|
||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
|
||||
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
|
||||
// / imdb / tmdb / … auto-lists) are marked with a lock.
|
||||
// The order this page had before it grew a Sort control, and still its default: a franchise reads in
|
||||
// release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request
|
||||
// goes out without the params, which is why the control always sends them.
|
||||
const COLLECTION_SORT_DEFAULT: NonNullable<SortState> = { key: "year", dir: "asc" };
|
||||
|
||||
function shuffled(a: string[]): string[] {
|
||||
const r = [...a];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = r[i]!;
|
||||
r[i] = r[j]!;
|
||||
r[j] = tmp;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export default function PlexCollectionView({
|
||||
collectionId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
}: {
|
||||
collectionId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (rk: string, queue: string[]) => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// One menu for the member grid (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
// The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the
|
||||
// same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key
|
||||
// rather than the browse grid's sort: opening a collection must not silently re-sort the library
|
||||
// behind it.
|
||||
const [sortPref, setSortPref] = usePref<NonNullable<SortState>>(
|
||||
"plexCollectionSort",
|
||||
COLLECTION_SORT_DEFAULT,
|
||||
);
|
||||
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
|
||||
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
|
||||
// exactly), so the click did nothing visible until a reload.
|
||||
const lang = plexLang(i18n.language);
|
||||
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
|
||||
const q = useQuery({
|
||||
queryKey: detailKey,
|
||||
queryFn: () => api.plexCollection(collectionId, sortPref, lang),
|
||||
// A re-sort keeps the current page on screen while the new order loads. Without it every sort
|
||||
// change was a key with no data yet, and the whole page — the Sort control and its open panel
|
||||
// with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
|
||||
// within the SAME collection: stepping to another one must not show this one's hero meanwhile.
|
||||
placeholderData: (prev, prevQuery) =>
|
||||
prevQuery?.queryKey[2] === collectionId ? prev : undefined,
|
||||
});
|
||||
const d = q.data;
|
||||
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
|
||||
// meanwhile so a double-tap can't fire two queues.
|
||||
const [queueBusy, setQueueBusy] = useState(false);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// A collection lives in ONE library, so its members are all movies or all shows. That decides both
|
||||
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
|
||||
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
|
||||
const sortState = healPlexSort(sortPref, scope);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
// Play-all was empty and did nothing.
|
||||
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
const hasShows = items.some((c) => c.type === "show");
|
||||
const canPlay = leafKeys.length > 0 || hasShows;
|
||||
|
||||
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
|
||||
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
|
||||
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
|
||||
// which would fetch every show up front for a button that may never be pressed.
|
||||
async function buildQueue(): Promise<string[]> {
|
||||
const perMember = await Promise.all(
|
||||
items.map(async (c) => {
|
||||
if (c.type !== "show") return [c.id];
|
||||
const detail = await qc.fetchQuery({
|
||||
queryKey: qk.plexShow(c.id, lang),
|
||||
queryFn: () => api.plexShow(c.id, lang),
|
||||
});
|
||||
return showEpisodeKeys(detail);
|
||||
}),
|
||||
);
|
||||
return perMember.flat();
|
||||
}
|
||||
|
||||
async function playAll(shuffle: boolean) {
|
||||
setQueueBusy(true);
|
||||
try {
|
||||
let queue = await buildQueue();
|
||||
if (shuffle) queue = shuffled(queue);
|
||||
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
||||
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
||||
if (queue.length > 0) onPlay(queue[0]!, queue);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
}
|
||||
setQueueBusy(false);
|
||||
}
|
||||
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
||||
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
||||
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
|
||||
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
|
||||
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => {
|
||||
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
// This page's OWN key too, not just the library's: the flip above lands on the entry the page
|
||||
// reads, and resync is what corrects it to the truth when the toggle fails — invalidating only
|
||||
// `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also
|
||||
// refreshes the other sort orders of this collection cached behind this one.
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) });
|
||||
},
|
||||
});
|
||||
const actions = usePlexCardActions(
|
||||
{
|
||||
onOpen: onOpenCard,
|
||||
onRestart: onRestartCard,
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: toggleWatched,
|
||||
},
|
||||
menu.open,
|
||||
);
|
||||
|
||||
async function doRename() {
|
||||
const title = name.trim();
|
||||
if (!title || !d || title === d.title) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexRenameCollection(collectionId, title);
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!d) return;
|
||||
if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) })))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexDeleteCollection(collectionId);
|
||||
invalidate();
|
||||
onBack();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove(card: PlexCard) {
|
||||
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
|
||||
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexCollectionRemoveItem(collectionId, card.id);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function enableEdit() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexSetCollectionEditable(collectionId, true);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const meta = [
|
||||
d?.year_min != null &&
|
||||
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
|
||||
d?.content_rating && vocab(t, "certificate", d.content_rating),
|
||||
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
||||
formatRuntime(d?.total_runtime_s, lang),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div className="glass-media mx-auto max-w-300 p-4">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState shape="poster" label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
||||
posterTitle={t("plex.play")}
|
||||
posterDisabled={!canPlay || queueBusy}
|
||||
showPlayOverlay={canPlay}
|
||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
||||
// Sized to the text beside it (the owner's pick): a title, one meta line and a short
|
||||
// description sat beside a show-sized poster, two thirds of the hero empty under them.
|
||||
posterSizeClass="w-20 sm:w-24"
|
||||
customizeMenu={null}
|
||||
>
|
||||
{renaming ? (
|
||||
<Input
|
||||
className="w-full text-2xl font-semibold"
|
||||
size="heading"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={doRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
armRenameReturn(e);
|
||||
void doRename(); // reports its own failure
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
armRenameReturn(e);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t("plex.collection.rename")}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeading className="text-2xl font-semibold leading-tight">
|
||||
{d.title}
|
||||
</PageHeading>
|
||||
{readOnly && (
|
||||
<Badge title={t("plex.collection.readOnly")}>
|
||||
<Lock />
|
||||
{t("plex.collection.readOnly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
|
||||
{meta.map((m, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "· "}
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.summary && (
|
||||
<ClampedText
|
||||
text={d.summary}
|
||||
lines={3}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{canPlay && (
|
||||
<>
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => playAll(false)}
|
||||
disabled={queueBusy}
|
||||
icon={Play}
|
||||
label={t("plex.collection.playAll")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => playAll(true)}
|
||||
disabled={queueBusy}
|
||||
icon={Shuffle}
|
||||
label={t("plex.collection.shuffle")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<ActionBtn
|
||||
ref={renameTarget}
|
||||
onClick={() => {
|
||||
setName(d.title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
icon={Pencil}
|
||||
label={t("plex.collection.rename")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => setAdding((v) => !v)}
|
||||
icon={Plus}
|
||||
label={t("plex.collection.addItem")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={doDelete}
|
||||
icon={Trash2}
|
||||
label={t("plex.collection.delete")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canEnableEdit && (
|
||||
<ActionBtn
|
||||
onClick={enableEdit}
|
||||
icon={Check}
|
||||
label={t("plex.collection.enableEdit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
{adding && canEdit && (
|
||||
<AddItemPanel
|
||||
collectionId={collectionId}
|
||||
scope={scope}
|
||||
existing={new Set(items.map((c) => c.id))}
|
||||
onAdded={invalidate}
|
||||
onClose={() => setAdding(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Sort sits directly above the grid, pinned right — the same placement (and the same
|
||||
control, options and labels) as the browse grid, so the two read as one idea. */}
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSortPref(next ?? COLLECTION_SORT_DEFAULT)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left
|
||||
an ~85px card whose title shared its line with the touch "…". */}
|
||||
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PlexMenu
|
||||
menu={menu}
|
||||
actions={actions}
|
||||
extra={(c) =>
|
||||
canEdit
|
||||
? [
|
||||
{
|
||||
// A write to the shared Plex library: a menu row (the card's hover
|
||||
// row owns its corner now), still behind the confirmation in `doRemove`.
|
||||
id: "collection-remove",
|
||||
label: t("plex.collection.removeItem"),
|
||||
icon: <X className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
danger: true,
|
||||
disabled: busy,
|
||||
onSelect: () => void doRemove(c),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A compact search-and-add panel: search the collection's library, click a result to add it (write-
|
||||
// through to Plex). Stays open so several items can be added in a row; already-present items are marked.
|
||||
function AddItemPanel({
|
||||
collectionId,
|
||||
scope,
|
||||
existing,
|
||||
onAdded,
|
||||
onClose,
|
||||
}: {
|
||||
collectionId: string;
|
||||
scope: string;
|
||||
existing: Set<string>;
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [term, setTerm] = useState("");
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
|
||||
async function add(card: PlexCard) {
|
||||
try {
|
||||
await api.plexCollectionAddItem(collectionId, card.id);
|
||||
setAdded((s) => new Set(s).add(card.id));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${SURFACE.panel} mb-6 p-4`}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
autoFocus
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder={t("plex.collection.addSearch")}
|
||||
aria-label={t("plex.collection.addSearch")}
|
||||
/>
|
||||
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
<PlexLibraryPicker
|
||||
scope={scope}
|
||||
term={term}
|
||||
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
||||
renderAction={(c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={inColl ? <Check /> : <Plus />}
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
>
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex Discover landing. Covers the rail surface served from the mocked /discover +
|
||||
// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added
|
||||
// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty
|
||||
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
|
||||
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name.
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.title ? `${key}:${opts.title}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const plexDiscover = vi.fn();
|
||||
const plexSurprise = vi.fn();
|
||||
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
plexDiscover: (...a: unknown[]) => plexDiscover(...a),
|
||||
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
|
||||
plexSetState: (...a: unknown[]) => plexSetState(...a),
|
||||
},
|
||||
// PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs
|
||||
// EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply.
|
||||
EMPTY_PLEX_FILTERS: {
|
||||
genres: [],
|
||||
contentRatings: [],
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
collections: [],
|
||||
},
|
||||
}));
|
||||
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { PlexCard } from "../../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
});
|
||||
|
||||
const fullPayload = {
|
||||
scope: "both",
|
||||
continue_watching: [card("cw1", "Resume Me")],
|
||||
recently_watched: [card("rw1", "Watched Me")],
|
||||
recently_added: [card("ra1", "Just Added")],
|
||||
genres: [{ value: "Drama", count: 12, art: [] }],
|
||||
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
|
||||
};
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("PlexDiscover", () => {
|
||||
it("renders each non-empty rail, the genre tiles, and the surprise sample", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.continueWatching");
|
||||
expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.surprise")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.byGenre")).toBeTruthy();
|
||||
// The because-you-watched heading interpolates the anchor title.
|
||||
expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy();
|
||||
// A card from each rail is present (title rendered in the poster label).
|
||||
expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Watched Me").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
|
||||
// Now called with the active filters too (empty default here, no provider mounted).
|
||||
expect(plexDiscover).toHaveBeenCalledWith(
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the discover + surprise queries keyed by the active lang", async () => {
|
||||
// The real cache-separation guard (the queryKeys factory test only pins that a lang ARG survives the
|
||||
// spread): inspect the actual keys the component registered. A regression dropping lang from either
|
||||
// qk call would collide HU/EN in the cache even while the queryFn still fetches the right language.
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("plex.discover.continueWatching");
|
||||
const keys = qc
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.map((q) => q.queryKey);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-discover",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-discover",
|
||||
"surprise",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fires onGenre with the tile's value", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
const onGenre = vi.fn();
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={onGenre}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Drama"));
|
||||
expect(onGenre).toHaveBeenCalledWith("Drama");
|
||||
});
|
||||
|
||||
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
const onSeeAllRecent = vi.fn();
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
|
||||
expect(onSeeAllRecent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reshuffles the surprise rail alone (refetch) without refetching the surface", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("Random Pick");
|
||||
expect(plexSurprise).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
||||
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
||||
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
|
||||
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
|
||||
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
|
||||
});
|
||||
|
||||
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
|
||||
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
|
||||
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
let status: PlexCard["status"] = "new";
|
||||
plexSurprise.mockImplementation(
|
||||
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||
Promise.resolve({
|
||||
scope: "both",
|
||||
items: o.keep?.length
|
||||
? [{ ...card("sp1", "Random Pick"), status }]
|
||||
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
|
||||
}),
|
||||
);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const ui = (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const first = render(ui);
|
||||
await screen.findByText("Pick");
|
||||
first.unmount();
|
||||
render(ui);
|
||||
await screen.findByText("Random Pick");
|
||||
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
|
||||
status = "watched";
|
||||
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
|
||||
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
|
||||
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
|
||||
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
|
||||
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
|
||||
expect(entry?.options.gcTime).toBe(Infinity);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||
await screen.findByText("Pick");
|
||||
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
|
||||
exclude: ["sp1"],
|
||||
});
|
||||
});
|
||||
|
||||
// A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻
|
||||
// deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back
|
||||
// and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles.
|
||||
it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
const answers: ((v: unknown) => void)[] = [];
|
||||
const rereads: ((v: unknown) => void)[] = [];
|
||||
plexSurprise.mockImplementation(
|
||||
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||
plexSurprise.mock.calls.length === 1
|
||||
? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] })
|
||||
: new Promise((r) => (o.keep ? rereads : answers).push(r)),
|
||||
);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Old Pick");
|
||||
const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" });
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
await waitFor(() => expect(rereads).toHaveLength(1));
|
||||
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||
await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] }));
|
||||
fireEvent.click(button());
|
||||
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
await waitFor(() => expect(answers).toHaveLength(2));
|
||||
const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1];
|
||||
expect(last?.[4]).toEqual({ exclude: ["sp1"] });
|
||||
// The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it.
|
||||
await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] }));
|
||||
expect(button().getAttribute("aria-busy")).toBe("true");
|
||||
await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] }));
|
||||
await screen.findByText("New Pick");
|
||||
expect(button().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the empty message when every rail is empty", async () => {
|
||||
plexDiscover.mockResolvedValue({
|
||||
scope: "both",
|
||||
continue_watching: [],
|
||||
recently_watched: [],
|
||||
recently_added: [],
|
||||
genres: [],
|
||||
because_you_watched: null,
|
||||
});
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.empty");
|
||||
});
|
||||
|
||||
it("puts a Surprise card back when its watched toggle fails", async () => {
|
||||
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
|
||||
// left flipped until that read lands — the sibling of the collection page's fix.
|
||||
plexDiscover.mockResolvedValue({
|
||||
...fullPayload,
|
||||
continue_watching: [],
|
||||
recently_watched: [],
|
||||
recently_added: [],
|
||||
because_you_watched: null,
|
||||
});
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
plexSetState.mockRejectedValueOnce(new Error("refused"));
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
|
||||
await waitFor(() => expect(plexSetState).toHaveBeenCalledWith("sp1", "watched"));
|
||||
await waitFor(() => expect(screen.getByLabelText("plex.markWatched")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||
// from one to the next in page order (the owner's call).
|
||||
describe("PlexDiscover · keyboard", () => {
|
||||
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findAllByText("Random Pick");
|
||||
// Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres.
|
||||
// Registered by a passive effect, which can land after the cards paint.
|
||||
await waitFor(() => expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(6));
|
||||
const lists = [...document.querySelectorAll("[data-nav-list]")];
|
||||
// …and each holds its cards as items: the genre tiles too, not only the posters.
|
||||
expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true);
|
||||
const step = () => {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent;
|
||||
};
|
||||
const first = step();
|
||||
const second = step();
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBeTruthy();
|
||||
expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../../lib/api";
|
||||
import PosterTile from "./PosterTile";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { usePlex } from "./plexContext";
|
||||
import { Carousel } from "../../components/ui/Carousel";
|
||||
import { PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
|
||||
// The Plex "Discover" landing: a stack of horizontal Carousel rails served from ONE
|
||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
|
||||
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
|
||||
// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like
|
||||
// a grid card; the watched-toggle optimistically flips THIS surface's cache too.
|
||||
|
||||
function CardRail({
|
||||
title,
|
||||
ariaLabel,
|
||||
seeAllLabel,
|
||||
onSeeAll,
|
||||
cards,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onToggleWatched,
|
||||
action,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
ariaLabel: string;
|
||||
seeAllLabel?: string;
|
||||
onSeeAll?: () => void;
|
||||
cards: PlexCard[];
|
||||
onOpenCard: (c: PlexCard) => void;
|
||||
onRestartCard: (c: PlexCard) => void;
|
||||
onInfoCard: (c: PlexCard) => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
|
||||
}) {
|
||||
// One menu per rail (see ui/ContextMenu), not one per poster.
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched },
|
||||
menu.open,
|
||||
);
|
||||
const heading =
|
||||
action != null ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{title}
|
||||
{action}
|
||||
</span>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Carousel
|
||||
title={heading}
|
||||
ariaLabel={ariaLabel}
|
||||
onSeeAll={onSeeAll}
|
||||
seeAllLabel={seeAllLabel}
|
||||
keyboardNav
|
||||
>
|
||||
{cards.map((c, i) => (
|
||||
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
shelf
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onRestart={() => onRestartCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
<PlexMenu menu={menu} actions={actions} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlexDiscover({
|
||||
scope,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onGenre,
|
||||
onSeeAllRecent,
|
||||
}: {
|
||||
scope: string;
|
||||
onOpenCard: (c: PlexCard) => void;
|
||||
onRestartCard: (c: PlexCard) => void;
|
||||
onInfoCard: (c: PlexCard) => void;
|
||||
onGenre: (genre: string) => void;
|
||||
onSeeAllRecent: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang(i18n.language);
|
||||
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
|
||||
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
|
||||
const { filters } = usePlex();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexDiscover(scope, lang, filters),
|
||||
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||
});
|
||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
|
||||
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
|
||||
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
|
||||
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
|
||||
const surpriseKey = qk.plexSurprise(scope, lang, filters);
|
||||
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
|
||||
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
|
||||
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
|
||||
// answer does not stop the spinner of the deal that replaced it.
|
||||
const redraw = useRef(false);
|
||||
const lastDeal = useRef<Promise<unknown> | null>(null);
|
||||
const [dealing, setDealing] = useState(false);
|
||||
const surpriseQ = useQuery({
|
||||
queryKey: surpriseKey,
|
||||
queryFn: () => {
|
||||
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
|
||||
(c) => c.id,
|
||||
);
|
||||
if (!redraw.current && shown.length > 0) {
|
||||
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
|
||||
}
|
||||
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
|
||||
lastDeal.current = deal;
|
||||
const done = () => {
|
||||
if (lastDeal.current !== deal) return;
|
||||
redraw.current = false;
|
||||
setDealing(false);
|
||||
};
|
||||
void deal.then(done, done);
|
||||
return deal;
|
||||
},
|
||||
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
|
||||
// a collected draw is a new one.
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const reshuffle = () => {
|
||||
redraw.current = true;
|
||||
setDealing(true);
|
||||
void surpriseQ.refetch();
|
||||
};
|
||||
|
||||
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
||||
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
||||
const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) =>
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
||||
qk.plexSurprise(scope, lang, filters),
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) }
|
||||
: old,
|
||||
);
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => {
|
||||
const flip = (cards: PlexCard[]) =>
|
||||
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
||||
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang, filters), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
continue_watching: flip(old.continue_watching),
|
||||
recently_added: flip(old.recently_added),
|
||||
because_you_watched: old.because_you_watched
|
||||
? { ...old.because_you_watched, items: flip(old.because_you_watched.items) }
|
||||
: null,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
setSurpriseStatus(card, next);
|
||||
},
|
||||
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
|
||||
// refused toggle is put back at once rather than left flipped until that read lands.
|
||||
revert: () => setSurpriseStatus(card, card.status),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
|
||||
if (q.isError && !q.data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading || !q.data) {
|
||||
return <LoadingState shape="poster" label={t("plex.loading")} />;
|
||||
}
|
||||
|
||||
const d = q.data;
|
||||
const surprise = surpriseQ.data?.items ?? [];
|
||||
const bw = d.because_you_watched;
|
||||
const nothing =
|
||||
d.continue_watching.length === 0 &&
|
||||
d.recently_watched.length === 0 &&
|
||||
d.recently_added.length === 0 &&
|
||||
d.genres.length === 0 &&
|
||||
surprise.length === 0 &&
|
||||
(!bw || bw.items.length === 0);
|
||||
|
||||
if (nothing) {
|
||||
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
|
||||
}
|
||||
|
||||
const railProps = { onOpenCard, onRestartCard, onInfoCard, onToggleWatched: toggleWatched };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{d.continue_watching.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.continueWatching")}
|
||||
ariaLabel={t("plex.discover.continueWatching")}
|
||||
cards={d.continue_watching}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_watched.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyWatched")}
|
||||
ariaLabel={t("plex.discover.recentlyWatched")}
|
||||
cards={d.recently_watched}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_added.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyAdded")}
|
||||
ariaLabel={t("plex.discover.recentlyAdded")}
|
||||
onSeeAll={onSeeAllRecent}
|
||||
cards={d.recently_added}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{surprise.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.surprise")}
|
||||
ariaLabel={t("plex.discover.surprise")}
|
||||
cards={surprise}
|
||||
action={
|
||||
<IconButton
|
||||
onClick={reshuffle}
|
||||
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
|
||||
// new draw that never came.
|
||||
loading={dealing}
|
||||
label={t("plex.discover.reshuffle")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</IconButton>
|
||||
}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.genres.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g, i) => (
|
||||
<PosterTile
|
||||
key={g.value}
|
||||
navIndex={i}
|
||||
title={g.value}
|
||||
art={g.art}
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="h-24"
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bw && bw.items.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
cards={bw.items}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
|
||||
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
|
||||
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
|
||||
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
|
||||
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
// A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog.
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.name
|
||||
? `${key}:${opts.name}`
|
||||
: key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string"
|
||||
? opts.defaultValue
|
||||
: key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexInfo from "./PlexInfo";
|
||||
import { CREW_SHOWN } from "./plexDetailAside";
|
||||
import type { PlexItemDetail } from "../../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const detail: PlexItemDetail = {
|
||||
id: "m1",
|
||||
kind: "movie",
|
||||
title: "Test Movie",
|
||||
summary: "A summary.",
|
||||
year: 2010,
|
||||
duration_seconds: 7200,
|
||||
playable: "direct",
|
||||
thumb: "/api/plex/image/m1",
|
||||
art: "/api/plex/image/m1?variant=art",
|
||||
cast: [],
|
||||
imdb_rating: 7.8,
|
||||
imdb_id: "tt1",
|
||||
imdb_url: "https://www.imdb.com/title/tt1",
|
||||
rating: 8.4, // Plex audience
|
||||
content_rating: "R",
|
||||
genres: ["Crime"],
|
||||
directors: ["Plex Director"],
|
||||
studio: "Fallback Studio",
|
||||
tagline: "A tagline.",
|
||||
markers: [],
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
tmdb: {
|
||||
tmdb_id: 42,
|
||||
rating: 7.2,
|
||||
vote_count: 1234,
|
||||
budget: 160000000,
|
||||
revenue: 830000000,
|
||||
keywords: ["heist", "bank robbery"],
|
||||
crew: [
|
||||
{ name: "Jane Director", job: "Director" },
|
||||
{ name: "Sam Writer", job: "Writer" },
|
||||
],
|
||||
studios: ["Studio A", "Studio B"],
|
||||
// One coded (named in the reader's language), one from a row enriched before codes were stored.
|
||||
production_countries: [
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: null, name: "United States" },
|
||||
],
|
||||
recommendations: [],
|
||||
backdrops: [],
|
||||
},
|
||||
};
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("PlexInfo enriched detail (page)", () => {
|
||||
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
expect(screen.getByText("7.8")).toBeTruthy();
|
||||
expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted)
|
||||
expect(screen.getByText("7.2")).toBeTruthy();
|
||||
expect(screen.getByText("plex.info.audience")).toBeTruthy();
|
||||
expect(screen.getByText("8.4")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the facts panel with budget / revenue / studios / countries", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||||
expect(screen.getByText("plex.info.facts.title")).toBeTruthy();
|
||||
expect(screen.getByText("plex.info.facts.budget")).toBeTruthy();
|
||||
expect(screen.getByText("$160M")).toBeTruthy();
|
||||
expect(screen.getByText("$830M")).toBeTruthy();
|
||||
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
|
||||
// A coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
|
||||
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keyword chips filter the library on click", () => {
|
||||
const onFilter = vi.fn();
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByText("heist"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
|
||||
// Every value that filters says so on hover — the genre, certificate and studio had no hint
|
||||
// while the keyword and the year did — and a value filters in the facts panel as in the header.
|
||||
it("every filtering value carries a filter hint", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
|
||||
for (const v of ["heist", "Crime", "R", "Studio A"])
|
||||
for (const el of screen.getAllByText(v))
|
||||
expect(el.closest("[title]")?.getAttribute("title")).toBe(`plex.info.filterValue:${v}`);
|
||||
});
|
||||
|
||||
it("the facts panel's year and certificate filter like the header's", () => {
|
||||
const onFilter = vi.fn();
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||||
const [, factsCert] = screen.getAllByText("R");
|
||||
fireEvent.click(factsCert!);
|
||||
expect(onFilter).toHaveBeenLastCalledWith({ contentRatings: ["R"] });
|
||||
const [, factsYear] = screen.getAllByText("2010");
|
||||
fireEvent.click(factsYear!);
|
||||
expect(onFilter).toHaveBeenLastCalledWith({ yearMin: 2010, yearMax: 2010 });
|
||||
});
|
||||
|
||||
it("crew members open the person page on click", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
expect(screen.getByText("plex.info.crew")).toBeTruthy();
|
||||
// The job is TMDB's English word, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Jane Director"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
|
||||
});
|
||||
|
||||
it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => {
|
||||
const onFilter = vi.fn();
|
||||
const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null };
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={noImdb} variant="page" onFilter={onFilter} />);
|
||||
expect(screen.queryByText("IMDb")).toBeNull();
|
||||
// audience (8.4) is now the primary/filterable score → clicking it filters ratingMin.
|
||||
fireEvent.click(screen.getByText("8.4"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ ratingMin: 8 });
|
||||
});
|
||||
|
||||
it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => {
|
||||
const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8)
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={dupe} variant="page" />);
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed
|
||||
expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only)
|
||||
});
|
||||
|
||||
it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="overlay" />);
|
||||
expect(screen.queryByText("plex.info.facts.title")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.keywords")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.crew")).toBeNull();
|
||||
// but the ratings row still renders (it's compact enough for the overlay)
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("an episode page links up to its show and season", () => {
|
||||
const episode: PlexItemDetail = {
|
||||
...detail,
|
||||
id: "e1",
|
||||
kind: "episode",
|
||||
title: "Pilot",
|
||||
show_title: "A Show",
|
||||
show_id: "s1",
|
||||
season_id: "se1",
|
||||
season_number: 1,
|
||||
episode_number: 25,
|
||||
tmdb: null,
|
||||
};
|
||||
|
||||
it("the show title opens the show, the episode number its season", () => {
|
||||
const onOpenShow = vi.fn();
|
||||
const onOpenSeason = vi.fn();
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={episode}
|
||||
variant="page"
|
||||
onOpenShow={onOpenShow}
|
||||
onOpenSeason={onOpenSeason}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "A Show" }));
|
||||
expect(onOpenShow).toHaveBeenCalledWith("s1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "S1·E25" }));
|
||||
expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1");
|
||||
expect(screen.getByText(/— Pilot/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("over the running player the header stays plain text", () => {
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={episode}
|
||||
variant="overlay"
|
||||
onOpenShow={noop}
|
||||
onOpenSeason={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "A Show" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull();
|
||||
expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
|
||||
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||
// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under
|
||||
// the hero only the collection strips and the cast remain, stacked.
|
||||
describe("PlexInfo — the hero's second column (page)", () => {
|
||||
const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]";
|
||||
// The strips are Carousels, which watch their size.
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
const heading = (name: string) => screen.getByRole("heading", { name });
|
||||
const follows = (a: HTMLElement, b: HTMLElement) =>
|
||||
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const full: PlexItemDetail = {
|
||||
...detail,
|
||||
cast: [{ name: "An Actor", role: "Hero" }],
|
||||
collections: [
|
||||
{
|
||||
id: "c1",
|
||||
title: "The Collection",
|
||||
source: "plex",
|
||||
items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||
const aside = screen.getByTestId("detail-aside");
|
||||
expect(aside.contains(heading("plex.info.facts.title"))).toBe(true);
|
||||
expect(aside.contains(heading("plex.info.crew"))).toBe(true);
|
||||
expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true);
|
||||
// The grid's two children: the poster + text, and the column.
|
||||
const grid = aside.parentElement!;
|
||||
expect(grid.className).toContain(TWO_COLUMNS);
|
||||
const main = grid.children[0]!;
|
||||
const side = grid.children[1];
|
||||
expect(side).toBe(aside);
|
||||
expect(main.contains(screen.getByText("A summary."))).toBe(true);
|
||||
expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true);
|
||||
// Below the hero (the panel under the title): the collection, then the cast — and neither in it.
|
||||
const hero = grid.parentElement!;
|
||||
expect(hero.contains(heading("Test Movie"))).toBe(true);
|
||||
const coll = heading("The Collection");
|
||||
const cast = heading("plex.info.cast");
|
||||
expect(hero.contains(coll) || hero.contains(cast)).toBe(false);
|
||||
expect(follows(hero, coll) && follows(coll, cast)).toBe(true);
|
||||
});
|
||||
|
||||
it("without facts or crew there is no second column; the keywords still sit under the text", () => {
|
||||
const bare: PlexItemDetail = {
|
||||
...detail,
|
||||
year: null,
|
||||
duration_seconds: null,
|
||||
content_rating: null,
|
||||
studio: null,
|
||||
tmdb: {
|
||||
...detail.tmdb!,
|
||||
budget: null,
|
||||
revenue: null,
|
||||
studios: [],
|
||||
production_countries: [],
|
||||
crew: [],
|
||||
},
|
||||
};
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={bare} variant="page" />);
|
||||
expect(screen.queryByTestId("detail-aside")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull();
|
||||
const kw = screen.getByTestId("detail-keywords");
|
||||
expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull();
|
||||
expect(follows(heading("Test Movie"), kw)).toBe(true);
|
||||
});
|
||||
|
||||
// The hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
|
||||
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
|
||||
it("keeps the poster at its own height, not stretched to the row", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||
const poster = document.querySelector<HTMLImageElement>(`img[src="${detail.thumb}"]`)!;
|
||||
expect(poster.className).toContain("aspect-2/3");
|
||||
expect(poster.className.split(/\s+/)).toContain("self-start");
|
||||
});
|
||||
|
||||
// The owner's call: twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
|
||||
// the rest behind "Show N more"; a list barely longer shows whole.
|
||||
it("shows the first crew names and opens the rest on demand", () => {
|
||||
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||||
variant="page"
|
||||
/>,
|
||||
);
|
||||
const aside = screen.getByTestId("detail-aside");
|
||||
expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`);
|
||||
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
|
||||
const more = screen.getByRole("button", { name: "plex.info.crewMore" });
|
||||
expect(more.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(more);
|
||||
expect(aside.textContent).toContain("Person 11");
|
||||
const less = screen.getByRole("button", { name: "plex.info.crewLess" });
|
||||
expect(less.getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.click(less);
|
||||
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
|
||||
});
|
||||
|
||||
it("shows a crew only a little longer than that whole, with no toggle", () => {
|
||||
const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({
|
||||
name: `Person ${i}`,
|
||||
job: "Producer",
|
||||
}));
|
||||
wrap(
|
||||
<PlexInfo
|
||||
onPlayItem={noop}
|
||||
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||||
variant="page"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
|
||||
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
|
||||
});
|
||||
|
||||
// The cast row is the shared CastRow now; the page and the player's overlay hand it their
|
||||
// own palette. Only the overlay sits on the dark art, so only it may carry the white text.
|
||||
it.each([
|
||||
["page", false],
|
||||
["overlay", true],
|
||||
] as const)("the %s variant's cast row takes its own palette", (variant, white) => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={full} variant={variant} />);
|
||||
expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white);
|
||||
expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white);
|
||||
});
|
||||
});
|
||||
|
||||
// A collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||
// toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else.
|
||||
describe("a collection strip's card", () => {
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
const withColl: PlexItemDetail = {
|
||||
...detail,
|
||||
collections: [
|
||||
{
|
||||
id: "c1",
|
||||
title: "The Collection",
|
||||
source: "plex",
|
||||
items: [
|
||||
{
|
||||
id: "m2",
|
||||
type: "movie",
|
||||
title: "Sequel",
|
||||
thumb: "/api/plex/image/m2",
|
||||
status: "in_progress",
|
||||
position_seconds: 60,
|
||||
duration_seconds: 6000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("carries the card's whole action set, the Restart in its menu too", async () => {
|
||||
const onPlayItem = vi.fn();
|
||||
wrap(<PlexInfo detail={withColl} variant="page" onPlayItem={onPlayItem} />);
|
||||
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
|
||||
.parentElement!;
|
||||
const named = (n: string) =>
|
||||
[...strip.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === n);
|
||||
expect(named("plex.markWatched")).toBeDefined();
|
||||
expect(named("plex.playlistAdd.manage")).toBeDefined();
|
||||
const more = strip.querySelector<HTMLButtonElement>("button[aria-haspopup]");
|
||||
expect(more).not.toBeNull();
|
||||
fireEvent.click(more!);
|
||||
const restart = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
|
||||
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
|
||||
});
|
||||
|
||||
// Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard.
|
||||
it("opens the member's page from its menu, as its title does", async () => {
|
||||
const onOpenInfo = vi.fn();
|
||||
wrap(
|
||||
<PlexInfo detail={withColl} variant="page" onPlayItem={vi.fn()} onOpenInfo={onOpenInfo} />,
|
||||
);
|
||||
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
|
||||
.parentElement!;
|
||||
fireEvent.click(strip.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
|
||||
const rows = await screen.findAllByRole("menuitem", { hidden: true });
|
||||
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
|
||||
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,686 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
|
||||
import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
DETAIL_POSTER,
|
||||
episodeLine,
|
||||
episodeName,
|
||||
episodeNumbers,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailAside,
|
||||
useDetailPrefs,
|
||||
} from "../../lib/plexDetail";
|
||||
import { Carousel } from "../../components/ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "../../features/plexCards/PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { CastRow, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// 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.
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew) render
|
||||
// on the page variant only; the overlay stays lean.
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
|
||||
// declared during render is a NEW component type on every render — React unmounts and remounts the
|
||||
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
|
||||
// on the button while the info page re-renders around it.
|
||||
function Score({
|
||||
score,
|
||||
filterable,
|
||||
onFilterRating,
|
||||
}: {
|
||||
score: number;
|
||||
filterable: boolean;
|
||||
onFilterRating?: (score: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const shown = formatDecimal(score, i18n.language);
|
||||
return filterable && onFilterRating ? (
|
||||
<Pressable
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex min-h-6 items-center gap-1 hover:underline touch-target"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{shown}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{shown}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-source ratings cluster: IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
|
||||
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
|
||||
function RatingsRow({
|
||||
imdbRating,
|
||||
imdbUrl,
|
||||
audience,
|
||||
tmdbRating,
|
||||
tmdbVotes,
|
||||
onFilterRating,
|
||||
onDark,
|
||||
}: {
|
||||
imdbRating?: number | null;
|
||||
imdbUrl?: string | null;
|
||||
audience?: number | null;
|
||||
tmdbRating?: number | null;
|
||||
tmdbVotes?: number | null;
|
||||
onFilterRating?: (score: number) => void;
|
||||
/** Over the always-dark player: the page's `--rating` is tuned for the theme's page, so a light
|
||||
* theme's dark gold would sit on the player's black (2.6:1, measured). */
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const primary = imdbRating ?? audience ?? null; // the filterable score
|
||||
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
|
||||
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
||||
onDark ? "bg-rating-media/15 text-rating-media" : "bg-rating/15 text-rating"
|
||||
}`;
|
||||
const has = (x?: number | null): x is number => x != null;
|
||||
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{has(imdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score
|
||||
score={imdbRating}
|
||||
filterable={imdbRating === primary}
|
||||
onFilterRating={onFilterRating}
|
||||
/>
|
||||
<span className="text-2xs font-normal">IMDb</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
|
||||
{has(imdbRating) && imdbUrl && (
|
||||
<IconLink
|
||||
href={imdbUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label={t("plex.info.openImdb")}
|
||||
variant={onDark ? "overlay" : "ghost"}
|
||||
className="-ml-1.5"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
)}
|
||||
{has(tmdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={tmdbRating} filterable={false} />
|
||||
<span className="text-2xs font-normal">
|
||||
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{has(audience) && imdbRating !== audience && (
|
||||
<span className={chipCls}>
|
||||
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
|
||||
<span className="text-2xs font-normal">{t("plex.info.audience")}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
||||
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
|
||||
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
|
||||
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!detail.playable) return null;
|
||||
const cls = playableClass(detail.playable, detail.transcode_hw);
|
||||
const sub = [
|
||||
detail.codec_video ? detail.codec_video.toUpperCase() : null,
|
||||
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-muted"}`} />
|
||||
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||
{sub && <span className="opacity-70">{sub}</span>}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||
onOpenPerson?: (name: string) => void;
|
||||
// Play a sibling title from a collection strip: the page opens its player, the overlay plays it
|
||||
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
// Page variant, an episode: its show title opens the show page, its "S1·E25" the season page.
|
||||
onOpenShow?: (showId: string) => void;
|
||||
onOpenSeason?: (showId: string, seasonId: string) => void;
|
||||
// Page variant: a collection strip's title opens that title's info page (its picture plays it).
|
||||
onOpenInfo?: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function PlexInfo({
|
||||
detail,
|
||||
variant,
|
||||
onPlay,
|
||||
onClose,
|
||||
onStateChange,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
onPlayItem,
|
||||
library,
|
||||
onOpenShow,
|
||||
onOpenSeason,
|
||||
onOpenInfo,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
|
||||
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
|
||||
// is PlexInfo-specific state, persisted through the same savePref.
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
||||
useDetailPrefs();
|
||||
// The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
|
||||
const aside = useDetailAside(detail);
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||
{}) as { plexHiddenStripSources?: string[] };
|
||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
||||
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
||||
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||
Array.isArray(prefs.plexHiddenStripSources) ? prefs.plexHiddenStripSources : [],
|
||||
);
|
||||
const toggleSource = (src: string) => {
|
||||
setHiddenSources((cur) => {
|
||||
const next = cur.includes(src) ? cur.filter((s) => s !== src) : [...cur, src];
|
||||
savePref({ plexHiddenStripSources: next });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||
|
||||
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
|
||||
// its TMDB backdrops, and its collection siblings' art. Cleared in the overlay
|
||||
// variant / when disabled / on unmount.
|
||||
const backdropPool = [
|
||||
detail.art,
|
||||
...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
|
||||
...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []),
|
||||
];
|
||||
useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade);
|
||||
|
||||
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||
const setState = async (status: "new" | "watched") => {
|
||||
try {
|
||||
await api.plexSetState(detail.id, status);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||
onStateChange?.();
|
||||
};
|
||||
|
||||
// A collection sibling is a Plex card like any other: the related strip's menu, its
|
||||
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
const toggleColWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueriesData<PlexItemDetail>({ queryKey: qk.plexItem(detail.id) }, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
collections: old.collections?.map((col) => ({
|
||||
...col,
|
||||
items: col.items.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
})),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexItem(card.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
// Details wherever the member's title opens its page (not in the overlay): the menu is that door
|
||||
// for the keyboard.
|
||||
const colActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayItem(c.id),
|
||||
onRestart: (c) => onPlayItem(c.id, true),
|
||||
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
|
||||
onToggleWatched: toggleColWatched,
|
||||
},
|
||||
colMenu.open,
|
||||
);
|
||||
|
||||
const cast = showCast ? detail.cast : [];
|
||||
const overlay = variant === "overlay";
|
||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||
|
||||
// Distinct strip-source buckets present on THIS item, in strip order — one toggle each.
|
||||
const presentSources = (detail.collections ?? []).reduce<string[]>((acc, c) => {
|
||||
if (!acc.includes(c.source)) acc.push(c.source);
|
||||
return acc;
|
||||
}, []);
|
||||
const sourceLabel = (src: string) =>
|
||||
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||
|
||||
const asideShown = !overlay && aside.column;
|
||||
const infoTitle =
|
||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||
// Up from an episode: the show's title and the episode's season are links on the page (not over
|
||||
// the running player, where leaving would stop it).
|
||||
const showId = detail.kind === "episode" && !overlay ? detail.show_id : null;
|
||||
const seasonId = detail.season_id;
|
||||
const numbers = episodeNumbers(detail);
|
||||
const upLink = "hover:text-accent hover:underline";
|
||||
|
||||
return (
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
||||
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||
<div className={overlay ? "" : `${SURFACE.panel} @container 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">
|
||||
{/* The page's own h1; over the running player it is the info dialog's title. */}
|
||||
{overlay ? (
|
||||
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
|
||||
) : (
|
||||
<PageHeading className="text-2xl font-bold">
|
||||
{showId && onOpenShow && detail.show_title ? (
|
||||
<Pressable selectable className={upLink} onClick={() => onOpenShow(showId)}>
|
||||
{infoTitle}
|
||||
</Pressable>
|
||||
) : (
|
||||
infoTitle
|
||||
)}
|
||||
</PageHeading>
|
||||
)}
|
||||
{detail.kind === "episode" && (
|
||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
{showId && seasonId && onOpenSeason && numbers ? (
|
||||
<>
|
||||
<Pressable
|
||||
selectable
|
||||
className={upLink}
|
||||
onClick={() => onOpenSeason(showId, seasonId)}
|
||||
>
|
||||
{episodeName(detail)}
|
||||
</Pressable>
|
||||
{` — ${detail.title}`}
|
||||
</>
|
||||
) : (
|
||||
episodeLine(detail)
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DetailCustomizeMenu
|
||||
overlay={overlay}
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={detail.cast.length > 0}
|
||||
extra={
|
||||
<>
|
||||
{!overlay && <DetailAsideToggles aside={aside} />}
|
||||
{presentSources.map((src) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
label={sourceLabel(src)}
|
||||
on={!hiddenSources.includes(src)}
|
||||
onClick={() => toggleSource(src)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{overlay && onClose && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
label={t("plex.player.close")}
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
|
||||
<div
|
||||
className={
|
||||
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
{/* Poster */}
|
||||
<RemoteImage
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
|
||||
// The poster's own box, empty, so the text beside it does not jump into its place.
|
||||
fallback={
|
||||
<div
|
||||
className={`shrink-0 self-start rounded-xl border border-border bg-surface ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} 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
|
||||
tag
|
||||
onDark={overlay}
|
||||
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
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{vocab(t, "certificate", detail.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? (
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||
>
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
<PlaybackChip detail={detail} overlay={overlay} />
|
||||
</div>
|
||||
|
||||
{/* Multi-source ratings row: IMDb / TMDb / audience, whichever are present. */}
|
||||
<div className="mt-2">
|
||||
<RatingsRow
|
||||
imdbRating={detail.imdb_rating}
|
||||
imdbUrl={detail.imdb_url}
|
||||
audience={detail.rating}
|
||||
tmdbRating={detail.tmdb?.rating}
|
||||
tmdbVotes={detail.tmdb?.vote_count}
|
||||
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
||||
onDark={overlay}
|
||||
/>
|
||||
</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}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||
<span>{t("plex.info.director")}:</span>
|
||||
{detail.directors.map((d) => (
|
||||
<Filterable
|
||||
key={d}
|
||||
tag
|
||||
onDark={overlay}
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.summary && (
|
||||
<p
|
||||
className={`measure mt-3 whitespace-pre-line 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} icon={<Play className="fill-current" />}>
|
||||
{inProgress ? t("plex.resume") : t("plex.play")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.status === "watched" ? (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.markUnwatched")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
||||
{t("plex.info.markWatched")}
|
||||
</Button>
|
||||
)}
|
||||
{inProgress && (
|
||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||
{t("plex.info.clearResume")}
|
||||
</Button>
|
||||
)}
|
||||
{detail.kind === "movie" && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setPlaylistOpen(true)}
|
||||
icon={<ListPlus />}
|
||||
>
|
||||
{t("plex.playlistAdd.manage")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && detail.kind === "movie" && library && (
|
||||
<Button
|
||||
variant="glass"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
icon={<FolderPlus />}
|
||||
>
|
||||
{t("plex.collEditor.manage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!overlay && aside.shown.keywords && (
|
||||
<DetailKeywords
|
||||
keywords={detail.tmdb?.keywords ?? []}
|
||||
onFilter={onFilter}
|
||||
className="mt-5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{asideShown && (
|
||||
<DetailAside
|
||||
facts={detail}
|
||||
shown={aside.shown}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* End hero panel. */}
|
||||
|
||||
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
|
||||
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
|
||||
sat beside them moved up into the hero's second column. The overlay (over
|
||||
the running player) is the same stack, lean. */}
|
||||
{(() => {
|
||||
const collections = (detail.collections ?? []).filter(
|
||||
(col) => !hiddenSources.includes(col.source),
|
||||
);
|
||||
const strips = collections.map((col) => (
|
||||
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{col.title}
|
||||
</h2>
|
||||
{onFilter && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
onFilter({
|
||||
collections: [col.id],
|
||||
collectionTitles: { [col.id]: col.title },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("plex.info.browseCollection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav>
|
||||
{col.items.map((m, i) => (
|
||||
// The one media card, as on every other strip. A grid cell, so the
|
||||
// card fills the slot the carousel stretches to its tallest card.
|
||||
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
card={{ ...m, type: "movie" }}
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlayItem(m.id)}
|
||||
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
|
||||
onRestart={() => onPlayItem(m.id, true)}
|
||||
onToggleWatched={toggleColWatched}
|
||||
onAdd={colActions.add}
|
||||
menuProps={colMenu.triggerProps}
|
||||
menuButtonProps={colMenu.buttonProps}
|
||||
menuOpen={colMenu.open && colMenu.target?.id === m.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
));
|
||||
const castRow = cast.length > 0 && (
|
||||
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<CastRow
|
||||
cast={cast}
|
||||
tone={overlay ? "overlay" : "panel"}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (overlay)
|
||||
return (
|
||||
<>
|
||||
{strips}
|
||||
{castRow}
|
||||
</>
|
||||
);
|
||||
if (strips.length === 0 && !castRow) return null;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{strips}
|
||||
{castRow}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{editorOpen && library && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: detail.id, title: detail.title }}
|
||||
library={library}
|
||||
memberOf={(detail.collections ?? []).map((c) => c.id)}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onChanged={() => onStateChange?.()}
|
||||
/>
|
||||
)}
|
||||
{playlistOpen && (
|
||||
<PlexPlaylistAdd
|
||||
target={{ kind: "single", ratingKey: detail.id, title: detail.title }}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<PlexMenu menu={colMenu} actions={colActions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the
|
||||
// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in
|
||||
// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller
|
||||
// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid)
|
||||
// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so
|
||||
// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state
|
||||
// directly.
|
||||
import { type ComponentProps } from "react";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
// Identity debounce so a passed term is reflected immediately.
|
||||
vi.mock("../../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||
|
||||
// api.plexLibrary stub — returns whatever the current test queued.
|
||||
const plexLibrary = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
const card = (id: string, title: string, year = 2000) => ({
|
||||
id,
|
||||
type: "movie" as const,
|
||||
title,
|
||||
year,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
});
|
||||
const result = (items: ReturnType<typeof card>[]) => ({
|
||||
scope: "both",
|
||||
total: items.length,
|
||||
offset: 0,
|
||||
limit: 12,
|
||||
items,
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
plexLibrary.mockReset();
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderPicker = (props: Partial<ComponentProps<typeof PlexLibraryPicker>> = {}) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term=""
|
||||
renderAction={(c) => <button>act:{c.id}</button>}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("PlexLibraryPicker", () => {
|
||||
it("renders nothing and fires no query while disabled, even with a term", () => {
|
||||
const { container } = renderPicker({ enabled: false, term: "matrix" });
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the emptyHint (and fires no query) before a term is typed", () => {
|
||||
renderPicker({ term: "", emptyHint: <p>EMPTY_HINT</p> });
|
||||
expect(screen.getByText("EMPTY_HINT")).toBeTruthy();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => {
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")]));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy());
|
||||
expect(screen.getByText("act:m2")).toBeTruthy();
|
||||
expect(screen.getByText("The Matrix")).toBeTruthy();
|
||||
expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 });
|
||||
});
|
||||
|
||||
it("renders the honest error state (not an empty/no-results state) when the query fails", async () => {
|
||||
plexLibrary.mockRejectedValue(new Error("boom"));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy());
|
||||
expect(screen.queryByText("plex.collection.addNoResults")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-results message when the query returns an empty list", async () => {
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
renderPicker({ term: "zzzz" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { LoadingState } from "../../components/ui/QueryState";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
// render the same result rows, differing only in the per-row action. Callers keep their own chrome
|
||||
// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
|
||||
// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
|
||||
export default function PlexLibraryPicker({
|
||||
scope,
|
||||
term,
|
||||
enabled = true,
|
||||
emptyHint = null,
|
||||
renderAction,
|
||||
}: {
|
||||
scope: string;
|
||||
/** The raw search term; debounced internally. */
|
||||
term: string;
|
||||
/** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
|
||||
enabled?: boolean;
|
||||
/** Shown while the (debounced) term is empty — a hint, or nothing. */
|
||||
emptyHint?: ReactNode;
|
||||
/** The trailing control for each result row (Add / Create-with / …). */
|
||||
renderAction: (card: PlexCard) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-pick", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: enabled && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
if (!enabled) return null;
|
||||
if (debounced.length === 0) return <>{emptyHint}</>;
|
||||
if (q.isLoading) return <LoadingState shape="list" label={t("plex.loading")} />;
|
||||
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
||||
if (results.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<RemoteImage
|
||||
{...cardImage(c.thumb)}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
{renderAction(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
|
||||
// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles;
|
||||
// the card's hover "add" and the menu's "Add to playlist" open the same dialog.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { api, HttpError, type PlexCard, type PlexCollection } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
comfortPins: () => Promise.resolve({ items: [] }),
|
||||
plexPlaylists: () => Promise.resolve({ playlists: [] }),
|
||||
},
|
||||
};
|
||||
});
|
||||
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
||||
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import {
|
||||
usePlexCardActions,
|
||||
type PlexActionHandlers,
|
||||
} from "../../features/plexCards/usePlexCardActions";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions): matched on text.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const card = (extra: Partial<PlexCard>): PlexCard => ({
|
||||
id: "m1",
|
||||
type: "movie",
|
||||
title: "A film",
|
||||
thumb: "",
|
||||
status: "new",
|
||||
...extra,
|
||||
});
|
||||
|
||||
function Grid({
|
||||
c,
|
||||
handlers,
|
||||
pin,
|
||||
extra,
|
||||
}: {
|
||||
c: PlexCard;
|
||||
handlers: PlexActionHandlers;
|
||||
pin?: boolean;
|
||||
extra?: Parameters<typeof PlexMenu>[0]["extra"];
|
||||
}) {
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const actions = usePlexCardActions(handlers, menu.open);
|
||||
return (
|
||||
<>
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => handlers.onOpen(c)}
|
||||
onToggleWatched={handlers.onToggleWatched}
|
||||
onAdd={actions.add}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
<PlexMenu menu={menu} actions={actions} pin={pin} extra={extra} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(
|
||||
c: PlexCard,
|
||||
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
|
||||
{ admin = false }: { admin?: boolean } = {},
|
||||
) {
|
||||
const handlers = {
|
||||
onOpen: vi.fn(),
|
||||
onInfo: vi.fn(),
|
||||
onToggleWatched: vi.fn(),
|
||||
onRestart: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Grid c={c} handlers={handlers} {...opts} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers, qc };
|
||||
}
|
||||
// The card root holds the menu's trigger props (right-click anywhere on it).
|
||||
const rightClick = () =>
|
||||
fireEvent.contextMenu(screen.getByText("A film", { selector: ".line-clamp-2" }));
|
||||
const moreButtons = () => screen.getAllByRole("button", { name: "card.menu.more" });
|
||||
|
||||
describe("a Plex title's menu", () => {
|
||||
it("lists a movie's whole action list, in its groups", () => {
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
expect(labels()).toEqual([
|
||||
"plex.play",
|
||||
"plex.markWatched",
|
||||
"plex.playlistAdd.manage",
|
||||
"plex.info.title",
|
||||
"common.copy.title",
|
||||
"home.pin.add",
|
||||
]);
|
||||
});
|
||||
|
||||
// C5: on touch the card's hover pair is gone, so the menu is where a started title starts over.
|
||||
it("a started title continues from its Play row and starts over from the next one", async () => {
|
||||
const { handlers } = setup(card({ status: "in_progress", position_seconds: 600 }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
fireEvent.click(item("card.restartTitle"));
|
||||
// A row acts a task after the menu closes (closeThenAct).
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onRestart).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("a watched title has no restart row: it plays from the start anyway", () => {
|
||||
setup(card({ status: "watched", position_seconds: 600 }));
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
|
||||
// The watched toggle and the add belong to an episode, so a show has none.
|
||||
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
|
||||
const { handlers } = setup(card({ type: "show" }));
|
||||
rightClick();
|
||||
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
|
||||
fireEvent.click(item("plex.play"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.openShow"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("a started show continues, and starts its episode over from the next row", () => {
|
||||
setup(card({ type: "show", status: "in_progress", resumable: true }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
});
|
||||
|
||||
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
|
||||
setup(card({ type: "show", status: "in_progress", resumable: false }));
|
||||
rightClick();
|
||||
expect(labels()[0]).toBe("plex.play");
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("the card's '…' opens the same menu, and says so", () => {
|
||||
setup(card({}));
|
||||
const hoverMore = moreButtons()[0]!;
|
||||
expect(hoverMore.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(hoverMore);
|
||||
expect(labels()).toContain("plex.info.title");
|
||||
expect(moreButtons()[0]!.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("each row fires the handler the card's own controls do", async () => {
|
||||
const { handlers } = setup(card({ status: "watched" }));
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.markUnwatched"));
|
||||
// A row acts a task after the menu closes (closeThenAct), so the assertions wait for it.
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onToggleWatched).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.info.title"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.play"));
|
||||
await vi.waitFor(() =>
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the title inside the click", () => {
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("common.copy.title"));
|
||||
expect(copyText).toHaveBeenCalledWith("A film");
|
||||
});
|
||||
|
||||
it("the hover 'add' and the menu row open the same playlist dialog", async () => {
|
||||
setup(card({}));
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlistAdd.manage" }));
|
||||
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
||||
cleanup();
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.playlistAdd.manage"));
|
||||
expect(await screen.findByText("plex.playlistAdd.title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an episode list carries no pin, and a host's own rows come last", async () => {
|
||||
const remove = vi.fn();
|
||||
setup(card({ type: "episode" }), {
|
||||
pin: false,
|
||||
extra: (c) => [{ id: "x", label: "remove", danger: true, onSelect: () => remove(c.id) }],
|
||||
});
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("home.pin.add");
|
||||
// The row names what it adds, as the episode card's own hover button does.
|
||||
expect(labels()).toContain("plex.playlist.addEpisode");
|
||||
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
|
||||
expect(labels()[labels().length - 1]).toBe("remove");
|
||||
fireEvent.click(item("remove"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
||||
});
|
||||
});
|
||||
|
||||
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
|
||||
// search for the title. From the card's menu it is a tick, or a name and Enter.
|
||||
describe("a Plex title's menu — Add to collection", () => {
|
||||
const coll = (id: string, title: string, contains: boolean) =>
|
||||
({ id, title, contains }) as PlexCollection & { contains: boolean };
|
||||
|
||||
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({ type: "show" }), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({ type: "episode" }), {}, { admin: true });
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||
cleanup();
|
||||
setup(card({}));
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("plex.collectionAdd.row");
|
||||
});
|
||||
|
||||
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
|
||||
});
|
||||
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const remove = vi
|
||||
.spyOn(api, "plexCollectionRemoveItem")
|
||||
.mockResolvedValue({} as PlexCollection);
|
||||
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const row = async (name: string) =>
|
||||
vi.waitFor(() => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||
.find((b) => b.textContent === name);
|
||||
expect(found).toBeDefined();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(await row("Beta"));
|
||||
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||
// One write at a time: the next tick waits for this one to settle.
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||
);
|
||||
fireEvent.click(await row("Alpha"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
|
||||
const name = screen.getByLabelText("plex.collection.namePlaceholder");
|
||||
fireEvent.change(name, { target: { value: " Rainy days " } });
|
||||
fireEvent.keyDown(name, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
|
||||
});
|
||||
|
||||
const checkRow = async (name: string) =>
|
||||
vi.waitFor(() => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitemcheckbox", { hidden: true })
|
||||
.find((b) => b.textContent === name);
|
||||
expect(found).toBeDefined();
|
||||
return found!;
|
||||
});
|
||||
const idle = () =>
|
||||
vi.waitFor(() =>
|
||||
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
|
||||
);
|
||||
|
||||
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
|
||||
const list = vi.spyOn(api, "plexCollectionsFor");
|
||||
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
|
||||
// The refetch after the write never answers: the tick must not wait for it.
|
||||
list.mockImplementation(() => new Promise(() => {}));
|
||||
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const remove = vi
|
||||
.spyOn(api, "plexCollectionRemoveItem")
|
||||
.mockResolvedValue({} as PlexCollection);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
|
||||
await idle();
|
||||
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes a show's own page too, whose collections come with the show", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
|
||||
collections: [coll("cB", "Beta", false)],
|
||||
});
|
||||
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
|
||||
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
fireEvent.click(await checkRow("Beta"));
|
||||
await idle();
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
|
||||
});
|
||||
|
||||
it("is a named menu that says it is loading, not that there are no collections", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
|
||||
const menu = await vi.waitFor(() => {
|
||||
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
|
||||
expect(found).not.toBeNull();
|
||||
return found!;
|
||||
});
|
||||
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
|
||||
expect(screen.getByText("common.loading")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a failed load as one, with a retry — not as no collections", async () => {
|
||||
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
|
||||
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
|
||||
// A menu owns its items only: the line about the list sits beside it, not in it.
|
||||
expect(document.querySelector("[role=menu]")).not.toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
list.mockResolvedValue({
|
||||
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
|
||||
});
|
||||
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
|
||||
// on <body> with the menu open — it lands on the first row that came back.
|
||||
const retry = (await screen.findByText("common.retry")).closest("button")!;
|
||||
// The menu takes the focus a frame after it opens. Wait for that: a frame that came only after the
|
||||
// press would put the focus back on the panel over the hand-over below — a test faster than a
|
||||
// frame, not a reader, who cannot press a Retry they have not seen painted yet.
|
||||
const panel = retry.closest("[data-floating-ui-portal]")!;
|
||||
await vi.waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
|
||||
retry.focus();
|
||||
fireEvent.click(retry);
|
||||
const beta = await checkRow("Beta");
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
|
||||
// …and the menu's own arrows carry on from there.
|
||||
fireEvent.keyDown(beta, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(await checkRow("Gamma"));
|
||||
});
|
||||
|
||||
it("clears the name when Plex made the collection and only the read after it failed", async () => {
|
||||
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
|
||||
const create = vi
|
||||
.spyOn(api, "plexCreateCollection")
|
||||
.mockRejectedValue(
|
||||
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
|
||||
);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
|
||||
fireEvent.change(name, { target: { value: "Noir" } });
|
||||
fireEvent.keyDown(name, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
// It exists now: a second Enter on the kept name could only be refused as taken.
|
||||
await vi.waitFor(() => expect(name.value).toBe(""));
|
||||
});
|
||||
|
||||
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
|
||||
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
|
||||
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
|
||||
() => new Promise((resolve) => (answer = resolve)),
|
||||
);
|
||||
setup(card({}), {}, { admin: true });
|
||||
rightClick();
|
||||
fireEvent.click(item("plex.collectionAdd.row"));
|
||||
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
|
||||
name.focus();
|
||||
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
|
||||
const alpha = await checkRow("Alpha");
|
||||
fireEvent.keyDown(name, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(alpha);
|
||||
name.focus();
|
||||
fireEvent.keyDown(name, { key: "ArrowUp" });
|
||||
expect(document.activeElement).toBe(await checkRow("Beta"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PlexCard } from "../../lib/api";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { type ContextMenuController } from "../../components/ui/useContextMenu";
|
||||
import { type PlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
|
||||
/** A Plex poster list's one menu: every trigger — right-click, long-press, Shift+F10 and each card's
|
||||
* "…" — opens it with the title's whole action list (`usePlexCardActions`, held by the list because
|
||||
* its cards' hover "add" opens the same dialog). Render ONE per list, outside the cards. */
|
||||
export function PlexMenu({
|
||||
menu,
|
||||
actions,
|
||||
pin = true,
|
||||
extra,
|
||||
}: {
|
||||
menu: ContextMenuController<PlexCard>;
|
||||
actions: PlexCardActions;
|
||||
/** False for a list of episodes: an episode is not a comfort-shelf kind. */
|
||||
pin?: boolean;
|
||||
/** The host list's own rows for a title, after the shared ones (a collection's "Remove"). */
|
||||
extra?: (c: PlexCard) => ActionMenuItem[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const c = menu.target;
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("plex.menu")}
|
||||
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
|
||||
/>
|
||||
{actions.panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The actor detail page. Covers the two pieces the /code-review flagged as untested — the
|
||||
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
|
||||
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
|
||||
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
|
||||
// and the api is mocked so the query resolves synchronously in the test.
|
||||
import { setLanguage } from "../../i18n";
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
// CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// the real instance: these tests switch the language with setLanguage
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const plexPerson = vi.fn();
|
||||
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
plexPerson: (...a: unknown[]) => plexPerson(...a),
|
||||
plexSetState: (...a: unknown[]) => plexSetState(...a),
|
||||
},
|
||||
EMPTY_PLEX_FILTERS: {},
|
||||
plexFilterCount: () => 0,
|
||||
}));
|
||||
|
||||
import { CastStrip } from "../../features/plexCards/plexCards";
|
||||
import { PlexPersonView } from "./plexViews";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
});
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("CastStrip cast click", () => {
|
||||
it("opens the person page (onOpenPerson) rather than filtering, when both are wired", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
const onFilter = vi.fn();
|
||||
render(
|
||||
<CastStrip cast={[{ name: "Jane Doe" }]} onOpenPerson={onOpenPerson} onFilter={onFilter} />,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("plex.info.viewPerson"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Doe");
|
||||
expect(onFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the filter gesture when only onFilter is wired (e.g. the overlay)", () => {
|
||||
const onFilter = vi.fn();
|
||||
render(<CastStrip cast={[{ name: "Jane Doe" }]} onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByTitle("plex.info.filterActor"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPersonView", () => {
|
||||
it("renders the Movies + Shows sections and the filter-by-person action", async () => {
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
photo: null,
|
||||
biography: "A celebrated actor.",
|
||||
birthday: "1970-01-01",
|
||||
place_of_birth: "Springfield",
|
||||
known_for_department: "Acting",
|
||||
also_known_as: ["Janie D"],
|
||||
known_for: [card("kf1", "Famous Film")],
|
||||
movies: [card("m1", "Movie One")],
|
||||
shows: [card("s1", "Show One", "show")],
|
||||
});
|
||||
const onFilter = vi.fn();
|
||||
wrap(
|
||||
<PlexPersonView
|
||||
name="Jane Doe"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={onFilter}
|
||||
/>,
|
||||
);
|
||||
// Both section grids render their cards once the mocked query resolves.
|
||||
await screen.findByText("Movie One");
|
||||
expect(screen.getByText("Show One")).toBeTruthy();
|
||||
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
|
||||
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
||||
|
||||
// The bio hero: department, biography, birth facts, aka.
|
||||
// TMDB's English department, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
|
||||
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
|
||||
// The birth date as a date in the UI language, not the raw ISO string
|
||||
expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy();
|
||||
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
|
||||
expect(screen.getByText(/Janie D/)).toBeTruthy();
|
||||
// "Known for" rail renders its card.
|
||||
expect(screen.getByText("plex.person.knownFor")).toBeTruthy();
|
||||
expect(screen.getAllByText("Famous Film").length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByText("plex.person.filterBy"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "en");
|
||||
});
|
||||
|
||||
it("shows the empty message when the person has no titles", async () => {
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Nobody",
|
||||
photo: null,
|
||||
also_known_as: [],
|
||||
known_for: [],
|
||||
movies: [],
|
||||
shows: [],
|
||||
});
|
||||
wrap(
|
||||
<PlexPersonView
|
||||
name="Nobody"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.person.empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPersonView — the reader's language", () => {
|
||||
it("asks for the filmography in Hungarian", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
photo: null,
|
||||
also_known_as: [],
|
||||
known_for: [],
|
||||
movies: [card("m1", "Egy film")],
|
||||
shows: [],
|
||||
});
|
||||
wrap(
|
||||
<PlexPersonView
|
||||
name="Jane Doe"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("Egy film");
|
||||
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "hu");
|
||||
} finally {
|
||||
setLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
|
||||
|
||||
describe("PlexPersonView — a bio on its way", () => {
|
||||
const view = (
|
||||
<PlexPersonView
|
||||
name="Jane Doe"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={() => {}}
|
||||
/>
|
||||
);
|
||||
const person = (over: object) => ({
|
||||
name: "Jane Doe",
|
||||
photo: null,
|
||||
also_known_as: [],
|
||||
known_for: [],
|
||||
movies: [card("m1", "Movie One")],
|
||||
shows: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
it("holds the bio's place and asks again until it lands", async () => {
|
||||
plexPerson.mockReset();
|
||||
plexPerson
|
||||
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
|
||||
.mockResolvedValue(person({ biography: "Landed bio." }));
|
||||
wrap(view);
|
||||
await screen.findByText("Movie One");
|
||||
expect(screen.getByRole("status").textContent).toContain("plex.person.bioLoading");
|
||||
await screen.findByText("Landed bio.", undefined, { timeout: 3000 });
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(plexPerson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not ask again for a person whose bio is not coming", async () => {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue(person({ biography: null, enriching: false }));
|
||||
wrap(view);
|
||||
await screen.findByText("Movie One");
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
expect(plexPerson).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops asking once the wait is over, and takes the placeholder away", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue(person({ biography: null, enriching: true })); // TMDB never answers
|
||||
wrap(view);
|
||||
await screen.findByText("Movie One");
|
||||
await act(() => vi.advanceTimersByTimeAsync(21_000));
|
||||
const asked = plexPerson.mock.calls.length;
|
||||
expect(asked).toBeGreaterThan(2);
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(plexPerson).toHaveBeenCalledTimes(asked);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops asking when the page's own request fails, and says so with a retry", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
plexPerson.mockReset();
|
||||
plexPerson
|
||||
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
|
||||
.mockRejectedValue(new Error("server down"));
|
||||
wrap(view);
|
||||
await screen.findByText("Movie One");
|
||||
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||
expect(plexPerson).toHaveBeenCalledTimes(2);
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
// Not a blank that reads as "no biography": the failure is said, and a retry asks again.
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("plex.person.bioFailed");
|
||||
plexPerson.mockResolvedValue(person({ biography: "Landed bio." }));
|
||||
fireEvent.click(within(alert).getByRole("button"));
|
||||
await screen.findByText("Landed bio.");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a retry after the wait is over waits again: a bio still on its way still lands", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
plexPerson.mockReset();
|
||||
plexPerson
|
||||
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
|
||||
.mockRejectedValueOnce(new Error("server restarting"))
|
||||
.mockResolvedValueOnce(person({ biography: null, enriching: true })) // the retry
|
||||
.mockResolvedValue(person({ biography: "Late bio." }));
|
||||
wrap(view);
|
||||
await screen.findByText("Movie One");
|
||||
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||
await act(() => vi.advanceTimersByTimeAsync(25_000)); // the first wait runs out
|
||||
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
|
||||
await act(() => vi.advanceTimersByTimeAsync(10));
|
||||
expect(screen.getByRole("status")).toBeTruthy(); // on its way again, not a blank
|
||||
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||
await screen.findByText("Late bio.");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers More only when the bio is cut, and Less once open", async () => {
|
||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
|
||||
try {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." }));
|
||||
wrap(view);
|
||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
}
|
||||
cleanup();
|
||||
plexPerson.mockResolvedValue(person({ biography: "Short." }));
|
||||
wrap(view);
|
||||
await screen.findByText("Short.");
|
||||
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPersonView — the hero's columns", () => {
|
||||
const heroGrid = async (knownFor: number) => {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
photo: null,
|
||||
biography: "Bio.",
|
||||
also_known_as: [],
|
||||
known_for: Array.from({ length: knownFor }, (_, i) => card(`k${i}`, `Known ${i}`)),
|
||||
movies: [card("m1", "Movie One")],
|
||||
shows: [],
|
||||
});
|
||||
const { container, unmount } = wrap(
|
||||
<PlexPersonView
|
||||
name="Jane Doe"
|
||||
onBack={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
onFilter={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("Movie One");
|
||||
const cls = container.querySelector('section[class~="@container"] > div')!.className;
|
||||
unmount();
|
||||
return cls;
|
||||
};
|
||||
|
||||
it("gives a full rail half the hero and puts a short one right beside the text", async () => {
|
||||
expect(await heroGrid(4)).toContain("@3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
|
||||
const short = await heroGrid(1);
|
||||
expect(short).toContain("@3xl:grid-cols-[auto_auto]");
|
||||
expect(short).toContain("@3xl:justify-start");
|
||||
expect(await heroGrid(0)).not.toContain("grid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Who owns ⌫ while the Plex player is up. Three rounds got it wrong in turn: deferring to nothing
|
||||
// (the cheat sheet took ⌫ and the player stopped underneath it), deferring to any layer above (the
|
||||
// player's OWN menus push layers, so ⌫ stopped closing them), then naming just the sheet and the
|
||||
// palette (the app-wide ErrorDialog is neither, so ⌫ tore the player down under it). The rule that
|
||||
// holds: the player's own panels answer first; anything else layered above it takes the key. It had
|
||||
// no test.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../../lib/api";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
return onClose;
|
||||
}
|
||||
|
||||
const press = (key: string) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
describe("PlexPlayer · who owns Backspace", () => {
|
||||
it("goes back when nothing is layered over the player", async () => {
|
||||
const onClose = await mount();
|
||||
press("Backspace");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the key to a layer above it that is not its own — an error dialog, say", async () => {
|
||||
const onClose = await mount();
|
||||
const above = pushLayer();
|
||||
press("Backspace");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
removeLayer(above);
|
||||
});
|
||||
|
||||
it("still closes its OWN panel first, even with a layer above it", async () => {
|
||||
const onClose = await mount();
|
||||
press("d"); // the stats panel: one of the player's own
|
||||
const above = pushLayer();
|
||||
press("Backspace"); // closes the panel, does not leave the player
|
||||
removeLayer(above);
|
||||
press("Backspace"); // nothing left of its own: now it goes back
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's HLS path, with hls.js replaced by a stand-in the test drives (jsdom has no
|
||||
// MediaSource, so the real one never runs here). Its own file because the module mock is file-wide.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
// Hoisted with the mock below (vi.mock runs before the module body).
|
||||
const { FakeHls, hlsInstances } = vi.hoisted(() => {
|
||||
type Handler = (event: string, data: unknown) => void;
|
||||
const hlsInstances: InstanceType<typeof FakeHls>[] = [];
|
||||
class FakeHls {
|
||||
handlers = new Map<string, Handler[]>();
|
||||
audioTrack = 0;
|
||||
constructor() {
|
||||
hlsInstances.push(this);
|
||||
}
|
||||
on(event: string, fn: Handler) {
|
||||
this.handlers.set(event, [...(this.handlers.get(event) ?? []), fn]);
|
||||
}
|
||||
emit(event: string, data: unknown = {}) {
|
||||
for (const fn of this.handlers.get(event) ?? []) fn(event, data);
|
||||
}
|
||||
loadSource() {}
|
||||
attachMedia() {}
|
||||
destroy() {}
|
||||
}
|
||||
return { FakeHls, hlsInstances };
|
||||
});
|
||||
vi.mock("hls.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal<{ default: typeof import("hls.js").default }>()).default;
|
||||
return {
|
||||
default: Object.assign(FakeHls, {
|
||||
isSupported: () => true,
|
||||
Events: actual.Events,
|
||||
ErrorTypes: actual.ErrorTypes,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import Hls from "hls.js";
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../../lib/api";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
claimLocalData(7);
|
||||
hlsInstances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "A",
|
||||
title: "Title A",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 600,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const session = vi
|
||||
.spyOn(api, "plexSession")
|
||||
.mockResolvedValue({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="A" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
const lost404 = {
|
||||
fatal: true,
|
||||
type: Hls.ErrorTypes.NETWORK_ERROR,
|
||||
details: "fragLoadError",
|
||||
response: { code: 404, text: "No active playback session" },
|
||||
};
|
||||
|
||||
describe("an HLS session the server no longer has", () => {
|
||||
it("is restarted where the viewer is, instead of ending playback", async () => {
|
||||
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
|
||||
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
|
||||
const session = mount();
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(1));
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
const v = document.querySelector("video") as Media;
|
||||
v._t = 700; // 600 + 700 − the HLS lag
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
|
||||
await waitFor(() => expect(session).toHaveBeenCalledTimes(2));
|
||||
expect(session.mock.calls[1]![1]).toBeCloseTo(1299, 0);
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up and says so when the reclaimed session keeps 404ing", async () => {
|
||||
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
|
||||
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
|
||||
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
|
||||
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
|
||||
// spinner that never became an error.
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
// Two reclaims, then the honest answer — not a fourth, fifth, hundredth session.
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("keeps giving up even though every reclaimed session plays a fragment first", async () => {
|
||||
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
|
||||
// session essentially always delivers one, because `start_session` does not return until its
|
||||
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
|
||||
// cycles, the player opened 41 sessions and never showed an error. A
|
||||
// fragment is not proof the session can be served; surviving the window is.
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() =>
|
||||
hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, {
|
||||
frag: { type: "main", duration: 6, stats: { total: 1000 } },
|
||||
}),
|
||||
);
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("starts the budget over for a loss long after the last one", async () => {
|
||||
// The other half: a session reaped once an hour is not a session that cannot be served. The
|
||||
// window is what separates the two, so a spent budget must not follow the viewer around.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
act(() => void vi.setSystemTime(Date.now() + 10 * 60_000));
|
||||
}
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
expect(session).toHaveBeenCalledTimes(5);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives up on a session lost over and over on a SLOW cadence too", async () => {
|
||||
// The window's anchor used to be written only where the window RESET, so it marked the start of
|
||||
// the window instead of the last loss — a tumbling window, not the rolling one the constant is
|
||||
// named for. Any cadence slower than half the window then cleared the budget on every second
|
||||
// loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR
|
||||
// handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims,
|
||||
// never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit
|
||||
// outside the 60–120s band where the defect lived.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 6 && !screen.queryByText("plex.player.errGeneric"); i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
act(() => void vi.setSystemTime(Date.now() + 70_000));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
// Two reclaims, each within two minutes of the loss before it — then the honest answer.
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("is reclaimed at the asked position, and still starts, when it was lost before it played", async () => {
|
||||
// Two session requests for one item in flight at once (a seek twice in quick succession; the
|
||||
// dev StrictMode replay of the start effect): the server's LAST spawn wins, and it may be the
|
||||
// older request's. The newer request's answer then names a session that is already gone, and
|
||||
// its playlist 404s before a single manifest parsed — which ended playback with "couldn't
|
||||
// start" (Arrow S03E14 on localdev, 2026-09-27). The video is still paused then, so the
|
||||
// reclaim must keep the start's own "play once ready", not read it off the paused element.
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
|
||||
const session = mount();
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(1));
|
||||
act(() =>
|
||||
hlsInstances[0]!.emit(Hls.Events.ERROR, { ...lost404, details: "manifestLoadError" }),
|
||||
);
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(2));
|
||||
expect(session).toHaveBeenCalledTimes(2);
|
||||
expect(session.mock.calls[1]![1]).toBe(600);
|
||||
act(() => hlsInstances[1]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
expect(play).toHaveBeenCalled();
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up and says so when a session that never played keeps 404ing", async () => {
|
||||
// The reclaim budget is what bounds a server that 404s every session from the start.
|
||||
const session = mount();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// What may be open over the Plex player at once, and what Back closes. The ⋯ menu, the tracks
|
||||
// menu, the tuning panel, the info overlay and the up-next card are ONE value: opening one replaces
|
||||
// the other, so two can never draw on top of each other. The stats readout is not one of them — it
|
||||
// stays up beside a panel — and ⌫ / Escape peel the panel first, then the stats, and only then
|
||||
// leave the player; the mouse/browser Back peels them one per press too, the one opened last first.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
cast: [],
|
||||
genres: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={onClose} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
return onClose;
|
||||
}
|
||||
|
||||
/** The browser's Back, waited out to its popstate. */
|
||||
const browserBack = () =>
|
||||
act(async () => {
|
||||
const landed = new Promise((resolve) =>
|
||||
window.addEventListener("popstate", resolve, { once: true }),
|
||||
);
|
||||
window.history.back();
|
||||
await landed;
|
||||
});
|
||||
|
||||
const press = (key: string) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
// `hidden`: the faded-out control bands are aria-hidden while the video plays.
|
||||
const settings = () => screen.queryByRole("dialog", { name: "plex.player.settings", hidden: true });
|
||||
const stats = () => screen.queryByRole("region", { name: "plex.player.stats.title", hidden: true });
|
||||
const moreMenu = () => screen.queryByRole("menu", { hidden: true });
|
||||
// The info overlay is the full-screen scrim the media info sits in.
|
||||
const info = () => document.querySelector(".place-items-center.z-menu");
|
||||
|
||||
async function openSettings() {
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
const row = await waitFor(() => {
|
||||
const r = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.player.settings");
|
||||
expect(r).toBeDefined();
|
||||
return r!;
|
||||
});
|
||||
fireEvent.click(row);
|
||||
await waitFor(() => expect(settings()).not.toBeNull());
|
||||
}
|
||||
|
||||
describe("PlexPlayer · one panel at a time", () => {
|
||||
it("the browser Back with the ⋯ menu open closes the menu, not the player", async () => {
|
||||
const onClose = await mount();
|
||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||
await waitFor(() => expect(moreMenu()).not.toBeNull());
|
||||
// The menu claims a history entry, so the Back below is spent on it. First in the file: the
|
||||
// history module's entry count is per module, and an earlier test's entries would be in it.
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeDefined());
|
||||
|
||||
await browserBack();
|
||||
|
||||
await waitFor(() => expect(moreMenu()).toBeNull());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the browser Back peels each layer that is up, one press at a time", async () => {
|
||||
// One shared history entry for every layer: the first Back spent it on one layer, and the
|
||||
// second left the player with the stats readout still up. Second in the file (see above): the
|
||||
// first test leaves no entry behind.
|
||||
window.history.pushState({ page: "player" }, ""); // the entry the player was opened on
|
||||
const onClose = await mount();
|
||||
press("d");
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
|
||||
await browserBack();
|
||||
await waitFor(() => expect(info()).toBeNull());
|
||||
expect(stats()).not.toBeNull();
|
||||
|
||||
await browserBack();
|
||||
await waitFor(() => expect(stats()).toBeNull());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the info overlay replaces the tuning panel rather than drawing over it", async () => {
|
||||
await mount();
|
||||
await openSettings();
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
expect(settings()).toBeNull();
|
||||
});
|
||||
|
||||
it("the stats readout stays up beside a panel; Escape peels the panel, then the stats, then leaves", async () => {
|
||||
const onClose = await mount();
|
||||
press("d");
|
||||
press("i");
|
||||
await waitFor(() => expect(info()).not.toBeNull());
|
||||
expect(stats()).not.toBeNull();
|
||||
|
||||
press("Escape");
|
||||
expect(info()).toBeNull();
|
||||
expect(stats()).not.toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
press("Escape");
|
||||
expect(stats()).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,821 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's session lifecycle, rendered for real.
|
||||
//
|
||||
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
|
||||
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
|
||||
// previous item's position used to arrive after the player had already reset it for the next one).
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, HttpError, type PlexItemDetail } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
const later = (el: HTMLMediaElement, ...types: string[]) =>
|
||||
setTimeout(() => types.forEach((ty) => el.dispatchEvent(new Event(ty))), 0);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) {
|
||||
later(this, "timeupdate", "pause"); // as Chrome does: after the call has returned
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
||||
configurable: true,
|
||||
value(this: Media) {
|
||||
this._t = 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
||||
claimLocalData(7);
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const item = (id: string, position = 0): PlexItemDetail =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type: "episode",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: position,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [{ ord: 0, label: "English", language: "en", text: true }],
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
const direct = (id: string) =>
|
||||
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
/** A session answer the test releases when it wants to. */
|
||||
function deferred() {
|
||||
let resolve!: (v: Awaited<ReturnType<typeof api.plexSession>>) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount(opts: {
|
||||
details: Record<string, PlexItemDetail>;
|
||||
sessions: Record<string, () => Promise<Awaited<ReturnType<typeof api.plexSession>>>>;
|
||||
queue?: string[];
|
||||
qc?: QueryClient;
|
||||
}) {
|
||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||
vi.spyOn(api, "plexSession").mockImplementation((id: string) => opts.sessions[id]!());
|
||||
const progress = vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const qc = opts.qc ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||
const view = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, progress, status, qc };
|
||||
}
|
||||
|
||||
const video = () => document.querySelector("video") as Media;
|
||||
|
||||
async function playingAt(src: string, t: number) {
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
||||
const v = video();
|
||||
v._t = t;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
}
|
||||
|
||||
const tick = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
describe("moving to the next item", () => {
|
||||
it("settles the item being left, and gives the next one none of its position", async () => {
|
||||
const { progress, status, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B", 300) },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(status.mock.calls.some(([id]) => id === "B")).toBe(true));
|
||||
await tick(); // the timeupdate that pause() queued has landed by now
|
||||
// B's session has not answered, so where B is is where it was asked to start — not A's 25:00.
|
||||
expect(status.mock.calls.filter(([id]) => id === "B").map(([, , at]) => at)).not.toContain(
|
||||
1500,
|
||||
);
|
||||
expect(status.mock.calls.find(([id]) => id === "B")?.[2]).toBe(300);
|
||||
// A got its final checkpoint at the position it was left at…
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1500, 3000, true]);
|
||||
unmount();
|
||||
// …and leaving B while it was still loading saved nothing of A's position as B's.
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek that restarts the session is saved at its target while the new session loads", async () => {
|
||||
// seekTo wrote the target into the position ref only — and the restart re-renders at once, which
|
||||
// re-syncs that ref from the state: a page-hide during the restart saved the pre-seek spot.
|
||||
let calls = 0;
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => (calls++ === 0 ? Promise.resolve(direct("A")) : new Promise(() => {})),
|
||||
},
|
||||
});
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
|
||||
await tick();
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
expect(beacon).toHaveBeenLastCalledWith("A", 1510, 3000, true);
|
||||
});
|
||||
|
||||
describe("a session answer that lands late", () => {
|
||||
it("is dropped when the viewer has moved on to another item meanwhile", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/C");
|
||||
});
|
||||
|
||||
it("is dropped when the player has been closed meanwhile", async () => {
|
||||
const slowA = deferred();
|
||||
const { unmount } = mount({ details: { A: item("A") }, sessions: { A: () => slowA.promise } });
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalled());
|
||||
const v = video();
|
||||
unmount();
|
||||
await act(async () => slowA.resolve(direct("A")));
|
||||
await tick();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closing the player detaches the <video> it was playing", async () => {
|
||||
const { unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const v = video();
|
||||
unmount();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the player refreshes every surface that shows watch state, the Discover rails too", async () => {
|
||||
const { unmount, qc } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const refreshed = vi.spyOn(qc, "invalidateQueries");
|
||||
unmount();
|
||||
const keys = refreshed.mock.calls.map(([f]) => f?.queryKey);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([qk.plexLibrary(), qk.plexShow(), qk.plexDiscover()]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("the subtitle poll and notice", () => {
|
||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||
const { status } = mount({
|
||||
details: { A: item("A", 1198) },
|
||||
sessions: { A: () => new Promise(() => {}) },
|
||||
});
|
||||
await waitFor(() => expect(status).toHaveBeenCalled());
|
||||
expect(status.mock.calls[0]).toEqual(["A", 0, 1198]);
|
||||
});
|
||||
|
||||
it("says a failing poll even while an earlier window is still on screen", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.plexSubtitle("A", 0), { state: "partial", code: null, gen: 1 });
|
||||
const view = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
qc,
|
||||
});
|
||||
view.status.mockRejectedValue(new Error("offline"));
|
||||
await waitFor(() => expect(view.status).toHaveBeenCalled());
|
||||
await screen.findByText("plex.player.subUnavailable.failed");
|
||||
expect(document.querySelector("track")).not.toBeNull(); // the window stays mounted meanwhile
|
||||
});
|
||||
|
||||
it("marks which track is selected for assistive tech, not only by colour", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
||||
const off = await screen.findByText("plex.player.subOff");
|
||||
expect(off.closest('[role="menuitemradio"]')?.getAttribute("aria-checked")).toBe("false");
|
||||
const checked = document.querySelectorAll('[role="menuitemradio"][aria-checked="true"]');
|
||||
expect([...checked].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
act(() => {
|
||||
notify({ level: "error", message: "Couldn't mark it watched." });
|
||||
});
|
||||
const toast = await screen.findByText("Couldn't mark it watched.");
|
||||
let host: Element | null = video();
|
||||
while (host && !host.contains(toast)) host = host.parentElement;
|
||||
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
||||
});
|
||||
|
||||
// --- leaving a played-through item, restarts, failed requests -----------------------------------
|
||||
|
||||
/** A session answer that FAILS when the test says so. */
|
||||
function deferredFailure() {
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
|
||||
return { promise, reject };
|
||||
}
|
||||
|
||||
const startsAsked = (id: string) =>
|
||||
vi
|
||||
.mocked(api.plexSession)
|
||||
.mock.calls.filter(([i]) => i === id)
|
||||
.map(([, at]) => at);
|
||||
|
||||
describe("leaving an item that was played through", () => {
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
|
||||
|
||||
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
||||
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
||||
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up.
|
||||
const endMedia = () => {
|
||||
const el = video();
|
||||
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
|
||||
act(() => void el.dispatchEvent(new Event("pause")));
|
||||
act(() => void el.dispatchEvent(new Event("ended")));
|
||||
};
|
||||
|
||||
// A layer the player opens BY ITSELF — the auto-skip countdown, the up-next card — takes no history
|
||||
// entry. Chrome's Back button skips an entry pushed without a user gesture, and one pushed under an
|
||||
// open dialog (the shortcuts sheet) sat above it, so Back closed the hidden layer, not the dialog.
|
||||
const overlayPushes = (push: { mock: { calls: unknown[][] } }) =>
|
||||
push.mock.calls.filter(([state]) => (state as { _ov?: number } | null)?._ov != null);
|
||||
|
||||
it("gives the auto-skip countdown no history entry", async () => {
|
||||
prefs({ autoSkipIntro: true, autoSkipDelay: 5 });
|
||||
const withIntro = {
|
||||
...item("A"),
|
||||
markers: [{ type: "intro", start_s: 100, end_s: 160, final: false }],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: withIntro }, sessions: { A: () => Promise.resolve(direct("A")) } });
|
||||
await playingAt("/media/A", 50);
|
||||
await tick();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
await playingAt("/media/A", 110); // inside the intro: the countdown starts
|
||||
await waitFor(() => expect(screen.getByText("plex.player.skipIntro")).toBeTruthy());
|
||||
await tick();
|
||||
expect(overlayPushes(push)).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the up-next card no history entry", async () => {
|
||||
prefs({ upNext: true });
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
await tick();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
endMedia();
|
||||
await screen.findByText("Title B"); // the card names the next item
|
||||
await tick();
|
||||
expect(overlayPushes(push)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
expect(startsAsked("A")[1]).toBe(0);
|
||||
});
|
||||
|
||||
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
|
||||
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
|
||||
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
|
||||
// next item — here it lands at once.
|
||||
video().pause = function (this: HTMLMediaElement) {
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
};
|
||||
await playingAt("/media/A", 2910);
|
||||
await screen.findByText("Title B");
|
||||
await tick(); // the pause() that go() queued has fired its events by now
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
});
|
||||
it("takes the finish back when the server refuses the watched mark", async () => {
|
||||
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
|
||||
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
|
||||
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
|
||||
// disagreed with.
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
// The cached detail is not left claiming watched: only the server is honest now.
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect(cached?.status).not.toBe("watched");
|
||||
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
|
||||
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
|
||||
// — performing, through a second route, the very write that was just refused, while the error
|
||||
// toast said it had failed. There is no resume point at the end of the media to
|
||||
// restore; the cache drop is the whole of the rollback here.
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
});
|
||||
|
||||
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
|
||||
// A fix guarded the explicit rollback write, but the same catch lowered the play-through
|
||||
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
|
||||
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
|
||||
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
|
||||
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
|
||||
// and a scrobble sent to Plex. `upNext` is ON here — the default — so the
|
||||
// player STAYS on the finished item and those routes are live; the test above turns it
|
||||
// off, which tears the effect down before any of them can fire.
|
||||
prefs({ upNext: true });
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
const { qc, progress, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia(); // the up-next card is up, the item is finished, and the mark comes back refused
|
||||
await tick();
|
||||
progress.mockClear();
|
||||
|
||||
// The viewer closes the tab while the failed-mark toast is still up.
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
expect(beacon).not.toHaveBeenCalled();
|
||||
|
||||
// The refusal dropped the cached detail — and the open player's own observer refetches it, so
|
||||
// by the time the viewer leaves there is an entry again. `settle` must not re-assert watched
|
||||
// onto it: only the server is honest about this item now.
|
||||
act(() => void qc.setQueryData(qk.plexItem("A", "en"), item("A")));
|
||||
|
||||
// And leaving the player: `settle` must neither post the end position nor re-assert watched.
|
||||
unmount();
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id]) => id === "A")).toEqual([]);
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("restores nothing when the media ended short of the duration Plex reports", async () => {
|
||||
// Plex's metadata can overrun the file. Ending at 2900 of a reported 3000 is outside the finish
|
||||
// margin, so the duration-based guard alone wrote 2900 back as the resume point: an unwatched
|
||||
// item with a ~99% bar that reopened in its last seconds. The media's own `ended` decides.
|
||||
prefs({ upNext: false });
|
||||
const { progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2900);
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does restore the position when the refused finish came from the credits skip", async () => {
|
||||
// The other caller of the same helper captures a MID-episode position, and there the rollback
|
||||
// is the point: without it the viewer loses the stretch they watched. One helper, two callers,
|
||||
// and only the near-end one must hold back.
|
||||
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2000, end_s: 2990, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2010); // inside the credits → auto-skip advances and marks watched
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2010, 3000, true],
|
||||
]);
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("lets a credits block that does not end the item play on, its Skip only jumping past it", async () => {
|
||||
// An item can carry several credits blocks with scenes between them: the first one moving on
|
||||
// to the next item skipped a post-credit scene.
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const twoCredits = {
|
||||
...item("A"),
|
||||
markers: [
|
||||
{ type: "credits", start_s: 2000, end_s: 2100, final: false },
|
||||
{ type: "credits", start_s: 2900, end_s: 2995, final: true },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc } = mount({
|
||||
details: { A: twoCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2010);
|
||||
await tick();
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("B", "en"))).toBeUndefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
||||
await waitFor(() => expect(startsAsked("A").slice(-1)).toEqual([2100])); // past the block, still A
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
await playingAt("/media/A", 2910); // the final block: the auto-skip moves on
|
||||
await screen.findByText("Title B");
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
});
|
||||
|
||||
it("does not leave on its own at credits Plex did not flag final, but its Skip does", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const unflagged = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: false }],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({
|
||||
details: { A: unflagged, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2910);
|
||||
await tick();
|
||||
expect(screen.queryByText("Title B")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /plex\.player\.skipCredits/ }));
|
||||
await screen.findByText("Title B");
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
});
|
||||
|
||||
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
||||
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
|
||||
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
|
||||
// discarded a whole rewatch with the gate green.
|
||||
prefs({ upNext: true });
|
||||
const { progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia(); // the up-next card is up; the item is finished
|
||||
await tick();
|
||||
progress.mockClear();
|
||||
|
||||
// The viewer cancels the card and rewinds: playing again takes the finish back.
|
||||
const el = video() as Media;
|
||||
Object.defineProperty(el, "ended", { configurable: true, get: () => false });
|
||||
act(() => void el.dispatchEvent(new Event("play")));
|
||||
el._t = 2400;
|
||||
act(() => void el.dispatchEvent(new Event("timeupdate")));
|
||||
act(() => void el.dispatchEvent(new Event("pause")));
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2400, 3000, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restarting the session before playback has ticked", () => {
|
||||
it("an audio switch or a keyboard seek keeps the resume point", async () => {
|
||||
const twoAudio = {
|
||||
...item("A", 1200),
|
||||
audio_streams: [
|
||||
{ ord: 0, label: "English", language: "en", default: true },
|
||||
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
const answers = [deferred(), deferred(), deferred()];
|
||||
let calls = 0;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||
fireEvent.keyDown(window, { key: "a" });
|
||||
// One request per item in flight: the switch waits for the start's answer…
|
||||
await act(async () => answers[0]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await act(async () => answers[1]!.resolve(direct("A")));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
||||
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
|
||||
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek made while a restart is pending wins over it", async () => {
|
||||
let calls = 0;
|
||||
const pending = deferred();
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? pending.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
|
||||
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
||||
});
|
||||
|
||||
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
|
||||
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
|
||||
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
|
||||
// on localdev, 2026-10-06).
|
||||
let calls = 0;
|
||||
const restart = deferred();
|
||||
mount({
|
||||
details: { A: item("A", 1200) },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? restart.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
await playingAt("/media/A", 1200);
|
||||
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
|
||||
await tick();
|
||||
expect(startsAsked("A")).toEqual([1200, 1190]);
|
||||
await act(async () => restart.resolve(direct("A")));
|
||||
// The queued target is the LAST press's, asked once.
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
|
||||
});
|
||||
|
||||
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||
mount({
|
||||
details: { A: item("A", 1200), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
|
||||
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
|
||||
expect(video().currentTime).toBe(0); // not A's resume point, 1200
|
||||
});
|
||||
|
||||
it("a refetch of the playing item's detail does not restart it", async () => {
|
||||
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
|
||||
// watched toggle — each one restarted the film at the last saved checkpoint.
|
||||
const details = { A: item("A", 100) };
|
||||
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
|
||||
await playingAt("/media/A", 1500);
|
||||
details.A = item("A", 1490);
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
|
||||
});
|
||||
await tick();
|
||||
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
|
||||
expect(startsAsked("A")).toEqual([100]);
|
||||
});
|
||||
|
||||
describe("a session request that fails", () => {
|
||||
it("is not reported when it has been superseded", async () => {
|
||||
const slowB = deferredFailure();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.reject(new HttpError(404)));
|
||||
await tick();
|
||||
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps following the old stream when a seek-restart fails", async () => {
|
||||
let calls = 0;
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () =>
|
||||
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
|
||||
},
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
await playingAt("/media/A", 1600); // the old stream plays on
|
||||
unmount();
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
|
||||
});
|
||||
|
||||
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => Promise.reject(new HttpError(404)),
|
||||
},
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("plex.player.errNotFound");
|
||||
await tick();
|
||||
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
|
||||
unmount();
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a session answer that lands while the next item's detail is still loading is dropped", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const real = vi.mocked(api.plexItem).getMockImplementation()!;
|
||||
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
|
||||
id === "C" ? new Promise(() => {}) : real(id, lang),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await tick();
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
||||
});
|
||||
|
||||
describe("the server session the player no longer wants", () => {
|
||||
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
it("is stopped by its item and its own id when the page is unloaded", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
act(
|
||||
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
|
||||
);
|
||||
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||
});
|
||||
|
||||
it("is stopped the same way when the player closes", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
const { unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(hlsA) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
unmount();
|
||||
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||
});
|
||||
|
||||
it("is kept when the page only goes into the back/forward cache", async () => {
|
||||
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player must start playing by itself — also when React mounts it, unmounts it and mounts
|
||||
// it again, which StrictMode does to every component in development.
|
||||
//
|
||||
// Bug hunt 2026-09, found on localdev: the player's "still alive" flag (every play() is gated on it,
|
||||
// so a late one cannot start a detached <video> after Back) was only ever LOWERED, in an effect's
|
||||
// cleanup. StrictMode's dry-run unmount lowered it for good: the player never started on its own, and
|
||||
// a seek that restarts the session — back past the loaded region — left it paused. Production does
|
||||
// not double-mount, which is why only dev showed it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const expose = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../../lib/api";
|
||||
|
||||
const played: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
// jsdom has no media pipeline: record what the player asks of the element instead.
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) {
|
||||
played.push(this.getAttribute("src") ?? "");
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
played.length = 0;
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "99079",
|
||||
title: "The Adversary",
|
||||
type: "episode",
|
||||
duration_seconds: 3434,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
describe("PlexPlayer under StrictMode", () => {
|
||||
it("starts playing by itself once the media is ready", async () => {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
// A direct-play session: the element gets the file URL and plays on `loadedmetadata`. (hls.js is
|
||||
// not supported under jsdom, so this is the path the test can drive; the HLS path gates its
|
||||
// play() on the very same flag.)
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/api/plex/direct/99079",
|
||||
start_s: 0,
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId="99079" onClose={() => {}} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
const video = await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v?.getAttribute("src")).toBe("/api/plex/direct/99079");
|
||||
return v!;
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
|
||||
});
|
||||
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/api/plex/direct/99079",
|
||||
start_s: 0,
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId="99079" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const video = await waitFor(() => document.querySelector("video")!);
|
||||
expect(expose).toHaveBeenCalledWith(video);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player paints an opaque, full-viewport layer over the whole app — and for a long
|
||||
// time it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
|
||||
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
|
||||
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
|
||||
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
|
||||
// an earlier comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
||||
// too. Its own file because `layerStack` is module state and these tests push into it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../../lib/api";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
|
||||
beforeAll(() => {
|
||||
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
|
||||
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
|
||||
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
for (const [name, value] of Object.entries({
|
||||
play: () => Promise.resolve(),
|
||||
pause: () => {},
|
||||
load: () => {},
|
||||
}))
|
||||
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
const behind: HTMLButtonElement[] = [];
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
while (behind.length) behind.pop()!.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "1",
|
||||
title: "Title 1",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
async function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/media/1",
|
||||
start_s: 0,
|
||||
} as Awaited<ReturnType<typeof api.plexSession>>);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="1" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
|
||||
);
|
||||
}
|
||||
|
||||
/** A control on the page the player is painted over — the thing focus used to escape onto. */
|
||||
function pageBehind() {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = "on the page behind";
|
||||
document.body.insertBefore(b, document.body.firstChild);
|
||||
behind.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
const player = () => document.querySelector<HTMLElement>(".fixed.z-overlay")!;
|
||||
|
||||
/** jsdom has no layout, so the trap's visibility filter (`offsetParent`) would see nothing tabbable
|
||||
* anywhere. Give every element one for the duration of a check — the house pattern (PlayerModal,
|
||||
* OnboardingWizard, ui/dialogFocus all do this). */
|
||||
function withLayout<T>(fn: () => T): T {
|
||||
const spy = vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
|
||||
// measured it (never, under jsdom), and a hidden element's accessible name is empty.
|
||||
const menuItem = (label: string) =>
|
||||
waitFor(() => {
|
||||
const row = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
expect(row).toBeDefined();
|
||||
return row!;
|
||||
});
|
||||
|
||||
describe("PlexPlayer · Tab stays inside the player", () => {
|
||||
it("pulls the focus back in when it is on the page behind", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
withLayout(() => press("Tab"));
|
||||
// Not left on a control under an opaque overlay: the trap takes it into the player.
|
||||
expect(document.activeElement).not.toBe(outside);
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps from the last of its own controls back to the first", async () => {
|
||||
await mount();
|
||||
const items = [
|
||||
...player().querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
];
|
||||
expect(items.length).toBeGreaterThan(1);
|
||||
const first = items[0]!;
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(document.activeElement).toBe(first);
|
||||
// ...and backwards, which is the direction that used to walk straight out behind the player.
|
||||
first.focus();
|
||||
withLayout(() => press("Tab", true));
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it("keeps trapping while one of its OWN menus is open", async () => {
|
||||
await mount();
|
||||
// The settings and track menus are `useDismiss` layers. That hook owns Escape and the
|
||||
// outside-press and installs no Tab trap at all — but it used to register as a layer that
|
||||
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
|
||||
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
|
||||
// inside the player, so the player's trap is the one that must stay on.
|
||||
const buttons = () => [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
|
||||
const before = buttons().length;
|
||||
// The tuning panel opens from the ⋯ (C5): ⋯, then its Settings row.
|
||||
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
|
||||
expect(more).toBeDefined();
|
||||
act(() => more!.click());
|
||||
// The ⋯ is a menu on the popover engine: it portals out of the player and traps for itself.
|
||||
const settings = await menuItem("plex.player.settings");
|
||||
act(() => settings.click());
|
||||
await screen.findByRole("dialog", { name: "plex.player.settings" });
|
||||
const items = buttons();
|
||||
// The panel really opened — the trap now has to span its controls too.
|
||||
expect(items.length).toBeGreaterThan(before + 3);
|
||||
const last = items[items.length - 1]!;
|
||||
last.focus();
|
||||
withLayout(() => press("Tab"));
|
||||
expect(player().contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
});
|
||||
|
||||
it("leaves the Tab to a dialog opened above it, which traps for itself", async () => {
|
||||
await mount();
|
||||
const outside = pageBehind();
|
||||
outside.focus();
|
||||
const above = pushLayer(); // a Modal over the player — the download dialog, say
|
||||
try {
|
||||
withLayout(() => press("Tab"));
|
||||
// The player does not reach past it: the dialog above owns the keyboard and its own trap.
|
||||
expect(document.activeElement).toBe(outside);
|
||||
} finally {
|
||||
removeLayer(above);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's settings panel. Its section switch was a row of
|
||||
// `aria-pressed` buttons (now an ARIA tab list on `ui/Tabs`), its rows sat under the 44 touch target,
|
||||
// its on/off rows were a hand-laid label (now `CheckRow`), and its sliders and colour wells had no
|
||||
// accessible name — the visible label beside them was not tied to them.
|
||||
import { useRef, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { DEFAULT_PREFS, type PlexPlayerPrefs } from "../../lib/plexPlayerPrefs";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Tab = "sync" | "playback" | "subtitle" | "clock";
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "sync", label: "Sync" },
|
||||
{ key: "playback", label: "Playback" },
|
||||
{ key: "subtitle", label: "Subtitles" },
|
||||
{ key: "clock", label: "Clock" },
|
||||
];
|
||||
|
||||
function Harness({ start = "sync" as Tab, prefs: initial = {} as Partial<PlexPlayerPrefs> }) {
|
||||
const [tab, setTab] = useState<Tab>(start);
|
||||
const [prefs, setPrefs] = useState<PlexPlayerPrefs>({ ...DEFAULT_PREFS, ...initial });
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const back = useRef<HTMLElement>(null);
|
||||
return (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
returnFocusTo={back}
|
||||
settingsTabs={TABS}
|
||||
activeTab={tab}
|
||||
setSettingsTab={setTab}
|
||||
prefs={prefs}
|
||||
patchPrefs={(p) => setPrefs((cur) => ({ ...cur, ...p }))}
|
||||
changeAudioOffset={() => {}}
|
||||
now={new Date(2026, 8, 27, 12, 0)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tab = (name: string) => screen.getByRole("tab", { name });
|
||||
|
||||
describe("the Plex player's settings panel", () => {
|
||||
it("switches its sections as a tab list, the focus on the selected tab", () => {
|
||||
render(<Harness start="subtitle" />);
|
||||
expect(screen.getByRole("tablist")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(tab("Subtitles"));
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
|
||||
expect(tab("Clock").getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Clock" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives every slider and colour well the name its row shows", () => {
|
||||
render(<Harness start="subtitle" prefs={{ subBg: "#000000", subShadow: 2 }} />);
|
||||
for (const name of ["plex.player.subSize", "plex.player.subPos", "plex.player.subShadow"])
|
||||
expect(screen.getByRole("slider", { name })).toBeTruthy();
|
||||
for (const name of ["plex.player.subColor", "plex.player.subBg", "plex.player.subShadowColor"])
|
||||
expect(screen.getByLabelText(name).getAttribute("type")).toBe("color");
|
||||
fireEvent.click(tab("Clock"));
|
||||
expect(screen.getByLabelText("plex.player.clockColor").getAttribute("type")).toBe("color");
|
||||
});
|
||||
|
||||
it("lays its on/off rows on CheckRow and its buttons at the touch target", () => {
|
||||
render(<Harness start="clock" prefs={{ clockDate: true }} />);
|
||||
const row = screen.getByRole("checkbox", { name: "plex.player.clockShow" }).closest("label")!;
|
||||
expect(row.className).toContain("pointer-coarse:min-h-(--touch-target)");
|
||||
for (const b of screen.getAllByRole("button")) expect(b.className).toContain("touch-target");
|
||||
for (const t of screen.getAllByRole("tab")) expect(t.className).toContain("touch-target");
|
||||
fireEvent.click(tab("Subtitles"));
|
||||
const none = screen.getByRole("checkbox", { name: "plex.player.subBgNone" }).closest("label")!;
|
||||
expect(none.className).toContain("pointer-coarse:min-h-(--touch-target)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useLayoutEffect, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Slider, Toggle } from "./plexPlayerControls";
|
||||
import { clockParts, type PlexPlayerPrefs } from "../../lib/plexPlayerPrefs";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { ColorInput, CheckRow } from "../../components/ui/NativeControls";
|
||||
import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { MEDIA_CHOSEN } from "../../components/ui/mediaChrome";
|
||||
|
||||
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||
|
||||
// The gear/tuning panel of the Plex player — a tabbed (sync / playback / subtitle / clock) sheet of
|
||||
// Slider/Toggle rows over the per-account prefs. Extracted verbatim from PlexPlayer to keep that
|
||||
// component focused on the media engine; every row is still `prefs.<field>` + `patchPrefs`, so the
|
||||
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
|
||||
// only for the clock-date-style preview buttons.
|
||||
export function PlexPlayerSettings(props: {
|
||||
// `| null` is not optional under React 19's types: `useRef<T>(null)` is now `RefObject<T | null>`,
|
||||
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
|
||||
// about that and 18's types let it.
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
|
||||
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body>. */
|
||||
returnFocusTo: RefObject<HTMLElement | null>;
|
||||
settingsTabs: { key: SettingsTab; label: string }[];
|
||||
activeTab: SettingsTab;
|
||||
setSettingsTab: (tab: SettingsTab) => void;
|
||||
prefs: PlexPlayerPrefs;
|
||||
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
|
||||
changeAudioOffset: (v: number) => void;
|
||||
now: Date;
|
||||
}) {
|
||||
const {
|
||||
menuRef,
|
||||
returnFocusTo,
|
||||
settingsTabs,
|
||||
activeTab,
|
||||
setSettingsTab,
|
||||
prefs,
|
||||
patchPrefs,
|
||||
changeAudioOffset,
|
||||
now,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const panel = useTabPanel();
|
||||
// Into the panel on open (its selected tab), and back to the ⋯ on close. The cleanup runs while the
|
||||
// panel is still in the DOM, so "the focus was inside" is read before its removal drops it to
|
||||
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
|
||||
useLayoutEffect(() => {
|
||||
const root = menuRef.current;
|
||||
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
|
||||
root
|
||||
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
||||
?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
if (root?.contains(document.activeElement))
|
||||
queueMicrotask(() => back?.focus({ preventScroll: true }));
|
||||
};
|
||||
}, [menuRef, returnFocusTo]);
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="dialog"
|
||||
aria-label={t("plex.player.settings")}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="border border-white/15 bg-media-chrome/95 media-marks absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||
>
|
||||
<Tabs
|
||||
tone="media"
|
||||
idBase={panel.idBase}
|
||||
tabs={settingsTabs.map((tab) => ({ id: tab.key, label: tab.label }))}
|
||||
active={activeTab}
|
||||
onChange={(id) => setSettingsTab(id as SettingsTab)}
|
||||
className="mb-1.5 flex gap-1"
|
||||
/>
|
||||
<div {...panel.panelProps(activeTab)}>
|
||||
{activeTab === "sync" && (
|
||||
<>
|
||||
<Slider
|
||||
label={t("plex.player.subOffset")}
|
||||
value={prefs.subOffset}
|
||||
min={-10}
|
||||
max={10}
|
||||
step={0.1}
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ subOffset: v })}
|
||||
onReset={() => patchPrefs({ subOffset: 0 })}
|
||||
/>
|
||||
<Slider
|
||||
label={t("plex.player.audioOffset")}
|
||||
value={prefs.audioOffset}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.1}
|
||||
seconds
|
||||
onChange={changeAudioOffset}
|
||||
onReset={() => changeAudioOffset(0)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "playback" && (
|
||||
<>
|
||||
<Slider
|
||||
label={t("plex.player.seekStep")}
|
||||
value={prefs.seekStep}
|
||||
min={5}
|
||||
max={30}
|
||||
step={1}
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ seekStep: v })}
|
||||
/>
|
||||
<Slider
|
||||
label={t("plex.player.fineSeek")}
|
||||
value={prefs.fineSeekStep}
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.autoHide")}
|
||||
checked={prefs.barAutoHide}
|
||||
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.autoSkipIntro")}
|
||||
checked={prefs.autoSkipIntro}
|
||||
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.autoSkipCredits")}
|
||||
checked={prefs.autoSkipCredits}
|
||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.upNext.pref")}
|
||||
checked={prefs.upNext}
|
||||
onChange={(v) => patchPrefs({ upNext: v })}
|
||||
/>
|
||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||
<Slider
|
||||
label={t("plex.player.autoSkipDelay")}
|
||||
value={prefs.autoSkipDelay}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "subtitle" && (
|
||||
<>
|
||||
<Slider
|
||||
label={t("plex.player.subSize")}
|
||||
value={prefs.subSize}
|
||||
min={50}
|
||||
max={250}
|
||||
step={5}
|
||||
suffix="%"
|
||||
onChange={(v) => patchPrefs({ subSize: v })}
|
||||
/>
|
||||
<Slider
|
||||
label={t("plex.player.subPos")}
|
||||
value={prefs.subPos}
|
||||
min={50}
|
||||
max={95}
|
||||
step={1}
|
||||
suffix="%"
|
||||
onChange={(v) => patchPrefs({ subPos: v })}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-1 py-1.5">
|
||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
|
||||
<ColorInput
|
||||
value={prefs.subColor}
|
||||
aria-label={t("plex.player.subColor")}
|
||||
onChange={(v) => patchPrefs({ subColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1 py-1.5">
|
||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckRow
|
||||
checked={prefs.subBg === "transparent"}
|
||||
onChange={(checked) =>
|
||||
patchPrefs({
|
||||
subBg: checked ? "transparent" : "#000000",
|
||||
})
|
||||
}
|
||||
className="gap-1 text-2xs text-white/70"
|
||||
>
|
||||
{t("plex.player.subBgNone")}
|
||||
</CheckRow>
|
||||
{prefs.subBg !== "transparent" && (
|
||||
<ColorInput
|
||||
value={prefs.subBg}
|
||||
aria-label={t("plex.player.subBg")}
|
||||
onChange={(v) => patchPrefs({ subBg: v })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
label={t("plex.player.subShadow")}
|
||||
value={prefs.subShadow}
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(v) => patchPrefs({ subShadow: v })}
|
||||
/>
|
||||
{prefs.subShadow > 0 && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5">
|
||||
<span className="text-[0.75rem] text-white/80">
|
||||
{t("plex.player.subShadowColor")}
|
||||
</span>
|
||||
<ColorInput
|
||||
value={prefs.subShadowColor}
|
||||
aria-label={t("plex.player.subShadowColor")}
|
||||
onChange={(v) => patchPrefs({ subShadowColor: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "clock" && (
|
||||
<>
|
||||
<Toggle
|
||||
label={t("plex.player.clockShow")}
|
||||
checked={prefs.clockShow}
|
||||
onChange={(v) => patchPrefs({ clockShow: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("plex.player.clock24h")}
|
||||
checked={prefs.clock24h}
|
||||
onChange={(v) => patchPrefs({ clock24h: v })}
|
||||
/>
|
||||
<Slider
|
||||
label={t("plex.player.clockSize")}
|
||||
value={prefs.clockSize}
|
||||
min={70}
|
||||
max={200}
|
||||
step={5}
|
||||
suffix="%"
|
||||
onChange={(v) => patchPrefs({ clockSize: v })}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-1 py-1.5">
|
||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
|
||||
<ColorInput
|
||||
value={prefs.clockColor}
|
||||
aria-label={t("plex.player.clockColor")}
|
||||
onChange={(v) => patchPrefs({ clockColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<Toggle
|
||||
label={t("plex.player.clockDate")}
|
||||
checked={prefs.clockDate}
|
||||
onChange={(v) => patchPrefs({ clockDate: v })}
|
||||
/>
|
||||
{prefs.clockDate && (
|
||||
<div className="flex gap-1 px-1 py-1">
|
||||
{(["hu", "en"] as const).map((st) => (
|
||||
<Pressable
|
||||
key={st}
|
||||
align="center"
|
||||
aria-pressed={prefs.clockDateStyle === st}
|
||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
|
||||
prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// One accepted write, one cache entry that matches it — the Plex half.
|
||||
//
|
||||
// `Playlists.tsx` (the YouTube list) got this rule first: the detail cache is what a rollback
|
||||
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
|
||||
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
|
||||
// the rollback half of that fix and not this half.
|
||||
//
|
||||
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
|
||||
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
|
||||
import { it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const { dragEnd } = vi.hoisted(() => ({ dragEnd: { fire: (_e: unknown) => {} } }));
|
||||
vi.mock("@dnd-kit/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
|
||||
return {
|
||||
...actual,
|
||||
DndContext: ({
|
||||
children,
|
||||
onDragEnd,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onDragEnd: (e: unknown) => void;
|
||||
}) => {
|
||||
dragEnd.fire = onDragEnd;
|
||||
return <>{children}</>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => {
|
||||
// The refetch that same handler schedules must not be what makes the cache right: the
|
||||
// point of the write is that a re-entry inside the 30 s staleTime renders the accepted order
|
||||
// immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion
|
||||
// needed it to land, deleting the write would still pass and that fix would be unguarded.
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
++calls === 1
|
||||
? Promise.resolve({ id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] })
|
||||
: new Promise(() => {}),
|
||||
);
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
|
||||
// The list key was always invalidated; the DETAIL key — the one `putBack` reads and the one a
|
||||
// re-entry renders from — kept the pre-reorder order, so leaving and coming back inside the
|
||||
// staleTime showed the reorder undone, and the next drag persisted that.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
|
||||
).toEqual(["2", "1", "3"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches the detail too, so a membership change that landed meanwhile is not clobbered", async () => {
|
||||
// `next` is the membership of the render that started the drag, and `reorder_playlist` only
|
||||
// touches the rating_keys it is handed — it never ratifies that list. So writing `next` into the
|
||||
// detail cache silently drops anything that landed in between (a row removed from another
|
||||
// surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it:
|
||||
// staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as
|
||||
// long as the view was mounted and the next drag persisted an order built from it. The YouTube
|
||||
// sibling invalidates the detail key right after writing it; this half did not.
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
++calls === 1
|
||||
? { id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] }
|
||||
: { id: 5, title: "Films", items: [movie("2"), movie("1")] }, // "3" is gone server-side
|
||||
),
|
||||
);
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 3");
|
||||
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
|
||||
// The refetch the accepted write schedules brings the real membership back.
|
||||
await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id),
|
||||
).toEqual(["2", "1"]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex playlist header's meta line — the count and the playlist's total runtime, mirroring the
|
||||
// YouTube playlist header — was never rendered by a test, so a regression in the sum shipped green.
|
||||
// The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list
|
||||
// nothing sums for says no runtime at all.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// An unknown runtime is an ABSENT field: `5400 + null` is 5400 in JS, so null would prove nothing
|
||||
// about the sum's guard; `undefined` turns an unguarded sum into NaN.
|
||||
const movie = (id: string, duration_seconds?: number) =>
|
||||
({
|
||||
id,
|
||||
type: "movie",
|
||||
title: `Movie ${id}`,
|
||||
year: 2020,
|
||||
thumb: "",
|
||||
duration_seconds,
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
async function meta(items: PlexCard[]) {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText(`Movie ${items[0]!.id}`);
|
||||
return screen.getByText(
|
||||
(_, el) => /^plex\.browse\.items/.test(el?.textContent ?? "") && el?.childElementCount === 0,
|
||||
).textContent;
|
||||
}
|
||||
|
||||
/** Pick `label` from the menu `open` opens. The surface is visibility:hidden in jsdom (floating-ui
|
||||
* never positions it), so the row is matched on its text. */
|
||||
async function pickFromMenu(open: () => void, label: string) {
|
||||
open();
|
||||
const row = await waitFor(() => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(row);
|
||||
}
|
||||
|
||||
describe("PlexPlaylistView · the header's meta line", () => {
|
||||
it("sums the known runtimes and counts an unknown one as nothing", async () => {
|
||||
expect(await meta([movie("1", 5400), movie("2"), movie("3", 1800)])).toBe(
|
||||
"plex.browse.items · 2 hr 0 min",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the runtime when nothing sums", async () => {
|
||||
expect(await meta([movie("1")])).toBe("plex.browse.items");
|
||||
});
|
||||
});
|
||||
|
||||
// Enter on an unchanged (or emptied) title wrote the same name back to Plex, and an empty
|
||||
// one armed the focus hand-back while leaving the field open. Now both close with no write, as the
|
||||
// collection rename does.
|
||||
describe("PlexPlaylistView · rename", () => {
|
||||
it("closes without a write when the title is unchanged or empty", async () => {
|
||||
const write = vi.spyOn(api, "plexRenamePlaylist").mockResolvedValue({} as never);
|
||||
await meta([movie("1", 5400)]);
|
||||
for (const value of [null, " "]) {
|
||||
await pickFromMenu(
|
||||
() => fireEvent.click(screen.getByRole("button", { name: "plex.playlist.menu" })),
|
||||
"plex.playlist.rename",
|
||||
);
|
||||
const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" });
|
||||
if (value !== null) fireEvent.change(field, { target: { value } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await screen.findByRole("heading", { name: "Films" });
|
||||
}
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex playlist page: a title, a show group and a season each open ONE menu (the "…"
|
||||
// beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no
|
||||
// confirm, since the toast's Undo puts the rows back; `j`/`k` walk the visible rows and Delete takes
|
||||
// out the one the keyboard is on; the playlist's rename and delete sit in the header's "…".
|
||||
import { setLanguage } from "../../i18n";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key),
|
||||
i18n: real,
|
||||
}),
|
||||
};
|
||||
});
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) }));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockReset();
|
||||
h.confirm.mockReset();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
const ep = (id: string, season: number, n: number) =>
|
||||
({
|
||||
id,
|
||||
type: "episode",
|
||||
title: `Ep ${id}`,
|
||||
show_id: "S",
|
||||
show_title: "The Show",
|
||||
season_number: season,
|
||||
episode_number: n,
|
||||
thumb: "",
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
function mount(items: PlexCard[], onInfoCard?: (c: PlexCard) => void, { admin = false } = {}) {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items } as never);
|
||||
vi.spyOn(api, "comfortPins").mockResolvedValue([] as never);
|
||||
const onPlay = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
if (admin) client.setQueryData(qk.me(), { role: "admin" });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={onPlay} onInfoCard={onInfoCard} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onPlay, onBack };
|
||||
}
|
||||
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
async function openMenu(open: () => void) {
|
||||
open();
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
}
|
||||
async function pick(open: () => void, label: string) {
|
||||
await openMenu(open);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
||||
);
|
||||
}
|
||||
const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="plex.playlist.rowMenu"]')!;
|
||||
const toast = () =>
|
||||
h.notify.mock.calls
|
||||
.map((c) => c[0] as { message: string; action?: { label: string; onClick: () => void } })
|
||||
.filter((n) => n.message?.startsWith("plex.playlist.removedToast"))
|
||||
.pop();
|
||||
// The list's keys are live once its effect has registered it (`data-nav-list`), a passive effect that
|
||||
// can run AFTER the rows are on screen: a key pressed on a slow machine in between did nothing (CI flake).
|
||||
const keysLiveOn = (el: Element) =>
|
||||
waitFor(() => expect(el.closest("[data-nav-list]")).not.toBeNull());
|
||||
const pressKey = (key: string) =>
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · a row's menu", () => {
|
||||
it("offers an admin a movie's 'Add to collection…', as its card's menu does", async () => {
|
||||
mount([movie("1")], undefined, { admin: true });
|
||||
await screen.findByText("Movie 1");
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
expect(menuRows()).toContain("plex.collectionAdd.row");
|
||||
});
|
||||
|
||||
it("has no X at rest: each title ends in its '…', which hides until hover or focus", async () => {
|
||||
mount([movie("1"), ep("e1", 1, 1)]);
|
||||
await screen.findByText("Movie 1");
|
||||
expect(screen.queryAllByRole("button", { name: /plex\.playlist\.remove/ })).toHaveLength(0);
|
||||
const more = moreOf("Movie 1");
|
||||
expect(more.classList.contains("reveal")).toBe(true);
|
||||
expect(rowOf("Movie 1").classList.contains("group")).toBe(true);
|
||||
expect(moreOf("The Show")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists the title's own actions, its moves, then the removal", async () => {
|
||||
const info = vi.fn();
|
||||
mount([movie("1")], info);
|
||||
await screen.findByText("Movie 1");
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
expect(menuRows()).toEqual([
|
||||
"plex.play",
|
||||
"plex.markWatched",
|
||||
"plex.playlistAdd.manage",
|
||||
"plex.info.title",
|
||||
"common.copy.title",
|
||||
"home.pin.add",
|
||||
"common.order.top",
|
||||
"common.order.up",
|
||||
"common.order.down",
|
||||
"common.order.bottom",
|
||||
"plex.playlist.removeFromPlaylist",
|
||||
]);
|
||||
expect(moreOf("Movie 1").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("closes on a second press of the same '…', though its row is rebuilt each render", async () => {
|
||||
mount([movie("1")]);
|
||||
await screen.findByText("Movie 1");
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Movie 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Movie 1"));
|
||||
fireEvent.click(moreOf("Movie 1"), { detail: 1 });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => {
|
||||
mount([movie("1"), movie("2"), movie("3")]);
|
||||
await screen.findByText("Movie 2");
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
|
||||
// The server's side of it: the Undo reads the list fresh, and the row is gone from it.
|
||||
vi.mocked(api.plexPlaylist).mockResolvedValue({
|
||||
id: 5,
|
||||
title: "Films",
|
||||
items: [movie("1"), movie("3")],
|
||||
} as never);
|
||||
return {} as never;
|
||||
});
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
|
||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
|
||||
act(() => toast()!.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
|
||||
});
|
||||
|
||||
it("plays and removes a whole show from its group's menu", async () => {
|
||||
const { onPlay } = mount([movie("1"), ep("e1", 1, 1), ep("e2", 2, 1)]);
|
||||
await screen.findByText("The Show");
|
||||
await openMenu(() => fireEvent.click(moreOf("The Show")));
|
||||
expect(menuRows()).toEqual([
|
||||
"plex.playlist.playShow",
|
||||
"common.copy.title",
|
||||
"common.order.top",
|
||||
"common.order.up",
|
||||
"common.order.down",
|
||||
"common.order.bottom",
|
||||
"plex.playlist.removeShow",
|
||||
]);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.playlist.playShow")!,
|
||||
);
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledWith("e1", ["1", "e1", "e2"]));
|
||||
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
await pick(() => fireEvent.click(moreOf("The Show")), "plex.playlist.removeShow");
|
||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
|
||||
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:The Show"));
|
||||
});
|
||||
|
||||
it("removes one season from its label's menu", async () => {
|
||||
mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]);
|
||||
fireEvent.click(await screen.findByText("The Show")); // open the group
|
||||
const seasonLabel = await screen.findByText(/plex\.seasonTitle · 2/);
|
||||
const more = seasonLabel.parentElement!.querySelector<HTMLElement>(
|
||||
'button[aria-label="plex.playlist.rowMenu"]',
|
||||
)!;
|
||||
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
await openMenu(() => fireEvent.click(more));
|
||||
expect(menuRows()).toEqual(["plex.playlist.playSeason", "plex.playlist.removeSeason"]);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.playlist.removeSeason")!,
|
||||
);
|
||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e2", "e3"]));
|
||||
});
|
||||
});
|
||||
|
||||
const moveRow = (label: string) =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
|
||||
|
||||
describe("PlexPlaylistView · reordering without a drag (WCAG 2.5.7)", () => {
|
||||
it("moves a title among the blocks from its menu, the moves that go nowhere held off", async () => {
|
||||
mount([movie("1"), ep("e1", 1, 1), movie("2")]);
|
||||
await screen.findByText("Movie 1");
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
|
||||
await openMenu(() => fireEvent.click(moreOf("Movie 1")));
|
||||
expect((moveRow("common.order.top") as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((moveRow("common.order.up") as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(moveRow("common.order.bottom"));
|
||||
// The show moves as one block: Movie 1 lands after Movie 2, the episode stays with its show.
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "2", "1"]));
|
||||
});
|
||||
|
||||
it("moves a whole show from its group's menu", async () => {
|
||||
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2)]);
|
||||
await screen.findByText("The Show");
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
|
||||
await pick(() => fireEvent.click(moreOf("The Show")), "common.order.up");
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["e1", "e2", "1"]));
|
||||
});
|
||||
|
||||
it("moves an episode only within its own show", async () => {
|
||||
mount([movie("1"), ep("e1", 1, 1), ep("e2", 1, 2), ep("e3", 1, 3)]);
|
||||
fireEvent.click(await screen.findByText("The Show")); // open the group
|
||||
await screen.findByText("Ep e3");
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue({} as never);
|
||||
await openMenu(() => fireEvent.click(moreOf("Ep e3")));
|
||||
// Last of three in its show: down is off, though a movie sits above the show in the list.
|
||||
expect((moveRow("common.order.down") as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(moveRow("common.order.top"));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["1", "e3", "e1", "e2"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · the keyboard", () => {
|
||||
it("walks the rows with j and takes out the walked one on Delete", async () => {
|
||||
mount([movie("1"), movie("2"), movie("3")]);
|
||||
await keysLiveOn(await screen.findByText("Movie 3"));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
pressKey("j");
|
||||
pressKey("j");
|
||||
expect(document.activeElement).toBe(rowOf("Movie 2"));
|
||||
pressKey("Delete");
|
||||
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
|
||||
await waitFor(() => expect(screen.queryByText("Movie 2")).toBeNull());
|
||||
await waitFor(() => expect(document.activeElement).toBe(rowOf("Movie 3")));
|
||||
});
|
||||
|
||||
it("walks an open show's episodes after its header, and Delete on the header takes the show", async () => {
|
||||
mount([ep("e1", 1, 1), ep("e2", 1, 2), movie("9")]);
|
||||
fireEvent.click(await screen.findByText("The Show"));
|
||||
await keysLiveOn(await screen.findByText("Ep e2"));
|
||||
pressKey("j");
|
||||
expect(document.activeElement).toBe(rowOf("The Show"));
|
||||
pressKey("j");
|
||||
expect(document.activeElement).toBe(rowOf("Ep e1"));
|
||||
// Both sit flush in the show's clipping card: an outside ring was cut away (index.css).
|
||||
expect(rowOf("The Show").hasAttribute("data-nav-inset")).toBe(true);
|
||||
expect(rowOf("Ep e1").hasAttribute("data-nav-inset")).toBe(true);
|
||||
pressKey("k");
|
||||
const bulk = vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
|
||||
pressKey("Delete");
|
||||
await waitFor(() => expect(bulk).toHaveBeenCalledWith(5, ["e1", "e2"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · the header's '…'", () => {
|
||||
it("holds rename and delete; the pencil and the trash are gone", async () => {
|
||||
const { onBack } = mount([movie("1")]);
|
||||
await screen.findByText("Movie 1");
|
||||
expect(screen.queryByRole("button", { name: "plex.playlist.rename" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.playlist.delete" })).toBeNull();
|
||||
const del = vi.spyOn(api, "plexDeletePlaylist").mockResolvedValue({ deleted: 5 });
|
||||
const more = screen.getByRole("button", { name: "plex.playlist.menu" });
|
||||
await openMenu(() => fireEvent.click(more));
|
||||
expect(menuRows()).toEqual(["plex.playlist.rename", "plex.playlist.delete"]);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.playlist.delete")!,
|
||||
);
|
||||
// Deleting the playlist itself keeps its confirm: it has no undo.
|
||||
await waitFor(() =>
|
||||
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })),
|
||||
);
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(5));
|
||||
await waitFor(() => expect(onBack).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands the focus back to the '…' when a rename opened from it is left", async () => {
|
||||
mount([movie("1")]);
|
||||
await screen.findByText("Movie 1");
|
||||
const more = screen.getByRole("button", { name: "plex.playlist.menu" });
|
||||
act(() => more.focus());
|
||||
await pick(() => fireEvent.click(more), "plex.playlist.rename");
|
||||
const field = await screen.findByRole("textbox", { name: "plex.playlist.rename" });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "plex.playlist.menu" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The watched toggle flips the view's LOCAL copy of the rows. On a failure the
|
||||
// resync refetches an identical list, react-query keeps the same `data`, and the copy was never
|
||||
// re-seeded — the row stayed "watched" on screen while Plex still had it unwatched.
|
||||
describe("PlexPlaylistView · a failed watched toggle", () => {
|
||||
it("puts the row's watched state back", async () => {
|
||||
mount([movie("1"), movie("2")]);
|
||||
await screen.findByText("Movie 2");
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
|
||||
),
|
||||
);
|
||||
await act(async () => {});
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Movie 2")));
|
||||
expect(menuRows()).toContain("plex.markWatched");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · the reader's language", () => {
|
||||
it("asks for the rows in Hungarian", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
mount([movie("1")]);
|
||||
await screen.findByText("Movie 1");
|
||||
expect(api.plexPlaylist).toHaveBeenCalledWith(5, "hu");
|
||||
} finally {
|
||||
setLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPlaylistView · a season's name", () => {
|
||||
it("names season 0 the Specials, as the show page does", async () => {
|
||||
mount([ep("e0", 0, 1), ep("e1", 1, 1)]);
|
||||
fireEvent.click(await screen.findByText("The Show")); // open the show's seasons
|
||||
expect(await screen.findByText(/^plex\.specials · 1$/)).toBeTruthy();
|
||||
expect(screen.getByText(/^plex\.seasonTitle · 1$/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,834 @@
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ListTree,
|
||||
Pencil,
|
||||
Play,
|
||||
Rows3,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type PlexCard } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { DragHandle } from "../../components/ui/DragHandle";
|
||||
import { useOrderItems } from "../../components/ui/useOrderItems";
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
import {
|
||||
sameListCollision,
|
||||
sameListKeyboardCoordinates,
|
||||
useSortableA11y,
|
||||
useSortableItem,
|
||||
} from "../../components/ui/sortableA11y";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { useReturnFocus } from "../../components/ui/useReturnFocus";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { MEDIA_BADGE } from "../../components/ui/mediaCardStyles";
|
||||
import { seasonTitle } from "../../lib/plexDetail";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
// list. Reorder is drag & drop, or a row menu's Top / Up / Down / Bottom (a whole show-group moves as
|
||||
// one block; episodes reorder within their show). Two layouts — Accordion (A) and Tree (C) — are a
|
||||
// per-account preference. Rendered as a PlexBrowse subview; "Play all" opens the player with the
|
||||
// whole ordered list as its queue.
|
||||
//
|
||||
// Every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on
|
||||
// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no
|
||||
// confirm: the toast's Undo puts the rows back where they stood. `j`/`k` walk the visible rows and
|
||||
// Delete takes out the one the keyboard is on; the playlist's own rename and delete sit in the
|
||||
// header's "…".
|
||||
|
||||
type Layout = "accordion" | "tree";
|
||||
type Season = { key: string; number: number | null; items: PlexCard[] };
|
||||
type ShowBlock = Extract<Block, { kind: "show" }>;
|
||||
/** What a row's menu acts on. `key` is the row's identity across renders (the objects are rebuilt
|
||||
* on every one), so "is THIS row's menu the open one" — and the "…" closing its own — compare keys. */
|
||||
type MenuTarget =
|
||||
| { kind: "item"; key: string; item: PlexCard }
|
||||
| { kind: "show"; key: string; block: ShowBlock }
|
||||
| { kind: "season"; key: string; block: ShowBlock; season: Season };
|
||||
const targetKey = (t: MenuTarget) => t.key;
|
||||
type Block =
|
||||
| { kind: "movie"; id: string; item: PlexCard }
|
||||
| {
|
||||
kind: "show";
|
||||
id: string;
|
||||
showKey: string;
|
||||
title: string;
|
||||
items: PlexCard[];
|
||||
seasons: Season[];
|
||||
};
|
||||
|
||||
function buildSeasons(items: PlexCard[]): Season[] {
|
||||
const map = new Map<string, Season>();
|
||||
for (const it of items) {
|
||||
const num = it.season_number ?? null;
|
||||
const key = String(num ?? "?");
|
||||
let s = map.get(key);
|
||||
if (!s) {
|
||||
s = { key, number: num, items: [] };
|
||||
map.set(key, s);
|
||||
}
|
||||
s.items.push(it);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
// Turn the flat ordered list into blocks: contiguous episodes of one show group together (then split
|
||||
// into seasons for display); everything else (movies) is its own block.
|
||||
function buildBlocks(items: PlexCard[]): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
for (const it of items) {
|
||||
if (it.type === "episode") {
|
||||
const showKey = it.show_id || it.show_title || "?";
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last && last.kind === "show" && last.showKey === showKey) {
|
||||
last.items.push(it);
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "show",
|
||||
id: `show:${showKey}`,
|
||||
showKey,
|
||||
title: it.show_title || it.title,
|
||||
items: [it],
|
||||
seasons: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blocks.push({ kind: "movie", id: it.id, item: it });
|
||||
}
|
||||
}
|
||||
for (const b of blocks) if (b.kind === "show") b.seasons = buildSeasons(b.items);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const flatten = (blocks: Block[]): PlexCard[] =>
|
||||
blocks.flatMap((b) => (b.kind === "movie" ? [b.item] : b.items));
|
||||
|
||||
// Callbacks + shared state passed down to the module-scope block/row components.
|
||||
type Ctx = {
|
||||
layout: Layout;
|
||||
busy: boolean;
|
||||
expandedShows: Set<string>;
|
||||
collapsedSeasons: Set<string>;
|
||||
toggleShow: (k: string) => void;
|
||||
toggleSeason: (k: string) => void;
|
||||
onPlay: (rk: string) => void;
|
||||
/** The row's "…" and its right-click / long-press / Shift+F10 — the page's one menu, on `target`. */
|
||||
more: (target: MenuTarget) => ReactNode;
|
||||
trigger: (target: MenuTarget) => Record<string, unknown>;
|
||||
/** The row's index among the rows the keyboard walks (`navItemAttrs`). */
|
||||
navIndex: (key: string) => number;
|
||||
};
|
||||
|
||||
function Poster({ src, className }: { src: string; className: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
|
||||
>
|
||||
<RemoteImage
|
||||
{...cardImage(src)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: ep.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const target: MenuTarget = { kind: "item", key: ep.id, item: ep };
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...ctx.trigger(target)}
|
||||
{...navItemAttrs(ctx.navIndex(ep.id), ep.title, { inset: true })}
|
||||
className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
|
||||
>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: ep.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
|
||||
{ep.episode_number}
|
||||
</span>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => ctx.onPlay(ep.id)}
|
||||
data-nav-open
|
||||
className="group/thumb flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
|
||||
>
|
||||
<RemoteImage
|
||||
{...cardImage(ep.thumb)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{ep.status === "watched" && <span className={`${MEDIA_BADGE} right-0.5 top-0.5`}>✓</span>}
|
||||
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
|
||||
<Play className="h-4 w-4 text-white" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
|
||||
</Pressable>
|
||||
{ctx.more(target)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: item.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const dense = ctx.layout === "tree";
|
||||
const target: MenuTarget = { kind: "item", key: item.id, item };
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...ctx.trigger(target)}
|
||||
{...navItemAttrs(ctx.navIndex(item.id), item.title)}
|
||||
className={`group ${SURFACE.card} flex items-center gap-3 p-2`}
|
||||
>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: item.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => ctx.onPlay(item.id)}
|
||||
data-nav-open
|
||||
className="group/thumb flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-muted">{item.year}</div>
|
||||
</div>
|
||||
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover/thumb:bg-accent/15 group-hover/thumb:text-accent">
|
||||
<Play className="h-4 w-4" fill="currentColor" />
|
||||
</div>
|
||||
</Pressable>
|
||||
{ctx.more(target)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A season's name, as its label and in the toast that says it went — named as the show page names it
|
||||
* (season 0 is the Specials). */
|
||||
function useSeasonLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (s: Season) => seasonTitle({ season_number: s.number }, t);
|
||||
}
|
||||
|
||||
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: block.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const open = ctx.expandedShows.has(block.showKey);
|
||||
const dense = ctx.layout === "tree";
|
||||
const seasonLabel = useSeasonLabel();
|
||||
const target: MenuTarget = { kind: "show", key: block.id, block };
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
|
||||
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
|
||||
<div
|
||||
{...ctx.trigger(target)}
|
||||
{...navItemAttrs(ctx.navIndex(block.id), block.title, { inset: true })}
|
||||
// The card's corners minus its 1 px edge, so the inside keyboard ring follows the card's
|
||||
// curve instead of cutting across it; square at the bottom while the seasons hang below.
|
||||
className={`group flex items-center gap-2.5 p-2 rounded-t-[calc(var(--radius-xl)-1px)] ${
|
||||
open ? "" : "rounded-b-[calc(var(--radius-xl)-1px)]"
|
||||
}`}
|
||||
>
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: block.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => ctx.toggleShow(block.showKey)}
|
||||
aria-expanded={open}
|
||||
label={block.title}
|
||||
>
|
||||
{open ? <ChevronDown /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
{!dense && (
|
||||
// A second, pointer-only target for the same toggle (its images are decorative): out of
|
||||
// the tab order, so the keyboard does not stop on a button with no name.
|
||||
<Pressable
|
||||
onClick={() => ctx.toggleShow(block.showKey)}
|
||||
className="flex shrink-0"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
>
|
||||
{block.items.slice(0, 3).map((ep, i) => (
|
||||
<RemoteImage
|
||||
key={ep.id}
|
||||
{...cardImage(ep.thumb)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
|
||||
style={{ marginLeft: i ? -14 : 0 }}
|
||||
/>
|
||||
))}
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => ctx.toggleShow(block.showKey)}
|
||||
data-nav-open
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<div className="truncate text-sm font-semibold">{block.title}</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("plex.playlist.episodes", { count: block.items.length })}
|
||||
</div>
|
||||
</Pressable>
|
||||
{ctx.more(target)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className={`border-t border-border/50 ${dense ? "px-2 pb-1.5 pt-1" : "px-2 pb-2"}`}>
|
||||
<SortableContext
|
||||
items={block.items.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{block.seasons.map((s) => {
|
||||
const sKey = `${block.showKey}:${s.key}`;
|
||||
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
|
||||
const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s };
|
||||
return (
|
||||
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
|
||||
<div
|
||||
{...ctx.trigger(sTarget)}
|
||||
className="group flex items-center gap-1.5 py-1 pl-1"
|
||||
>
|
||||
{dense && (
|
||||
<IconButton
|
||||
onClick={() => ctx.toggleSeason(sKey)}
|
||||
aria-expanded={sOpen}
|
||||
label={seasonLabel(s)}
|
||||
>
|
||||
{sOpen ? <ChevronDown /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
)}
|
||||
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
|
||||
{seasonLabel(s)} · {s.items.length}
|
||||
</span>
|
||||
<span className="ml-1">{ctx.more(sTarget)}</span>
|
||||
</div>
|
||||
{sOpen && (
|
||||
<div
|
||||
className={
|
||||
dense ? "ml-2 space-y-0.5 border-l border-border/50 pl-1.5" : "space-y-1"
|
||||
}
|
||||
>
|
||||
{s.items.map((ep) => (
|
||||
<EpisodeRow key={ep.id} ep={ep} ctx={ctx} dense={dense} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlexPlaylistView({
|
||||
playlistId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onInfoCard,
|
||||
}: {
|
||||
playlistId: number;
|
||||
onBack: () => void;
|
||||
onPlay: (itemRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
/** A title's media-info page, from its menu's "Details". */
|
||||
onInfoCard?: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||
const [name, setName] = useState("");
|
||||
const [layout, setLayout] = usePref<Layout>("plexPlaylistLayout", "accordion", {
|
||||
localKey: LS.plexPlaylistLayout,
|
||||
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
|
||||
});
|
||||
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
|
||||
// seasons start expanded once their show is opened. Both are just local view state.
|
||||
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
|
||||
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
|
||||
|
||||
// The query, the optimistic copy and the two writes that edit it (reorder, remove) live in
|
||||
// `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches
|
||||
// `commit` cannot be, since dnd-kit's sensors do not activate under jsdom.
|
||||
const { q, items, busy, invalidate, commit, onRemove, setStatus } =
|
||||
usePlexPlaylistEdits(playlistId);
|
||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||
// Total playtime of the whole playlist, mirroring the YouTube playlist header: items with
|
||||
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
|
||||
const totalSeconds = useMemo(
|
||||
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
async function rename() {
|
||||
const title = name.trim();
|
||||
// Nothing to write (as the collection rename): close, so an armed focus hand-back fires now and
|
||||
// not on some later close.
|
||||
if (!title || title === q.data?.title) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
|
||||
return;
|
||||
}
|
||||
setRenaming(false);
|
||||
invalidate();
|
||||
}
|
||||
async function del() {
|
||||
if (
|
||||
!(await confirm({
|
||||
message: t("plex.playlist.deleteConfirm", { title: q.data?.title }),
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
} catch (e) {
|
||||
// Gone already (deleted on another device): what was asked for is true — leave the dead page.
|
||||
// Anything else is a real failure, and the page stays.
|
||||
if (!(e instanceof HttpError && e.status === 404)) {
|
||||
notifyActionError(e, t("plex.playlist.deleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onBack();
|
||||
}
|
||||
|
||||
const toggleShow = (k: string) =>
|
||||
setExpandedShows((s) => {
|
||||
const n = new Set(s);
|
||||
n.has(k) ? n.delete(k) : n.add(k);
|
||||
return n;
|
||||
});
|
||||
const toggleSeason = (k: string) =>
|
||||
setCollapsedSeasons((s) => {
|
||||
const n = new Set(s);
|
||||
n.has(k) ? n.delete(k) : n.add(k);
|
||||
return n;
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sameListKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
|
||||
const dndA11y = useSortableA11y((id) => {
|
||||
const at = blocks.findIndex((b) => b.id === id);
|
||||
if (at >= 0) {
|
||||
const b = blocks[at]!;
|
||||
return {
|
||||
title: b.kind === "show" ? b.title : b.item.title,
|
||||
pos: at + 1,
|
||||
total: blocks.length,
|
||||
};
|
||||
}
|
||||
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
|
||||
const eps = show?.kind === "show" ? show.items : [];
|
||||
const ep = eps.findIndex((it) => it.id === id);
|
||||
return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length };
|
||||
});
|
||||
|
||||
// A move, by drag or by a row menu's Top / Up / Down / Bottom: a whole block (a movie, a show)
|
||||
// among the blocks, an episode only within its own show.
|
||||
function moveBlock(id: string, to: number) {
|
||||
const from = blocks.findIndex((b) => b.id === id);
|
||||
if (from < 0 || to < 0 || to >= blocks.length || from === to) return;
|
||||
commit(flatten(arrayMove(blocks, from, to)));
|
||||
}
|
||||
function moveEpisode(id: string, to: number) {
|
||||
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === id));
|
||||
if (!b || b.kind !== "show") return;
|
||||
const from = b.items.findIndex((it) => it.id === id);
|
||||
if (to < 0 || to >= b.items.length || from === to) return;
|
||||
commit(
|
||||
flatten(
|
||||
blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const aId = String(active.id);
|
||||
const oId = String(over.id);
|
||||
// `over` is always a sibling of `active` (sameListCollision): a block over a block, an episode
|
||||
// over an episode of the same show. The lookups only refuse a stale id.
|
||||
const toBlock = blocks.findIndex((b) => b.id === oId);
|
||||
if (blocks.some((b) => b.id === aId)) {
|
||||
if (toBlock >= 0) moveBlock(aId, toBlock);
|
||||
return;
|
||||
}
|
||||
const show = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
|
||||
const to = show?.kind === "show" ? show.items.findIndex((it) => it.id === oId) : -1;
|
||||
if (to >= 0) moveEpisode(aId, to);
|
||||
}
|
||||
|
||||
// --- The rows' ONE menu -------------------------------------------------------------
|
||||
// Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened.
|
||||
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
|
||||
const copy = useCopyItems();
|
||||
const seasonLabel = useSeasonLabel();
|
||||
const titleActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlay(c.id, order),
|
||||
onRestart: (c) => onPlay(c.id, order, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (c) =>
|
||||
void toggleWatchState(c, t, {
|
||||
optimistic: (next) => setStatus(c.id, next),
|
||||
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
|
||||
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
|
||||
revert: () => setStatus(c.id, c.status ?? "new"),
|
||||
resync: invalidate,
|
||||
}),
|
||||
},
|
||||
rowMenu.open,
|
||||
);
|
||||
// What a row's removal takes out, and the name its toast says.
|
||||
const removal = (target: MenuTarget): { keys: string[]; title: string } =>
|
||||
target.kind === "item"
|
||||
? { keys: [target.item.id], title: target.item.title }
|
||||
: target.kind === "show"
|
||||
? { keys: target.block.items.map((i) => i.id), title: target.block.title }
|
||||
: {
|
||||
keys: target.season.items.map((i) => i.id),
|
||||
title: `${target.block.title} · ${seasonLabel(target.season)}`,
|
||||
};
|
||||
const remove = (target: MenuTarget) => {
|
||||
const { keys, title } = removal(target);
|
||||
void onRemove(keys, title);
|
||||
};
|
||||
const removeRow = (target: MenuTarget, label: string): ActionMenuItem => ({
|
||||
id: "remove",
|
||||
label,
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
separatorBefore: true,
|
||||
disabled: busy,
|
||||
onSelect: () => remove(target),
|
||||
});
|
||||
const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] =>
|
||||
first
|
||||
? [
|
||||
{
|
||||
id: "play",
|
||||
label,
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => onPlay(first.id, order),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
// The non-drag way (WCAG 2.5.7): what the row's grip drags, its menu moves — a block among the
|
||||
// blocks, an episode within its show. Off while a write is in flight, like the grip.
|
||||
const orderItems = useOrderItems();
|
||||
const off = busy ? {} : undefined;
|
||||
const orderRows = (target: Exclude<MenuTarget, { kind: "season" }>): ActionMenuItem[] => {
|
||||
const id = target.kind === "item" ? target.item.id : target.block.id;
|
||||
const at = blocks.findIndex((b) => b.id === id);
|
||||
if (at >= 0) return orderItems(at, blocks.length, (to) => moveBlock(id, to), off);
|
||||
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
|
||||
if (show?.kind !== "show") return [];
|
||||
const ep = show.items.findIndex((it) => it.id === id);
|
||||
return orderItems(ep, show.items.length, (to) => moveEpisode(id, to), off);
|
||||
};
|
||||
const menuTarget = rowMenu.target;
|
||||
const menuItems: ActionMenuItem[] = !menuTarget
|
||||
? []
|
||||
: menuTarget.kind === "item"
|
||||
? [
|
||||
...titleActions.items(menuTarget.item, { anchor: rowMenu.anchor }),
|
||||
...orderRows(menuTarget),
|
||||
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
|
||||
]
|
||||
: menuTarget.kind === "show"
|
||||
? [
|
||||
...playRow(t("plex.playlist.playShow"), menuTarget.block.items[0]),
|
||||
...copy(menuTarget.block.title, null).map((it, i) => ({
|
||||
...it,
|
||||
separatorBefore: i === 0,
|
||||
})),
|
||||
...orderRows(menuTarget),
|
||||
removeRow(menuTarget, t("plex.playlist.removeShow")),
|
||||
]
|
||||
: [
|
||||
...playRow(t("plex.playlist.playSeason"), menuTarget.season.items[0]),
|
||||
removeRow(menuTarget, t("plex.playlist.removeSeason")),
|
||||
];
|
||||
|
||||
// --- The keyboard's rows: every row on screen, in reading order ---------------------------------
|
||||
// A title, a show's header and — while the show is open — its episodes; a season label is a
|
||||
// heading, not a row (its "…" holds its actions). Delete takes out the row it is on.
|
||||
const navRows: MenuTarget[] = [];
|
||||
for (const b of blocks) {
|
||||
if (b.kind === "movie") {
|
||||
navRows.push({ kind: "item", key: b.item.id, item: b.item });
|
||||
continue;
|
||||
}
|
||||
navRows.push({ kind: "show", key: b.id, block: b });
|
||||
if (!expandedShows.has(b.showKey)) continue;
|
||||
for (const se of b.seasons) {
|
||||
if (layout === "tree" && collapsedSeasons.has(`${b.showKey}:${se.key}`)) continue;
|
||||
for (const ep of se.items) navRows.push({ kind: "item", key: ep.id, item: ep });
|
||||
}
|
||||
}
|
||||
const navIndexOf = new Map(navRows.map((r, i) => [r.key, i]));
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useListNav(listRef, {
|
||||
enabled: items.length > 0,
|
||||
onDelete: (i) => {
|
||||
const row = navRows[i];
|
||||
if (row) remove(row);
|
||||
},
|
||||
});
|
||||
|
||||
const { buttonProps: rowMenuButton, triggerProps: rowMenuTrigger } = rowMenu;
|
||||
const ctx: Ctx = {
|
||||
layout,
|
||||
busy,
|
||||
expandedShows,
|
||||
collapsedSeasons,
|
||||
toggleShow,
|
||||
toggleSeason,
|
||||
onPlay: (rk) => onPlay(rk, order),
|
||||
more: (target) => {
|
||||
const mine = rowMenu.open && rowMenu.target?.key === target.key;
|
||||
return (
|
||||
<MoreButton
|
||||
label={t("plex.playlist.rowMenu")}
|
||||
expanded={mine}
|
||||
menuProps={rowMenuButton(target)}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
trigger: rowMenuTrigger,
|
||||
navIndex: (key) => navIndexOf.get(key) ?? -1,
|
||||
};
|
||||
|
||||
// --- The playlist's own menu: the header's "…" ---------------------------------------------------
|
||||
const headerMenu = useContextMenu<number>();
|
||||
const headerItems: ActionMenuItem[] = [
|
||||
{
|
||||
id: "rename",
|
||||
label: t("plex.playlist.rename"),
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
onSelect: () => {
|
||||
setName(q.data?.title ?? "");
|
||||
setRenaming(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: t("plex.playlist.delete"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void del(),
|
||||
},
|
||||
];
|
||||
|
||||
const layoutOptions: ViewOption<Layout>[] = [
|
||||
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
|
||||
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[90%] max-w-275 p-4">
|
||||
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
|
||||
<div className={`${SURFACE.panel} p-4 sm:p-6`}>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<Input
|
||||
className="w-full text-lg font-bold"
|
||||
size="heading"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
armRenameReturn(e);
|
||||
void rename(); // reports its own failure
|
||||
}
|
||||
// Unmounting the field fires no blur, so Escape really does cancel.
|
||||
if (e.key === "Escape") {
|
||||
armRenameReturn(e);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
onBlur={rename}
|
||||
aria-label={t("plex.playlist.rename")}
|
||||
/>
|
||||
) : (
|
||||
<PageHeading className="truncate text-2xl font-bold">
|
||||
{q.data?.title ?? " "}
|
||||
</PageHeading>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-0.5 text-sm text-muted">
|
||||
{t("plex.browse.items", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={setLayout}
|
||||
label={t("plex.playlist.layoutLabel")}
|
||||
/>
|
||||
{items.length > 0 && (
|
||||
<Button
|
||||
onClick={() => onPlay(order[0]!, order)}
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
{t("plex.playlist.playAll")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…"; a
|
||||
rename opened from there hands the focus back to it. */}
|
||||
<MoreButton
|
||||
ref={renameTarget}
|
||||
variant="glass"
|
||||
size="md"
|
||||
label={t("plex.playlist.menu")}
|
||||
expanded={headerMenu.open}
|
||||
menuProps={headerMenu.buttonProps(playlistId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState shape="list" label={t("plex.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={sameListCollision}
|
||||
onDragEnd={onDragEnd}
|
||||
accessibility={dndA11y}
|
||||
>
|
||||
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
|
||||
<div ref={listRef} className="space-y-1.5">
|
||||
{blocks.map((b) =>
|
||||
b.kind === "movie" ? (
|
||||
<MovieRow key={b.id} item={b.item} ctx={ctx} />
|
||||
) : (
|
||||
<ShowGroup key={b.id} block={b} ctx={ctx} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("plex.playlist.rowMenu")} items={menuItems} />
|
||||
<ContextMenu menu={headerMenu} ariaLabel={t("plex.playlist.menu")} items={headerItems} />
|
||||
{titleActions.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
||||
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
||||
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
|
||||
// default saved view was never applied again.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
|
||||
import { PlexProvider } from "./PlexProvider";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
function mount() {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NavigationProvider>
|
||||
<PlexProvider>
|
||||
<div />
|
||||
</PlexProvider>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(5);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("PlexProvider shared link", () => {
|
||||
it("persists the scope a link carries and leaves the dimensions it omits unwritten", () => {
|
||||
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
|
||||
mount();
|
||||
|
||||
expect(localStorage.getItem("siftlode.plexLibrary")).toBe("movie");
|
||||
expect(localStorage.getItem("siftlode.plexFilters")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexSort")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexShow")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../../lib/api";
|
||||
import { parseSortState, type SortState } from "../../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../../lib/plexSort";
|
||||
import {
|
||||
hasPlexParams,
|
||||
plexParamsToState,
|
||||
plexParamUpdates,
|
||||
type PlexUrlState,
|
||||
} from "../../lib/plexUrlState";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
|
||||
import { logFilterChange } from "../../lib/filterLog";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import {
|
||||
PlexActionsContext,
|
||||
PlexStateContext,
|
||||
type PlexActions,
|
||||
type PlexJumpTarget,
|
||||
type PlexState,
|
||||
} from "./plexContext";
|
||||
|
||||
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
|
||||
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
|
||||
// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
||||
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
|
||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||
// into (and is never greeted by a stale value from) the feed.
|
||||
//
|
||||
// The contexts, their value types and the hooks live in plexContext.ts.
|
||||
|
||||
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
|
||||
function readPlexScope(): string {
|
||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||
}
|
||||
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
||||
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
||||
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
||||
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
|
||||
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
||||
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
||||
// upgrade rather than snapping back to the default.
|
||||
function readPlexSort(): SortState {
|
||||
const raw = getAccountRaw(LS.plexSort);
|
||||
if (!raw) return PLEX_SORT_FALLBACK;
|
||||
let wasJson = false;
|
||||
try {
|
||||
const parsed = parseSortState(JSON.parse(raw));
|
||||
wasJson = true;
|
||||
if (parsed) return parsed;
|
||||
} catch {
|
||||
/* not JSON → a legacy bare-key string, handled below */
|
||||
}
|
||||
// Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a
|
||||
// key. Only a non-JSON raw is a genuine legacy bare key.
|
||||
if (wasJson) return PLEX_SORT_FALLBACK;
|
||||
let dir: "asc" | "desc" = "asc";
|
||||
try {
|
||||
if (
|
||||
(JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc"
|
||||
)
|
||||
dir = "desc";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { key: raw, dir };
|
||||
}
|
||||
|
||||
// A shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
|
||||
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
|
||||
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
|
||||
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
|
||||
function readPlexFilters(): PlexFilters {
|
||||
try {
|
||||
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
|
||||
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
||||
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
type PlexUrlSeed = PlexUrlState & { present: PlexSeedPresent };
|
||||
function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
const state = plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
const present = {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
};
|
||||
return { ...state, present };
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
|
||||
// the account (the effect further down), so the shared view also survives a later plain reload.
|
||||
const [urlSeed] = useState(readPlexUrlSeed);
|
||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
|
||||
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
|
||||
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
|
||||
return coercePlexFilters(JSON.parse(filtersRaw));
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}, [filtersRaw]);
|
||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||
const [q, setQState] = useState<string>(readPlexQ);
|
||||
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
|
||||
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
|
||||
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
||||
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
||||
// account has picked filters, so the account's default saved view would never be applied again.
|
||||
useEffect(() => {
|
||||
if (!urlSeed) return;
|
||||
const { present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
||||
if (present.show) setAccountRaw(LS.plexShow, urlSeed.show);
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
||||
}, [urlSeed]);
|
||||
|
||||
const setScope = useCallback((v: string) => {
|
||||
logFilterChange("plex", { scope: v });
|
||||
setScopeState(v);
|
||||
setAccountRaw(LS.plexLibrary, v);
|
||||
}, []);
|
||||
const setShow = useCallback((v: string) => {
|
||||
logFilterChange("plex", { show: v });
|
||||
setShowState(v);
|
||||
setAccountRaw(LS.plexShow, v);
|
||||
}, []);
|
||||
const setSort = useCallback((v: SortState) => {
|
||||
logFilterChange("plex", { sort: v });
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||
}, []);
|
||||
const setFilters = useCallback((f: PlexFilters) => {
|
||||
logFilterChange("plex", { filters: f });
|
||||
const s = JSON.stringify(f);
|
||||
setFiltersRaw(s);
|
||||
setAccountRaw(LS.plexFilters, s);
|
||||
}, []);
|
||||
const setQ = useCallback((v: string) => {
|
||||
logFilterChange("plex", {}, v);
|
||||
setQState(v);
|
||||
setAccountRaw(LS.plexQ, v);
|
||||
}, []);
|
||||
const setField = useCallback((v: PlexSearchField) => {
|
||||
logFilterChange("plex", { field: v });
|
||||
setFieldState(v);
|
||||
setAccountRaw(LS.plexField, v);
|
||||
}, []);
|
||||
|
||||
const state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
|
||||
);
|
||||
const actions = useMemo<PlexActions>(
|
||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
// Mirror the content state (scope + sort + watch-state + filters) into the address bar under the
|
||||
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
|
||||
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
|
||||
// search term is deliberately transient-per-view and not part of the shared four-key contract.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
|
||||
|
||||
return (
|
||||
<PlexActionsContext.Provider value={actions}>
|
||||
<PlexStateContext.Provider value={state}>{children}</PlexStateContext.Provider>
|
||||
</PlexActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header library-scope pill (scopes + browse modes): it renders the
|
||||
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
|
||||
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
|
||||
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
|
||||
// button group (no floating-ui), so no observer stubs are needed.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const plexState = { scope: "both" } as { scope: string };
|
||||
const setScope = vi.fn();
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => plexState,
|
||||
usePlexActions: () => ({ setScope }),
|
||||
}));
|
||||
|
||||
// The bridge: a controllable current view kind + an open spy.
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./plexViewContext", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
controls.kind = "discover";
|
||||
setScope.mockReset();
|
||||
controls.open.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const buttons = () => [
|
||||
...screen.getByRole("group", { name: "plex.filter.scope" }).querySelectorAll("button"),
|
||||
];
|
||||
const labels = () => buttons().map((b) => b.textContent);
|
||||
const pressed = () =>
|
||||
buttons()
|
||||
.filter((b) => b.getAttribute("aria-pressed") === "true")
|
||||
.map((b) => b.textContent);
|
||||
|
||||
describe("PlexScopeToggle", () => {
|
||||
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
||||
plexState.scope = "movie";
|
||||
render(<PlexScopeToggle />);
|
||||
expect(labels()).toEqual([
|
||||
"plex.filter.scopeOpt.both",
|
||||
"plex.filter.scopeOpt.movie",
|
||||
"plex.filter.scopeOpt.show",
|
||||
"plex.filter.scopeOpt.playlists",
|
||||
"plex.filter.scopeOpt.collections",
|
||||
]);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
});
|
||||
|
||||
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||
expect(setScope).toHaveBeenCalledWith("show");
|
||||
expect(controls.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the Collection mode active when the current view is collections", () => {
|
||||
controls.kind = "collections";
|
||||
render(<PlexScopeToggle />);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
||||
});
|
||||
|
||||
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
||||
controls.kind = "playlists";
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
||||
expect(setScope).toHaveBeenCalledWith("movie");
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { usePlexViewControls } from "./plexViewContext";
|
||||
|
||||
// The Plex "browse target" as a compact header control next to the search box: the three library
|
||||
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
|
||||
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
|
||||
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
|
||||
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
|
||||
// playlists/collections surface returns to the title grid at that scope.
|
||||
const SCOPES = ["both", "movie", "show"] as const;
|
||||
|
||||
export default function PlexScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { scope } = usePlex();
|
||||
const { setScope } = usePlexActions();
|
||||
const ctl = usePlexViewControls();
|
||||
const inPlaylists = ctl?.kind === "playlists";
|
||||
const inCollections = ctl?.kind === "collections";
|
||||
// The active segment: the browse-mode when in one, else the library scope.
|
||||
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
||||
|
||||
const onChange = (v: string) => {
|
||||
if (v === "playlists" || v === "collections") {
|
||||
ctl?.open({ kind: v });
|
||||
return;
|
||||
}
|
||||
setScope(v);
|
||||
// Leaving the playlists/collections surface for a title scope → show the title grid.
|
||||
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.filter.scope")}
|
||||
options={[
|
||||
...SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) })),
|
||||
{ value: "playlists", label: t("plex.filter.scopeOpt.playlists") },
|
||||
{ value: "collections", label: t("plex.filter.scopeOpt.collections") },
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the Plex header search box: the field scope-pill and the instant typeahead.
|
||||
// Covers the pieces the /code-review flagged: the pill's per-scope options (Episode only in
|
||||
// show/both), the suggestion list rendering, keyboard pick → jumpTo hand-off, and the regression
|
||||
// that closing the panel (Escape) must NOT wedge it shut while the input keeps focus — typing again
|
||||
// re-opens it. i18n is key-passthrough; the provider, api, and debounce are mocked so the test drives
|
||||
// state directly. ActionMenu is floating-ui based, so we stub the observers and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
// Identity debounce so a typed value is reflected immediately.
|
||||
vi.mock("../../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||
|
||||
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
|
||||
const plexState = { scope: "both", field: "all", q: "" } as {
|
||||
scope: string;
|
||||
field: string;
|
||||
q: string;
|
||||
};
|
||||
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
|
||||
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
|
||||
// the panel re-appears from cache.
|
||||
const setQ = vi.fn((v: string) => {
|
||||
plexState.q = v;
|
||||
});
|
||||
const setField = vi.fn();
|
||||
const setJumpTo = vi.fn();
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => plexState,
|
||||
usePlexActions: () => ({ setQ, setField, setJumpTo }),
|
||||
}));
|
||||
|
||||
// api.plexLibrary stub — returns whatever the current test queued.
|
||||
const plexLibrary = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
|
||||
const card = (id: string, title: string, type: "movie" | "show" = "movie", year = 2000) => ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
year,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
});
|
||||
const result = (items: ReturnType<typeof card>[]) => ({
|
||||
scope: "both",
|
||||
total: items.length,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
items,
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
// jsdom has no layout, so scrollIntoView (used to keep the active option in view) is absent.
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
plexState.field = "all";
|
||||
plexState.q = "";
|
||||
setQ.mockClear(); // clear calls but KEEP the write-back implementation
|
||||
setField.mockReset();
|
||||
setJumpTo.mockReset();
|
||||
plexLibrary.mockReset();
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderBox = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlexSearchBox />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const openPill = () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.searchField.aria" }));
|
||||
|
||||
describe("PlexSearchBox — scope-pill", () => {
|
||||
it("offers all five field scopes when shows are in scope", () => {
|
||||
plexState.scope = "both";
|
||||
renderBox();
|
||||
openPill();
|
||||
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
expect(labels).toEqual([
|
||||
"plex.searchField.all",
|
||||
"plex.searchField.title",
|
||||
"plex.searchField.people",
|
||||
"plex.searchField.summary",
|
||||
"plex.searchField.episode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the Episode scope in a movie-only scope", () => {
|
||||
plexState.scope = "movie";
|
||||
renderBox();
|
||||
openPill();
|
||||
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
expect(labels).toEqual([
|
||||
"plex.searchField.all",
|
||||
"plex.searchField.title",
|
||||
"plex.searchField.people",
|
||||
"plex.searchField.summary",
|
||||
]);
|
||||
expect(labels).not.toContain("plex.searchField.episode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexSearchBox — typeahead", () => {
|
||||
it("renders the top matches on focus and picks one via keyboard → jumpTo", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(
|
||||
result([card("m1", "The Matrix"), card("m2", "The Matrix Reloaded")]),
|
||||
);
|
||||
renderBox();
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
|
||||
// Both matches show up as options.
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("The Matrix"),
|
||||
expect.stringContaining("The Matrix Reloaded"),
|
||||
]),
|
||||
);
|
||||
// The request carried the active field scope + the limit.
|
||||
expect(plexLibrary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scope: "both", q: "matrix", field: "all", limit: 10 }),
|
||||
);
|
||||
|
||||
// ArrowDown highlights the first, Enter opens it via the jumpTo hand-off (not a Go search).
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie" });
|
||||
});
|
||||
|
||||
it("reopens after Escape when the user types again (focus/dismiss are decoupled)", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
renderBox();
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
await screen.findByRole("listbox");
|
||||
|
||||
// Escape dismisses the panel (input stays focused — Escape does not blur).
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
|
||||
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
|
||||
// for the new term (not just re-show the cached list).
|
||||
plexLibrary.mockClear();
|
||||
fireEvent.change(input, { target: { value: "matrixx" } });
|
||||
await screen.findByRole("listbox");
|
||||
await waitFor(() =>
|
||||
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
|
||||
);
|
||||
});
|
||||
|
||||
// SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
|
||||
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
|
||||
// next Escape leaves.
|
||||
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
renderBox();
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
await screen.findByRole("listbox");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
|
||||
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
renderBox();
|
||||
const input = screen.getByRole("combobox");
|
||||
// Collapsed until focused.
|
||||
expect(input.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
fireEvent.focus(input);
|
||||
const list = await screen.findByRole("listbox");
|
||||
// Expanded, and the input points at the listbox it controls.
|
||||
expect(input.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(input.getAttribute("aria-controls")).toBe(list.id);
|
||||
|
||||
// Escape collapses it again and drops the association.
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
|
||||
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||
});
|
||||
|
||||
// A listbox may own options only — "Loading…" sat inside it.
|
||||
it("shows its loading line beside the listbox, not in it", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockReturnValue(new Promise(() => {}));
|
||||
renderBox();
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const loading = await screen.findByText("plex.loading");
|
||||
expect(screen.getByRole("listbox").contains(loading)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||
import SearchBar from "../../features/header/SearchBar";
|
||||
import ActionMenu from "../../components/ui/ActionMenu";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { api } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../../lib/plexSearch";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { ROVING_HIGHLIGHT } from "../../components/ui/AnchoredPopover";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a
|
||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
||||
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
||||
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
||||
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
||||
const MIN_CHARS = 2;
|
||||
const SUGGEST_LIMIT = 10;
|
||||
|
||||
export default function PlexSearchBox() {
|
||||
const { t } = useTranslation();
|
||||
const { q, scope, field } = usePlex();
|
||||
const { setQ, setField, setJumpTo } = usePlexActions();
|
||||
const activeField = healPlexField(field, scope);
|
||||
const dq = useDebounced(q.trim(), 200);
|
||||
// `focused` mirrors the input's real DOM focus (only onFocus/onBlur touch it). `dismissed` is the
|
||||
// separate "the user closed the panel" flag (Escape / after a pick) — kept apart so closing the
|
||||
// panel never desyncs from actual focus: typing again clears `dismissed` and the panel re-opens
|
||||
// without needing a blur+refocus cycle.
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const listId = useId();
|
||||
|
||||
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
||||
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
||||
const suggestEnabled =
|
||||
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
|
||||
const suggestQ = useQuery({
|
||||
queryKey: qk.plexSuggest(scope, dq, activeField),
|
||||
enabled: suggestEnabled,
|
||||
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
||||
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
||||
|
||||
// A fresh query resets the highlight — during THAT render, so no frame ever draws the new
|
||||
// suggestion list with the old row highlighted.
|
||||
const suggestKey = `${scope}|${activeField}|${dq}`;
|
||||
const [hiFor, setHiFor] = useState(suggestKey);
|
||||
if (hiFor !== suggestKey) {
|
||||
setHiFor(suggestKey);
|
||||
setHi(-1);
|
||||
}
|
||||
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
||||
useEffect(() => {
|
||||
if (hi < 0) return;
|
||||
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [hi]);
|
||||
|
||||
const pick = (i: number) => {
|
||||
const c = suggestions[i];
|
||||
if (!c) return;
|
||||
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
||||
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
|
||||
setHi(-1);
|
||||
};
|
||||
|
||||
const fields = availablePlexFields(scope);
|
||||
const pillLabel = t(plexFieldLabelKey(activeField));
|
||||
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
|
||||
// on a collapsed combobox is an inconsistent a11y state.
|
||||
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
|
||||
|
||||
return (
|
||||
<SearchBar
|
||||
value={q}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
// Typing after a dismiss (Escape / pick) re-opens the panel.
|
||||
onChange={(v) => {
|
||||
setDismissed(false);
|
||||
setQ(v);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
setDismissed(false);
|
||||
}}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHi((h) => Math.max(h - 1, -1));
|
||||
} else if (e.key === "Enter" && hi >= 0) {
|
||||
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||
pick(hi);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
|
||||
setDismissed(true);
|
||||
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
||||
}
|
||||
}}
|
||||
inputProps={{
|
||||
role: "combobox",
|
||||
"aria-autocomplete": "list",
|
||||
"aria-expanded": open,
|
||||
"aria-controls": open ? listId : undefined,
|
||||
"aria-activedescendant": activeOptionId,
|
||||
}}
|
||||
leading={
|
||||
<ActionMenu
|
||||
ariaLabel={t("plex.searchField.aria")}
|
||||
className="min-w-0 shrink inline-flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium text-muted transition state-layer hover:text-fg touch-target"
|
||||
trigger={
|
||||
<>
|
||||
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
items={fields.map((f) => ({
|
||||
id: f,
|
||||
label: t(plexFieldLabelKey(f)),
|
||||
onSelect: () => setField(f),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
below={
|
||||
open ? (
|
||||
<div
|
||||
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70dvh] overflow-y-auto animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* Beside the listbox, not in it: a listbox may own options only. */}
|
||||
{suggestions.length === 0 && suggestQ.isFetching && (
|
||||
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||
)}
|
||||
<div ref={listRef} id={listId} role="listbox" aria-label={t("plex.suggest.aria")}>
|
||||
{suggestions.map((c, i) => (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
id={`${listId}-opt-${i}`}
|
||||
data-idx={i}
|
||||
role="option"
|
||||
aria-selected={hi === i}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => pick(i)}
|
||||
// The pointer moves `hi` too, so the highlight IS the hover: the menus' roving
|
||||
// recipe (AnchoredPopover's ROVING_HIGHLIGHT), one look for the mouse and the keys.
|
||||
data-active={hi === i ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
|
||||
>
|
||||
<RemoteImage
|
||||
{...cardImage(c.thumb)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
||||
{c.year != null && (
|
||||
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
||||
)}
|
||||
{c.type === "show" ? (
|
||||
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
) : (
|
||||
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The sidebar's playlist picker: typing a name offers "Create …" — but only
|
||||
// once the lists have loaded. Over a failed load the create row pushed the error and its Retry out
|
||||
// of the list at the first keystroke, and whether the name already existed was unknown anyway.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { name?: string }) => (o?.name != null ? `${key}:${o.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => ({}),
|
||||
usePlexActions: () => ({ setPlaylistOpen: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { PlaylistPicker } from "./PlexSidebar";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistPicker />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return screen.getByRole("combobox");
|
||||
}
|
||||
|
||||
describe("the sidebar playlist picker", () => {
|
||||
it("keeps the load error and its Retry while a name is typed, with no Create row", async () => {
|
||||
vi.spyOn(api, "plexPlaylists").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const input = mount();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "Road trip" } });
|
||||
await waitFor(() => expect(screen.getByRole("alert", { hidden: true })).toBeTruthy());
|
||||
expect(screen.getByText("common.retry")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.playlist.createNamed:Road trip")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers Create for a new name once the lists are in", async () => {
|
||||
vi.spyOn(api, "plexPlaylists").mockResolvedValue({ playlists: [] } as never);
|
||||
const input = mount();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "Road trip" } });
|
||||
expect(await screen.findByText("plex.playlist.createNamed:Road trip")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import MatchToggle from "../../components/ui/MatchToggle";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../feed/savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
|
||||
// the backend so the panel only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
{ key: "short", min: null, max: 90 * 60 },
|
||||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||
const { scope, show, filters } = usePlex();
|
||||
const { setFilters } = usePlexActions();
|
||||
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||||
// title opens the full manager modal (same shared components as the feed).
|
||||
const { openModal, modalOpen } = useSavedViews();
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||||
const lang = plexLang(i18n.language); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||||
enabled: !!scope,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
const fCount = plexFilterCount(filters);
|
||||
// Scope and sort moved to the header / above-the-grid toolbar; the panel badge now counts only
|
||||
// the filters that still live in this panel (expanded filters + watch-state).
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0);
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
|
||||
// just lists them as removable chips. Studios now have their own picker section (below).
|
||||
const peopleActive = filters.directors.length + filters.actors.length > 0;
|
||||
|
||||
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
|
||||
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
|
||||
// layout and lets the user reorder / hide / collapse them.
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "playlists",
|
||||
title: t("plex.playlist.section"),
|
||||
body: <PlaylistPicker />,
|
||||
},
|
||||
...(peopleActive
|
||||
? [
|
||||
{
|
||||
id: "people",
|
||||
title: t("plex.filter.active"),
|
||||
body: (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.directors.map((d) => (
|
||||
<RemovableChip
|
||||
key={`d:${d}`}
|
||||
label={d}
|
||||
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
||||
/>
|
||||
))}
|
||||
<MatchToggle
|
||||
picked={filters.actors.length}
|
||||
className="w-full"
|
||||
value={filters.actorMode ?? "any"}
|
||||
onChange={(m) => patch({ actorMode: m })}
|
||||
/>
|
||||
{filters.actors.map((a) => (
|
||||
<RemovableChip
|
||||
key={`a:${a}`}
|
||||
label={a}
|
||||
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "collection",
|
||||
title: t("plex.filter.collection"),
|
||||
body: (
|
||||
<CollectionPicker
|
||||
values={filters.collections}
|
||||
titles={filters.collectionTitles}
|
||||
mode={filters.collectionMode ?? "any"}
|
||||
onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })}
|
||||
onModeChange={(m) => patch({ collectionMode: m })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rating",
|
||||
title: t("plex.filter.rating"),
|
||||
body: (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.rating")}
|
||||
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||||
options={[
|
||||
{ value: "", label: t("plex.filter.any") },
|
||||
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||||
]}
|
||||
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(facets && facets.genres.length > 0
|
||||
? [
|
||||
{
|
||||
id: "genre",
|
||||
title: t("plex.filter.genre"),
|
||||
body: (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={filters.genres.length}
|
||||
value={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("plex.filter.genre")}
|
||||
options={facets.genres}
|
||||
selected={filters.genres}
|
||||
onChange={(next) => patch({ genres: next })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "year",
|
||||
title: t("plex.filter.year"),
|
||||
body: (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(scope === "movie"
|
||||
? [
|
||||
{
|
||||
id: "duration",
|
||||
title: t("plex.filter.duration"),
|
||||
body: (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.duration")}
|
||||
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
|
||||
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
|
||||
value={
|
||||
durBucketKey ??
|
||||
(filters.durationMin == null && filters.durationMax == null ? "" : " ")
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: t("plex.filter.any") },
|
||||
...DURATION_BUCKETS.map((b) => ({
|
||||
value: b.key,
|
||||
label: t(`plex.filter.durationOpt.${b.key}`),
|
||||
})),
|
||||
]}
|
||||
onChange={(key) => {
|
||||
const b = DURATION_BUCKETS.find((x) => x.key === key);
|
||||
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "added",
|
||||
title: t("plex.filter.added"),
|
||||
body: (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.added")}
|
||||
value={filters.addedWithin || ""}
|
||||
options={[
|
||||
{ value: "", label: t("plex.filter.any") },
|
||||
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
|
||||
]}
|
||||
onChange={(v) => patch({ addedWithin: v })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(facets && facets.content_ratings.length > 0
|
||||
? [
|
||||
{
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={facets.content_ratings.map((o) => ({
|
||||
...o,
|
||||
label: vocab(t, "certificate", o.value),
|
||||
}))}
|
||||
selected={filters.contentRatings}
|
||||
onChange={(next) => patch({ contentRatings: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(facets && facets.studios.length > 0
|
||||
? [
|
||||
{
|
||||
id: "studio",
|
||||
title: t("plex.filter.studio"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.studio")}
|
||||
options={facets.studios}
|
||||
selected={filters.studios}
|
||||
onChange={(next) => patch({ studios: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||||
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||||
...(scope === "movie" && facets && facets.playable.length > 0
|
||||
? [
|
||||
{
|
||||
id: "playable",
|
||||
title: t("plex.filter.playable"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.playable")}
|
||||
options={facets.playable.map((p) => ({
|
||||
value: p.value,
|
||||
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||
count: p.count,
|
||||
}))}
|
||||
selected={filters.playable}
|
||||
onChange={(next) => patch({ playable: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
|
||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
|
||||
// Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||
onTitleClick={openModal}
|
||||
titleActionLabel={t("views.manageTitle")}
|
||||
/>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The playlists section: a combobox that stays compact (just a field + chevron) until opened, instead
|
||||
// of an always-expanded list that grew the rail with every playlist. Picking a playlist opens it (the
|
||||
// PlexBrowse `playlistOpen` hand-off); typing a name that doesn't exist yet offers a "Create …" row so
|
||||
// you can still add one — the same reuse-the-Combobox idea as the feed's "Your tags" widget. `selected`
|
||||
// stays empty: this picks an action (open / create), it doesn't hold a persistent selection.
|
||||
const CREATE_PLAYLIST = "__create__";
|
||||
export function PlaylistPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||||
const qc = useQueryClient();
|
||||
const [query, setQuery] = useState("");
|
||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = playlistsQ.data?.playlists ?? [];
|
||||
|
||||
const q = query.trim();
|
||||
const ql = q.toLowerCase();
|
||||
const matches = ql ? playlists.filter((p) => p.title.toLowerCase().includes(ql)) : playlists;
|
||||
const exact = playlists.some((p) => p.title.toLowerCase() === ql);
|
||||
const options: ComboboxOption[] = [
|
||||
...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })),
|
||||
// Offer to create only when the typed name isn't already a playlist — which is unknown until the
|
||||
// lists load: over a failed load the row pushed out the error and its Retry.
|
||||
...(q && !exact && playlistsQ.data ? [{ value: CREATE_PLAYLIST, label: q }] : []),
|
||||
];
|
||||
|
||||
async function createPlaylist(title: string) {
|
||||
try {
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
} catch (e) {
|
||||
// Called without an await from the picker, so a refusal was an unhandled rejection.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={t("plex.playlist.section")}
|
||||
multiple={false}
|
||||
clientFilter={false} // we build `options` (incl. the create row) against the query ourselves
|
||||
options={options}
|
||||
selected={[]}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder={t("plex.playlist.pickerPlaceholder")}
|
||||
loading={playlistsQ.isLoading}
|
||||
error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined}
|
||||
onRetry={() => void playlistsQ.refetch()}
|
||||
emptyText={t("plex.playlist.none")}
|
||||
renderOption={(o) =>
|
||||
o.value === CREATE_PLAYLIST ? (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="flex-1 truncate">
|
||||
{t("plex.playlist.createNamed", { name: o.label })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
onChange={(next) => {
|
||||
const v = next[0];
|
||||
if (!v) return;
|
||||
if (v === CREATE_PLAYLIST)
|
||||
void createPlaylist(q); // reports its own failure
|
||||
else onOpenPlaylist(Number(v.slice(3)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The collection filter (a multi-select combobox): a combi-input matching the genre/studio
|
||||
// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic <Combobox>
|
||||
// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
|
||||
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
|
||||
function CollectionPicker({
|
||||
values,
|
||||
titles,
|
||||
mode,
|
||||
onChange,
|
||||
onModeChange,
|
||||
}: {
|
||||
values: string[];
|
||||
titles: Record<string, string> | undefined;
|
||||
mode: "any" | "all";
|
||||
onChange: (values: string[], titles: Record<string, string> | undefined) => void;
|
||||
onModeChange: (mode: "any" | "all") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
enabled: open, // only fetch while the picker is open
|
||||
});
|
||||
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
|
||||
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
|
||||
// selected ids (see usePlexCollectionTitles).
|
||||
const { titles: resolved, pending } = usePlexCollectionTitles(values, titles);
|
||||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.title,
|
||||
count: c.child_count ?? undefined,
|
||||
}));
|
||||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||||
// selected collection's title (the fetched list is narrowed by the current search).
|
||||
const labelMap: Record<string, string> = { ...resolved };
|
||||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MatchToggle picked={values.length} value={mode} onChange={onModeChange} />
|
||||
<Combobox
|
||||
label={t("plex.filter.collection")}
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={values}
|
||||
selectedLabels={resolved}
|
||||
labelsPending={pending}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
error={q.isError ? t("common.loadError") : undefined}
|
||||
onRetry={() => void q.refetch()}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
onChange={(next) => {
|
||||
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
|
||||
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
|
||||
// differently from an absent key), mirroring the chip-row remove handler.
|
||||
const nextTitles: Record<string, string> = {};
|
||||
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
|
||||
onChange(next, next.length ? nextTitles : undefined);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DismissibleChip
|
||||
onDismiss={onRemove}
|
||||
dismissLabel={t("common.removeNamed", { name: label })}
|
||||
className="max-w-40"
|
||||
>
|
||||
{label}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
placeholder?: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
className="w-20 tabular-nums"
|
||||
size="sm"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder != null ? String(placeholder) : ""}
|
||||
onChange={(e) => {
|
||||
const n = e.target.value === "" ? null : Number(e.target.value);
|
||||
onChange(n != null && Number.isFinite(n) ? n : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import type { HlsEngine } from "../../lib/hlsEngine";
|
||||
import { buildStatRows, type FragMeter, type StatRow } from "../../lib/plexStats";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
|
||||
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
// offset. No data layer — the refs are already in hand. Doubles as a verification surface (it shows
|
||||
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
|
||||
/** Dropped / total frames so far. Outside the component: the React Compiler cannot lower an
|
||||
* optional call inside a try yet, and skipped the whole overlay for it. */
|
||||
function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: number } {
|
||||
try {
|
||||
const q = v?.getVideoPlaybackQuality?.();
|
||||
if (q) return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames };
|
||||
} catch {
|
||||
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
||||
}
|
||||
return { dropped: 0, total: 0 };
|
||||
}
|
||||
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
engine,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
absRef,
|
||||
durationRef,
|
||||
onClose,
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
|
||||
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
|
||||
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
|
||||
// long as this panel) — the source of the MEASURED bitrate row.
|
||||
fragMeterRef: RefObject<FragMeter>;
|
||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||
playable: string;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [rows, setRows] = useState<StatRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sample = () => {
|
||||
const v = videoRef.current;
|
||||
|
||||
const { dropped, total } = frameCounts(v);
|
||||
|
||||
let bufferAhead = 0;
|
||||
if (v) {
|
||||
const now = v.currentTime;
|
||||
for (let i = 0; i < v.buffered.length; i++) {
|
||||
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
|
||||
bufferAhead = v.buffered.end(i) - now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const level = engine.levelInfo();
|
||||
|
||||
setRows(
|
||||
buildStatRows({
|
||||
lang,
|
||||
playable,
|
||||
hwEncoder: engine.hw,
|
||||
tonemap: engine.tonemap,
|
||||
transport: level ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
droppedFrames: dropped,
|
||||
totalFrames: total,
|
||||
bufferAheadS: bufferAhead,
|
||||
hlsLevel: level,
|
||||
measuredBps: fragMeterRef.current?.bitrate(),
|
||||
audioLabel,
|
||||
subLabel,
|
||||
sessionStartS: engine.sessionStart.current,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.stats.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
|
||||
{t("plex.player.stats.title")}
|
||||
</span>
|
||||
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
|
||||
reader announce the changing values continuously and drown out real player announcements.
|
||||
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-white/55">{t(r.labelKey)}</dt>
|
||||
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player draws its subtitles itself. With the browser drawing them, the control bar covered
|
||||
// the text on an iPad whenever it was up: a native cue is placed by `line`, which nothing animates.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import PlexSubtitles from "./PlexSubtitles";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type FakeTrack = EventTarget & { kind: string; mode: TextTrackMode; activeCues: unknown[] };
|
||||
|
||||
/** A <video> 1000 px tall with one hidden track of the player's own <track>, whose active cues the
|
||||
* test sets; `addTrack` adds another as a browser would — the player's own, or one of the media's. */
|
||||
function fakeVideo() {
|
||||
const video = document.createElement("video");
|
||||
const list: FakeTrack[] = [];
|
||||
const tracks = Object.assign(new EventTarget(), {
|
||||
get length() {
|
||||
return list.length;
|
||||
},
|
||||
[Symbol.iterator]: () => list[Symbol.iterator](),
|
||||
});
|
||||
Object.defineProperty(video, "textTracks", { value: tracks });
|
||||
Object.defineProperty(video, "clientHeight", { value: 1000 });
|
||||
const make = (own: boolean) => {
|
||||
const track: FakeTrack = Object.assign(new EventTarget(), {
|
||||
kind: "subtitles",
|
||||
mode: "hidden" as TextTrackMode,
|
||||
activeCues: [] as unknown[],
|
||||
});
|
||||
if (own) {
|
||||
const el = document.createElement("track");
|
||||
Object.defineProperty(el, "track", { value: track });
|
||||
video.appendChild(el);
|
||||
}
|
||||
list.push(track);
|
||||
return track;
|
||||
};
|
||||
const cuesOn = (track: FakeTrack, texts: string[]) =>
|
||||
act(() => {
|
||||
track.activeCues = texts.map((text, i) => ({ startTime: i, text }));
|
||||
track.dispatchEvent(new Event("cuechange"));
|
||||
});
|
||||
const track = make(true);
|
||||
const show = (...texts: string[]) => cuesOn(track, texts);
|
||||
const addTrack = (own: boolean) => {
|
||||
const added = make(own);
|
||||
act(() => {
|
||||
tracks.dispatchEvent(new Event("addtrack"));
|
||||
});
|
||||
return (...texts: string[]) => cuesOn(added, texts);
|
||||
};
|
||||
return { video, show, addTrack };
|
||||
}
|
||||
|
||||
/** Lay the boxes out: the text's box ends at `textBottom`, the bar starts at `barTop`. With a `scale`,
|
||||
* the player is drawn that much larger than its own pixels, as the windowed player is. */
|
||||
function layout(
|
||||
video: HTMLVideoElement,
|
||||
bar: HTMLElement,
|
||||
textBottom: number,
|
||||
barTop: number,
|
||||
scale = 1,
|
||||
) {
|
||||
const rect = (top: number, bottom: number) =>
|
||||
({ top, bottom, left: 0, right: 0, width: 0, height: bottom - top }) as DOMRect;
|
||||
const isBox = (el: Element) => (el as HTMLElement).dataset?.testid === "plex-subtitles";
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||||
if (this === video) return rect(0, 1000);
|
||||
if (this === bar) return rect(barTop, 1000);
|
||||
// The outer box: the lift moves only the text inside it.
|
||||
if (isBox(this)) return rect(textBottom - 80, textBottom);
|
||||
return rect(0, 0);
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return isBox(this) ? 80 / scale : 0;
|
||||
});
|
||||
}
|
||||
|
||||
const lifted = () => screen.getByTestId("plex-subtitles-text");
|
||||
|
||||
function mount(video: HTMLVideoElement, avoid: boolean) {
|
||||
const bar = document.createElement("div");
|
||||
const barRef = createRef<HTMLElement>() as { current: HTMLElement | null };
|
||||
barRef.current = bar;
|
||||
const props = {
|
||||
video,
|
||||
pos: 88,
|
||||
size: 100,
|
||||
color: "#ffffff",
|
||||
background: "transparent",
|
||||
shadowPx: 0,
|
||||
shadowColor: "#000000",
|
||||
avoidRef: barRef,
|
||||
};
|
||||
const view = render(<PlexSubtitles {...props} avoid={avoid} />);
|
||||
return { bar, rerender: (a: boolean) => view.rerender(<PlexSubtitles {...props} avoid={a} />) };
|
||||
}
|
||||
|
||||
describe("PlexSubtitles", () => {
|
||||
it("shows the cues the browser reports active, at the cue type size of the video box", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
mount(video, false);
|
||||
show("Half full", "or half empty?");
|
||||
const box = screen.getByTestId("plex-subtitles");
|
||||
expect(box.textContent).toBe("Half fullor half empty?");
|
||||
expect(box.style.fontSize).toBe("50px");
|
||||
expect(box.style.top).toBe("88%");
|
||||
});
|
||||
|
||||
it("rises above the control bar while it shows, and settles back when it hides", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar, rerender } = mount(video, false);
|
||||
layout(video, bar, 950, 900);
|
||||
show("Half full");
|
||||
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||
rerender(true);
|
||||
// 950 − (900 − 8): the text's bottom clears the bar's top by the 8 px of air.
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
rerender(false);
|
||||
expect(lifted().style.transform).toBe("translateY(0px)");
|
||||
});
|
||||
|
||||
it("lifts in the player's own pixels when the player is drawn scaled", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar } = mount(video, true);
|
||||
layout(video, bar, 950, 900, 1.25);
|
||||
show("Half full");
|
||||
// 58 screen pixels are 46.4 of the player's own, which the browser then draws ×1.25.
|
||||
expect(lifted().style.transform).toBe("translateY(-46px)");
|
||||
});
|
||||
|
||||
it("places a cue that just appeared, and slides only one already on screen", () => {
|
||||
const { video, show } = fakeVideo();
|
||||
const { bar, rerender } = mount(video, true);
|
||||
layout(video, bar, 950, 900);
|
||||
show("Half full");
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
expect(lifted().className).not.toContain("transition-transform");
|
||||
rerender(false);
|
||||
expect(lifted().className).toContain("transition-transform");
|
||||
// The bar comes back while no cue is up: the next one is placed above it, not slid there.
|
||||
show();
|
||||
rerender(true);
|
||||
show("or half empty?");
|
||||
expect(lifted().style.transform).toBe("translateY(-58px)");
|
||||
expect(lifted().className).not.toContain("transition-transform");
|
||||
});
|
||||
|
||||
it("draws the cues of a track the player mounts later — a remounted <track> is a new one", () => {
|
||||
const { video, addTrack } = fakeVideo();
|
||||
mount(video, false);
|
||||
const showOnNew = addTrack(true);
|
||||
showOnNew("After the seek");
|
||||
expect(screen.getByTestId("plex-subtitles").textContent).toBe("After the seek");
|
||||
});
|
||||
|
||||
it("never draws a track that is not the player's own, such as a direct-played file's", () => {
|
||||
const { video, addTrack } = fakeVideo();
|
||||
mount(video, false);
|
||||
const showInBand = addTrack(false);
|
||||
showInBand("Embedded");
|
||||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing while no cue is active", () => {
|
||||
const { video } = fakeVideo();
|
||||
mount(video, true);
|
||||
expect(screen.queryByTestId("plex-subtitles")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { subShadow } from "../../lib/plexPlayerPrefs";
|
||||
import { ownTextTracks } from "../../lib/plexSubTracks";
|
||||
|
||||
// The Plex player's subtitles, drawn by us instead of the browser. The track stays mounted (hls.js and
|
||||
// the VTT fetch need it) but `hidden`: the browser parses the cues and reports the active ones, and
|
||||
// this layer shows them. The browser's own rendering could not move out of the way of the control
|
||||
// bar — a cue is placed by `line`, which neither WebKit nor Chromium animates, and on an iPad the bar
|
||||
// covered the text whenever it was up. Here the text rises above the bar while it shows and slides
|
||||
// back when it hides, the same on every engine, and the viewer's size / colour / background / outline
|
||||
// settings style it directly.
|
||||
|
||||
type Cue = TextTrackCue & { text?: string; getCueAsHTML?: () => DocumentFragment };
|
||||
|
||||
// A cue's default type size, as both engines draw it: 5 % of the video box's height.
|
||||
const CUE_EM_OF_HEIGHT = 0.05;
|
||||
// The air left between the text and what it avoids (the bar's top, or the video's bottom edge).
|
||||
const CLEARANCE_PX = 8;
|
||||
|
||||
/** The cues showing now, from the player's own track(s) of `video` that are not switched off. */
|
||||
function useActiveCues(video: HTMLVideoElement | null): Cue[] {
|
||||
const [cues, setCues] = useState<Cue[]>([]);
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
const tracks = video.textTracks;
|
||||
const read = () => {
|
||||
const now: Cue[] = [];
|
||||
const own = ownTextTracks(video);
|
||||
for (const tr of Array.from(tracks)) {
|
||||
if (!own.has(tr) || tr.mode === "disabled") continue;
|
||||
for (const c of Array.from(tr.activeCues ?? [])) now.push(c as Cue);
|
||||
}
|
||||
setCues((prev) =>
|
||||
prev.length === now.length && prev.every((c, i) => c === now[i]) ? prev : now,
|
||||
);
|
||||
};
|
||||
const bound = new Set<TextTrack>();
|
||||
const bind = (tr: TextTrack) => {
|
||||
if (bound.has(tr)) return;
|
||||
bound.add(tr);
|
||||
tr.addEventListener("cuechange", read);
|
||||
};
|
||||
const onAdd = () => {
|
||||
for (const tr of Array.from(tracks)) bind(tr);
|
||||
read();
|
||||
};
|
||||
for (const tr of Array.from(tracks)) bind(tr);
|
||||
tracks.addEventListener("addtrack", onAdd);
|
||||
tracks.addEventListener("removetrack", read);
|
||||
read();
|
||||
return () => {
|
||||
tracks.removeEventListener("addtrack", onAdd);
|
||||
tracks.removeEventListener("removetrack", read);
|
||||
for (const tr of bound) tr.removeEventListener("cuechange", read);
|
||||
};
|
||||
}, [video]);
|
||||
return cues;
|
||||
}
|
||||
|
||||
/** The video box's height, followed as the window or fullscreen changes it. */
|
||||
function useHeight(el: HTMLElement | null): number {
|
||||
const [height, setHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const measure = () => setHeight(el.clientHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [el]);
|
||||
return height;
|
||||
}
|
||||
|
||||
/** One cue's text. `getCueAsHTML` keeps its <i>/<b>; it builds DOM nodes from the parsed cue, never
|
||||
* markup from a string, so nothing in a subtitle file can run. */
|
||||
function CueText({ cue, style }: { cue: Cue; style: React.CSSProperties }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (typeof cue.getCueAsHTML === "function") el.replaceChildren(cue.getCueAsHTML());
|
||||
else el.textContent = cue.text ?? "";
|
||||
}, [cue]);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="whitespace-pre-line px-[0.25em] leading-[1.3] box-decoration-clone"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlexSubtitles({
|
||||
video,
|
||||
pos,
|
||||
size,
|
||||
color,
|
||||
background,
|
||||
shadowPx,
|
||||
shadowColor,
|
||||
avoid,
|
||||
avoidRef,
|
||||
}: {
|
||||
video: HTMLVideoElement | null;
|
||||
/** Where the text's top sits, in % of the video box's height (the setting's "cue line"). */
|
||||
pos: number;
|
||||
size: number;
|
||||
color: string;
|
||||
background: string;
|
||||
shadowPx: number;
|
||||
shadowColor: string;
|
||||
/** True while the control bar shows: the text then keeps above `avoidRef`'s top. */
|
||||
avoid: boolean;
|
||||
avoidRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const cues = useActiveCues(video);
|
||||
const height = useHeight(video);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// `slide` is false for the first placement of a box that just appeared: it is put where it belongs,
|
||||
// not slid there from where the previous cue was left.
|
||||
const [lift, setLift] = useState({ px: 0, slide: false });
|
||||
const shownRef = useRef(false);
|
||||
|
||||
// How far up the text has to move to clear the bar (or, without it, the video's bottom edge). The
|
||||
// outer box is never transformed — the lift moves the inner one — so its rect is where the text sits
|
||||
// with no lift, even mid-slide. The rects are screen pixels and the windowed player is scaled
|
||||
// (PlexPlayer's wrapper), while the lift applies in the box's own pixels: the distance is divided
|
||||
// by that scale.
|
||||
useLayoutEffect(() => {
|
||||
const box = boxRef.current;
|
||||
if (!box || !video || cues.length === 0) {
|
||||
shownRef.current = false;
|
||||
return;
|
||||
}
|
||||
const rect = box.getBoundingClientRect();
|
||||
const scale = box.offsetHeight > 0 ? rect.height / box.offsetHeight : 1;
|
||||
const bar = avoid ? avoidRef.current?.getBoundingClientRect().top : undefined;
|
||||
const limit = (bar ?? video.getBoundingClientRect().bottom) - CLEARANCE_PX;
|
||||
const px = Math.max(0, Math.round((rect.bottom - limit) / scale));
|
||||
setLift({ px, slide: shownRef.current });
|
||||
shownRef.current = true;
|
||||
}, [avoid, avoidRef, cues, height, pos, size, video]);
|
||||
|
||||
if (cues.length === 0 || height === 0) return null;
|
||||
const text: React.CSSProperties = {
|
||||
color,
|
||||
backgroundColor: background,
|
||||
textShadow: subShadow(shadowPx, shadowColor),
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
data-testid="plex-subtitles"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 px-[5%] text-center"
|
||||
style={{ top: `${pos}%`, fontSize: `${(height * CUE_EM_OF_HEIGHT * size) / 100}px` }}
|
||||
>
|
||||
<div
|
||||
data-testid="plex-subtitles-text"
|
||||
className={`flex flex-col items-center gap-[0.15em] ${lift.slide ? "transition-transform duration-200 ease-out motion-reduce:transition-none" : ""}`}
|
||||
style={{ transform: `translateY(${-lift.px}px)` }}
|
||||
>
|
||||
{cues.map((cue, i) => (
|
||||
<CueText key={`${cue.startTime}-${i}`} cue={cue} style={text} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
PlexViewPublishContext,
|
||||
PlexViewReadContext,
|
||||
type PlexViewControls,
|
||||
} from "./plexViewContext";
|
||||
|
||||
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
|
||||
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
|
||||
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
|
||||
// Header READS it to render the toggle beside the library-scope pill.
|
||||
//
|
||||
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
|
||||
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
|
||||
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
|
||||
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
|
||||
// reseed; the bridge only mirrors the small slice the header needs.
|
||||
//
|
||||
// The contexts + hooks live in plexViewContext.ts.
|
||||
|
||||
export function PlexViewBridge({ children }: { children: ReactNode }) {
|
||||
const [controls, setControls] = useState<PlexViewControls | null>(null);
|
||||
return (
|
||||
<PlexViewPublishContext.Provider value={setControls}>
|
||||
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
|
||||
</PlexViewPublishContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none,
|
||||
// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards
|
||||
// against). Also: it only renders on the two title-browse views, marks the active one, and drives
|
||||
// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./plexViewContext", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
controls.kind = "discover";
|
||||
controls.open.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlexViewToggle", () => {
|
||||
it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => {
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
// Without this class the whole toggle is click-dead inside the pointer-events-none header.
|
||||
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the two options with the current view pressed", () => {
|
||||
render(<PlexViewToggle />);
|
||||
const btns = [
|
||||
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
|
||||
];
|
||||
expect(btns.map((b) => b.textContent)).toEqual(["plex.discover.tab", "plex.view.browse"]);
|
||||
const pressed = btns
|
||||
.filter((b) => b.getAttribute("aria-pressed") === "true")
|
||||
.map((b) => b.textContent);
|
||||
expect(pressed).toEqual(["plex.discover.tab"]);
|
||||
});
|
||||
|
||||
it("opens the grid view when Browse is picked", () => {
|
||||
render(<PlexViewToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
|
||||
it("opens the discover view when Discover is picked from the grid", () => {
|
||||
controls.kind = "grid";
|
||||
render(<PlexViewToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
|
||||
});
|
||||
|
||||
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
|
||||
controls.kind = "playlists";
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { usePlexViewControls } from "./plexViewContext";
|
||||
|
||||
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
|
||||
// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on
|
||||
// the deeper views (a show/info/person page has no Discover/Browse choice).
|
||||
export default function PlexViewToggle() {
|
||||
const { t } = useTranslation();
|
||||
const ctl = usePlexViewControls();
|
||||
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
||||
// and the playlists/collections browse surfaces hide it.
|
||||
if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null;
|
||||
// The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the
|
||||
// content below); every interactive child must re-enable pointer events or it is click-dead — the
|
||||
// sibling PlexScopeToggle does the same.
|
||||
return (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
className="pointer-events-auto shrink-0"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={ctl.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../../lib/hueTile";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { MEDIA_IMG } from "../../components/ui/mediaCardStyles";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
||||
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
||||
//
|
||||
// Hover = the media card's: the shadow lifts and the art zooms inside its half, plus the
|
||||
// accent edge glass-hover gives a pressable panel. It had `glass-hover` on a tile with no border, so
|
||||
// the only visible change was a shadow under a colour band — the owner read it as no hover at all.
|
||||
export default function PosterTile({
|
||||
title,
|
||||
subtitle,
|
||||
topRight,
|
||||
art,
|
||||
onClick,
|
||||
className,
|
||||
navIndex,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
||||
topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon)
|
||||
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
||||
onClick: () => void;
|
||||
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
||||
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
|
||||
}) {
|
||||
const posters = (art ?? []).slice(0, 2);
|
||||
const band = hueTileGradient(hueOf(title));
|
||||
return (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={`group relative flex flex-col overflow-hidden rounded-xl border border-border shadow-md transition hover:border-accent/55 hover:shadow-2xl ${className ?? ""}`}
|
||||
>
|
||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||
<div
|
||||
className="grid h-1/2 min-h-0 grid-cols-2 overflow-hidden"
|
||||
aria-hidden
|
||||
style={posters.length === 0 ? { background: band } : undefined}
|
||||
>
|
||||
{posters.length > 0 &&
|
||||
[0, 1].map((i) =>
|
||||
posters[i] ? (
|
||||
<RemoteImage
|
||||
key={i}
|
||||
{...cardImage(`/api/plex/image/${posters[i]}`)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={MEDIA_IMG}
|
||||
/>
|
||||
) : (
|
||||
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{/* Bottom half: solid colour band — the label lives here so it stays readable over any art. */}
|
||||
<div className="relative flex flex-1 flex-col justify-end p-2.5" style={{ background: band }}>
|
||||
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
||||
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
||||
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
||||
</div>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The up-next end-card's core behaviour: the countdown that auto-advances at ~10s (the whole
|
||||
// point — it REPLACED the old instant jump, so a regression that fires early or never is exactly the
|
||||
// bug to guard), the "Play now"/Cancel controls, and the episode heading's null season/episode
|
||||
// fallback. i18n is key-passthrough; `../../lib/api` is mocked so no network runs; the countdown uses
|
||||
// performance.now, which vitest's fake timers advance alongside setInterval.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { plexItem: vi.fn(), plexImageUrl: () => "/img" },
|
||||
}));
|
||||
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderCard(opts: { onAdvance?: () => void; onCancel?: () => void } = {}) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UpNextCard
|
||||
nextId="e7"
|
||||
onAdvance={opts.onAdvance ?? vi.fn()}
|
||||
onCancel={opts.onCancel ?? vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UpNextCard", () => {
|
||||
it("auto-advances after the ~10s countdown, and NOT before", () => {
|
||||
vi.useFakeTimers();
|
||||
// A never-resolving detail query: the heading is irrelevant here and this avoids async state
|
||||
// updates racing the fake clock.
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
renderCard({ onAdvance });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(9000);
|
||||
});
|
||||
expect(onAdvance).not.toHaveBeenCalled(); // still counting down
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onAdvance).toHaveBeenCalledTimes(1); // crossed zero → advanced exactly once
|
||||
});
|
||||
|
||||
it("'Play now' advances immediately, without waiting for the countdown", () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
renderCard({ onAdvance });
|
||||
fireEvent.click(screen.getByRole("button", { name: /playNow/ }));
|
||||
expect(onAdvance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Cancel dismisses via onCancel and does not advance", () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||
const onAdvance = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
renderCard({ onAdvance, onCancel });
|
||||
fireEvent.click(screen.getByText("plex.player.upNext.cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onAdvance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An episode Plex has no number for (a special) is named by its title alone everywhere — a "?"
|
||||
// told the viewer nothing, and the player's own header already shows the title alone.
|
||||
it("an episode heading drops the numbers when Plex has none for it", async () => {
|
||||
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "e7",
|
||||
kind: "episode",
|
||||
show_title: "Westworld",
|
||||
title: "The Adversary",
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
});
|
||||
renderCard();
|
||||
expect(await screen.findByText("Westworld")).toBeTruthy();
|
||||
expect(screen.getByText("The Adversary")).toBeTruthy();
|
||||
expect(screen.queryByText(/\?/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { cardImage } from "../../lib/plexImage";
|
||||
import { episodeNumbers } from "../../lib/plexDetail";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// Up-next / end-card. Shown at episode end when a next item exists and the pref is on, INSTEAD
|
||||
// of the old instant jump — so binge stays under the viewer's control. Counts down ~10s then
|
||||
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
|
||||
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
|
||||
const COUNTDOWN_S = 10;
|
||||
|
||||
export function UpNextCard({
|
||||
nextId,
|
||||
onAdvance,
|
||||
onCancel,
|
||||
}: {
|
||||
nextId: string;
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
const nextQ = useQuery({
|
||||
queryKey: qk.plexItem(nextId, lang),
|
||||
queryFn: () => api.plexItem(nextId, lang),
|
||||
});
|
||||
const next = nextQ.data;
|
||||
|
||||
// Countdown. `onAdvance` is an effect event so the 100ms interval never restarts on re-render.
|
||||
const [remaining, setRemaining] = useState(COUNTDOWN_S);
|
||||
const advance = useEffectEvent(onAdvance);
|
||||
useEffect(() => {
|
||||
const started = performance.now();
|
||||
const iv = window.setInterval(() => {
|
||||
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
|
||||
if (left <= 0) {
|
||||
window.clearInterval(iv);
|
||||
setRemaining(0);
|
||||
advance();
|
||||
} else {
|
||||
setRemaining(left);
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const secs = Math.ceil(remaining);
|
||||
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
|
||||
const R = 10;
|
||||
const C = 2 * Math.PI * R;
|
||||
|
||||
// Title: "Show · S2·E5" over the episode title for episodes (the show alone when Plex has no
|
||||
// number for the episode); the plain title for movies/queue items.
|
||||
const nums = next ? episodeNumbers(next) : null;
|
||||
const heading =
|
||||
next?.kind === "episode" && next.show_title
|
||||
? nums
|
||||
? `${next.show_title} · S${nums.season}·E${nums.episode}`
|
||||
: next.show_title
|
||||
: (next?.title ?? "");
|
||||
const sub = next?.kind === "episode" ? next.title : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("plex.player.upNext.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 media-marks p-3.5 text-white shadow-2xl"
|
||||
>
|
||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||
{next && (
|
||||
<RemoteImage
|
||||
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
|
||||
{t("plex.player.upNext.title")}
|
||||
</div>
|
||||
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
|
||||
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
|
||||
<div className="mt-auto flex items-center gap-2 pt-2.5">
|
||||
<Button size="sm" round onClick={onAdvance} className="text-xs font-semibold">
|
||||
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
|
||||
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.25)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={C * (1 - frac)}
|
||||
/>
|
||||
</svg>
|
||||
<Play className="h-2.5 w-2.5 fill-black" />
|
||||
</span>
|
||||
{t("plex.player.upNext.playNow")}
|
||||
<span className="tabular-nums">{secs}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
round
|
||||
onClick={onCancel}
|
||||
className="border border-white/25 text-xs"
|
||||
>
|
||||
{t("plex.player.upNext.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={onCancel}
|
||||
label={t("plex.player.upNext.cancel")}
|
||||
className="absolute right-1 top-1"
|
||||
>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../../lib/api";
|
||||
import { formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string {
|
||||
const parts: string[] = [];
|
||||
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
|
||||
const rt = formatRuntime(c.total_runtime_s, lang);
|
||||
if (rt) parts.push(rt);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
|
||||
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
|
||||
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
|
||||
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
|
||||
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
|
||||
// take the colour fallback.
|
||||
|
||||
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
||||
|
||||
// A grid of the user's Plex playlists; a tile opens that playlist's view.
|
||||
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = q.data?.playlists ?? [];
|
||||
|
||||
if (q.isError && !q.data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading) return <LoadingState shape="poster" label={t("plex.loading")} />;
|
||||
if (playlists.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
return (
|
||||
<NavGrid className={GRID}>
|
||||
{playlists.map((p, i) => (
|
||||
<PosterTile
|
||||
key={p.id}
|
||||
navIndex={i}
|
||||
title={p.title}
|
||||
subtitle={t("plex.browse.items", { count: p.item_count })}
|
||||
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
|
||||
onClick={() => onOpen(p.id)}
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one —
|
||||
// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an
|
||||
// empty collection. Creating opens the new collection's detail page (onCreated → onPick).
|
||||
function NewCollectionPanel({
|
||||
existingNames,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
existingNames: Set<string>;
|
||||
onCreated: (rk: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [term, setTerm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const trimmed = name.trim();
|
||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
|
||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||
const nameOk = trimmed.length > 0;
|
||||
|
||||
async function create(card: PlexCard) {
|
||||
if (!nameOk || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — a taken name comes back
|
||||
// structured, and so localized (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${SURFACE.panel} mb-6 p-4`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{t("plex.collection.newCollectionTitle")}</h3>
|
||||
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("plex.collection.namePlaceholder")}
|
||||
aria-label={t("plex.collection.newCollection")}
|
||||
/>
|
||||
{dupHint ? (
|
||||
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
|
||||
) : (
|
||||
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
|
||||
)}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
disabled={!nameOk}
|
||||
placeholder={t("plex.collection.addSearch")}
|
||||
aria-label={t("plex.collection.addSearch")}
|
||||
/>
|
||||
</div>
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term={term}
|
||||
enabled={nameOk}
|
||||
renderAction={(c) => (
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<Check />}
|
||||
onClick={() => create(c)}
|
||||
loading={busy}
|
||||
>
|
||||
{t("plex.collection.createWith")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
|
||||
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
});
|
||||
const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]);
|
||||
const existingNames = useMemo(
|
||||
() => new Set(collections.map((c) => c.title.trim().toLowerCase())),
|
||||
[collections],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="w-full max-w-sm"
|
||||
size="sm"
|
||||
pill
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
aria-label={t("plex.filter.collectionSearch")}
|
||||
/>
|
||||
{isAdmin && !creating && (
|
||||
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
|
||||
{t("plex.collection.newCollection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{creating && (
|
||||
<NewCollectionPanel
|
||||
existingNames={existingNames}
|
||||
onCreated={(rk) => {
|
||||
setCreating(false);
|
||||
onPick(rk, "");
|
||||
}}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState shape="poster" label={t("plex.loading")} />
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<NavGrid className={GRID}>
|
||||
{collections.map((c, i) => (
|
||||
<PosterTile
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
title={c.title}
|
||||
art={c.art}
|
||||
subtitle={collectionMeta(c, t, i18n.language)}
|
||||
topRight={
|
||||
isIntrinsicReadOnly(c) ? (
|
||||
<Lock
|
||||
className="h-3.5 w-3.5 text-white/80 drop-shadow"
|
||||
aria-label={t("plex.collection.readOnly")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onPick(c.id, c.title)}
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../../lib/api";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
import type { PlexSearchField } from "../../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../../lib/plexSort";
|
||||
|
||||
// The Plex module's contexts + hooks, split out of PlexProvider so that file exports only components
|
||||
// (Fast Refresh).
|
||||
|
||||
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
|
||||
// a show's drill-down (like clicking a show card), or — for the Home hero, whose whole promise is a
|
||||
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
|
||||
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
|
||||
// landing page to avoid one hand-off would be the expensive way round.
|
||||
export type PlexJumpTarget = {
|
||||
id: string;
|
||||
type: "movie" | "show" | "play";
|
||||
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
|
||||
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
|
||||
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
|
||||
* actually came from rather than to a module root they never visited. Omitted for a jump made
|
||||
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
|
||||
* sub-view the user really was looking at. */
|
||||
replace?: boolean;
|
||||
/** `play` only: from the start, not the resume point (the card's Restart, C5). */
|
||||
fromStart?: boolean;
|
||||
};
|
||||
|
||||
export type PlexState = {
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
show: string;
|
||||
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||
filters: PlexFilters;
|
||||
q: string;
|
||||
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
|
||||
playlistOpen: number | null;
|
||||
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
|
||||
};
|
||||
|
||||
export interface PlexActions {
|
||||
setScope: (v: string) => void;
|
||||
setShow: (v: string) => void;
|
||||
setSort: (v: SortState) => void;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
setQ: (q: string) => void;
|
||||
setField: (f: PlexSearchField) => void;
|
||||
setPlaylistOpen: (id: number | null) => void;
|
||||
setJumpTo: (t: PlexJumpTarget | null) => void;
|
||||
}
|
||||
|
||||
export const PlexStateContext = createContext<PlexState>({
|
||||
scope: "both",
|
||||
show: "all",
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
q: "",
|
||||
field: "all",
|
||||
playlistOpen: null,
|
||||
jumpTo: null,
|
||||
});
|
||||
export const PlexActionsContext = createContext<PlexActions>({
|
||||
setScope: () => {},
|
||||
setShow: () => {},
|
||||
setSort: () => {},
|
||||
setFilters: () => {},
|
||||
setQ: () => {},
|
||||
setField: () => {},
|
||||
setPlaylistOpen: () => {},
|
||||
setJumpTo: () => {},
|
||||
});
|
||||
|
||||
export function usePlex(): PlexState {
|
||||
return useContext(PlexStateContext);
|
||||
}
|
||||
export function usePlexActions(): PlexActions {
|
||||
return useContext(PlexActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlexFilters } from "../../lib/api";
|
||||
import { formatMoney, formatRuntime } from "../../lib/format";
|
||||
import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail";
|
||||
import { regionName, vocab } from "../../lib/vocab";
|
||||
import { Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
|
||||
// The detail pages' second column (the owner's pick "A"): the TMDB facts and key crew of a movie or
|
||||
// a show beside its description, inside the hero — as the person page sets its known-for rail beside
|
||||
// the bio — and its keywords under the text. They used to sit under the hero (movie) or
|
||||
// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A
|
||||
// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same
|
||||
// three prefs on both pages (lib/plexDetail useDetailAside).
|
||||
|
||||
type OnFilter = (patch: Partial<PlexFilters>) => void;
|
||||
|
||||
// The customize menu's rows: one per section that has something (no toggle for an empty one).
|
||||
export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) {
|
||||
const { t } = useTranslation();
|
||||
const label: Record<AsideSection, string> = {
|
||||
facts: t("plex.info.prefFacts"),
|
||||
keywords: t("plex.info.prefKeywords"),
|
||||
crew: t("plex.info.prefCrew"),
|
||||
};
|
||||
return (["facts", "keywords", "crew"] as const)
|
||||
.filter((k) => aside.has[k])
|
||||
.map((k) => (
|
||||
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => aside.toggle(k)} />
|
||||
));
|
||||
}
|
||||
|
||||
// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is
|
||||
// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead
|
||||
// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12
|
||||
// crew, a hole under the text — the measured worst case.
|
||||
export function DetailAside({
|
||||
facts,
|
||||
shown,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
facts: DetailFacts;
|
||||
shown: DetailAsideState["shown"];
|
||||
onFilter?: OnFilter;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
if (!shown.facts && !shown.crew) return null;
|
||||
return (
|
||||
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
|
||||
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
|
||||
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The structured facts: release / runtime / cert / budget / revenue / studios / countries, from
|
||||
// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain
|
||||
// fact stays flat text (edged = pressable, flat = just says).
|
||||
function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tmdb = facts.tmdb;
|
||||
const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : [];
|
||||
const rows: { key: string; label: string; node: ReactNode }[] = [];
|
||||
// The same values filter here as in the hero's meta line (year, certificate, studios).
|
||||
const year = facts.year;
|
||||
if (year != null)
|
||||
rows.push({
|
||||
key: "year",
|
||||
label: t("plex.info.facts.release"),
|
||||
node: (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||
>
|
||||
{year}
|
||||
</Filterable>
|
||||
),
|
||||
});
|
||||
if (facts.duration_seconds)
|
||||
rows.push({
|
||||
key: "runtime",
|
||||
label: t("plex.info.facts.runtime"),
|
||||
node: formatRuntime(facts.duration_seconds, lang),
|
||||
});
|
||||
const cert = facts.content_rating;
|
||||
if (cert)
|
||||
rows.push({
|
||||
key: "cert",
|
||||
label: t("plex.info.facts.cert"),
|
||||
node: (
|
||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||
{vocab(t, "certificate", cert)}
|
||||
</Filterable>
|
||||
),
|
||||
});
|
||||
if (tmdb?.budget)
|
||||
rows.push({
|
||||
key: "budget",
|
||||
label: t("plex.info.facts.budget"),
|
||||
node: formatMoney(tmdb.budget, lang),
|
||||
});
|
||||
if (tmdb?.revenue)
|
||||
rows.push({
|
||||
key: "revenue",
|
||||
label: t("plex.info.facts.revenue"),
|
||||
node: formatMoney(tmdb.revenue, lang),
|
||||
});
|
||||
if (studios.length)
|
||||
rows.push({
|
||||
key: "studios",
|
||||
label: t("plex.info.facts.studios"),
|
||||
node: (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{studios.map((s) => (
|
||||
<Filterable
|
||||
key={s}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||
>
|
||||
{s}
|
||||
</Filterable>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
if (tmdb?.production_countries?.length)
|
||||
rows.push({
|
||||
key: "countries",
|
||||
label: t("plex.info.facts.countries"),
|
||||
node: tmdb.production_countries
|
||||
.map((c) => regionName(t, c.code, i18n.language, c.name))
|
||||
.join(", "),
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-muted">{r.label}</dt>
|
||||
<dd className="min-w-0 font-medium">{r.node}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// TMDB keyword chips — tap one to filter the library by that keyword. Under the hero's text.
|
||||
export function DetailKeywords({
|
||||
keywords,
|
||||
onFilter,
|
||||
className,
|
||||
}: {
|
||||
keywords: string[];
|
||||
onFilter?: OnFilter;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section data-testid="detail-keywords" className={className}>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((k) => (
|
||||
<Filterable
|
||||
key={k}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
||||
>
|
||||
{k}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
|
||||
// text is the hero's tallest part, and twelve names ran it ~170px past the text (the owner's call).
|
||||
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
|
||||
export const CREW_SHOWN = 6;
|
||||
const CREW_FOLD_OVER = CREW_SHOWN + 2;
|
||||
|
||||
// The TMDB key crew — Director / Writer / Producer / …, each opening their person page. It
|
||||
// complements the hero's Director line (which comes from Plex directly).
|
||||
function CrewSection({
|
||||
crew,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
crew: { name: string; job: string }[];
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [all, setAll] = useState(false);
|
||||
const folds = crew.length > CREW_FOLD_OVER;
|
||||
const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
|
||||
{shown.map((c, i) => (
|
||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||
{onOpenPerson ? (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => onOpenPerson(c.name)}
|
||||
title={t("plex.info.viewPerson", { name: c.name })}
|
||||
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
|
||||
>
|
||||
{c.name}
|
||||
</Pressable>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{c.name}</div>
|
||||
)}
|
||||
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{folds && (
|
||||
<Button
|
||||
variant={all ? "link-muted" : "link"}
|
||||
aria-expanded={all}
|
||||
onClick={() => setAll((v) => !v)}
|
||||
className="mt-2 text-sm"
|
||||
>
|
||||
{all
|
||||
? t("plex.info.crewLess")
|
||||
: t("plex.info.crewMore", { count: crew.length - CREW_SHOWN })}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Play,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDismiss } from "../../lib/useDismiss";
|
||||
import { DETAIL_POSTER } from "../../lib/plexDetail";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
|
||||
// customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts.
|
||||
|
||||
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
|
||||
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). A filtering value
|
||||
// always says so on hover: `title` is a caller's specific hint ("Show titles from 1998"), else the
|
||||
// generic "Filter by …" from the value's own text.
|
||||
export function Filterable({
|
||||
onClick,
|
||||
title,
|
||||
className,
|
||||
tag = false,
|
||||
onDark = false,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge
|
||||
* when it doesn't (edged = pressable). */
|
||||
tag?: boolean;
|
||||
onDark?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hint =
|
||||
title ??
|
||||
(onClick && (typeof children === "string" || typeof children === "number")
|
||||
? t("plex.info.filterValue", { name: children })
|
||||
: undefined);
|
||||
if (tag)
|
||||
return onClick ? (
|
||||
<Chip variant="token" onClick={onClick} title={hint}>
|
||||
{children}
|
||||
</Chip>
|
||||
) : (
|
||||
<Badge onDark={onDark}>{children}</Badge>
|
||||
);
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onClick}
|
||||
title={hint}
|
||||
// min-h-6 + touch-target: a value in the meta line is still a 24 / 44 target (B1, A3).
|
||||
className={`${className ?? ""} min-h-6 min-w-6 cursor-pointer hover:text-accent transition touch-target`}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailCustomizeMenu({
|
||||
artBg,
|
||||
onToggleArtBg,
|
||||
showCast,
|
||||
onToggleCast,
|
||||
hasCast,
|
||||
showRelated,
|
||||
onToggleRelated,
|
||||
hasRelated,
|
||||
crossfade,
|
||||
onToggleCrossfade,
|
||||
overlay,
|
||||
extra,
|
||||
}: {
|
||||
artBg: boolean;
|
||||
onToggleArtBg: () => void;
|
||||
showCast: boolean;
|
||||
onToggleCast: () => void;
|
||||
hasCast: boolean;
|
||||
showRelated?: boolean;
|
||||
onToggleRelated?: () => void;
|
||||
hasRelated?: boolean;
|
||||
// Crossfade toggle — offered only when the art backdrop itself is on; omitted where a page
|
||||
// doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control).
|
||||
crossfade?: boolean;
|
||||
onToggleCrossfade?: () => void;
|
||||
overlay?: boolean; // white-on-dark button styling when floated over the player
|
||||
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||||
// Deliberately NOT on the popover platform. In the player's OVERLAY variant this menu renders
|
||||
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
|
||||
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
|
||||
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
|
||||
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
|
||||
// any of them needs the platform to portal INTO a caller-named (scaled) container.
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<IconButton
|
||||
ref={btnRef}
|
||||
variant={overlay ? "overlay" : "ghost"}
|
||||
size="md"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
label={t("plex.info.customize")}
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
</IconButton>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`${SURFACE.menu} absolute right-0 top-full z-menu mt-1 w-52 text-sm`}
|
||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{artBg && onToggleCrossfade && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCrossfade")}
|
||||
on={crossfade ?? true}
|
||||
onClick={onToggleCrossfade}
|
||||
/>
|
||||
)}
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefRelated")}
|
||||
on={showRelated ?? true}
|
||||
onClick={onToggleRelated}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
|
||||
* so a plain button, not a menuitemradio. */
|
||||
export function PrefToggle({
|
||||
label,
|
||||
on,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
layer
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<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 transition-all ${on ? "left-3.5 bg-accent-fg" : "left-0.5 bg-white"}`}
|
||||
/>
|
||||
</span>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||||
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||||
|
||||
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||
return (
|
||||
<Button variant="glass" size="sm" icon={<ArrowLeft />} onClick={onBack} className="mb-4">
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionBtn({
|
||||
ref,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
label,
|
||||
sub,
|
||||
primary,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub?: string;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={primary ? "primary" : "glass"}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
icon={<Icon />}
|
||||
>
|
||||
{label}
|
||||
{/* The resume point rides on the same line: the md rung is one line tall (B1). */}
|
||||
{sub && <span className="opacity-75">· {sub}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||||
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||||
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||||
* `children`. Poster is slightly larger on the show page (default) than the season page. `aside` is the
|
||||
* show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero,
|
||||
* under it in a narrow one. */
|
||||
export function DetailHero({
|
||||
thumb,
|
||||
posterTitle,
|
||||
posterDisabled,
|
||||
showPlayOverlay,
|
||||
onPosterClick,
|
||||
posterSizeClass = DETAIL_POSTER,
|
||||
customizeMenu,
|
||||
aside,
|
||||
children,
|
||||
}: {
|
||||
thumb: string;
|
||||
posterTitle: string;
|
||||
posterDisabled: boolean;
|
||||
showPlayOverlay: boolean;
|
||||
onPosterClick: () => void;
|
||||
posterSizeClass?: string;
|
||||
customizeMenu: ReactNode;
|
||||
aside?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const main = (
|
||||
<div className="flex gap-4 sm:gap-5">
|
||||
<Pressable
|
||||
onClick={onPosterClick}
|
||||
disabled={posterDisabled}
|
||||
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||
title={posterTitle}
|
||||
>
|
||||
<RemoteImage
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||
fallback={
|
||||
<div className="w-full aspect-2/3 rounded-xl border border-border bg-surface" />
|
||||
}
|
||||
/>
|
||||
{showPlayOverlay && (
|
||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</Pressable>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={`${SURFACE.panel} @container relative p-4 sm:p-5 mb-8`}>
|
||||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||
{aside ? (
|
||||
<div className="grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
|
||||
{main}
|
||||
{aside}
|
||||
</div>
|
||||
) : (
|
||||
main
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||
* the show and season heroes; the labels differ, so the caller passes both. */
|
||||
export function WatchedToggle({
|
||||
watched,
|
||||
busy,
|
||||
onToggle,
|
||||
watchedLabel,
|
||||
unwatchedLabel,
|
||||
}: {
|
||||
watched: boolean;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
watchedLabel: string;
|
||||
unwatchedLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<ActionBtn
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
icon={watched ? CheckCheck : Check}
|
||||
label={watched ? watchedLabel : unwatchedLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// The playback-cost classes and their dot tints, shared by the Plex cards (plexCards) and the info
|
||||
// page's playback chip (PlexInfo). Its own module so plexCards exports only components (Fast Refresh).
|
||||
|
||||
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
|
||||
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
|
||||
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
|
||||
// gradient; all four stay visually distinct in light + dark.
|
||||
export const PLAYABLE_TINT: Record<string, string> = {
|
||||
direct: "bg-success",
|
||||
remux: "bg-info",
|
||||
transcode_hw: "bg-warn",
|
||||
transcode_sw: "bg-danger",
|
||||
};
|
||||
// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display
|
||||
// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys.
|
||||
// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically.
|
||||
export function playableClass(playable: string, transcodeHw?: boolean | null): string {
|
||||
if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw";
|
||||
return playable; // direct | remux
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type ReactNode, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Slider as Range, CheckRow } from "../../components/ui/NativeControls";
|
||||
import { formatDecimal, formatUnit } from "../../lib/format";
|
||||
|
||||
// --- settings-panel building blocks ------------------------------------------------------------
|
||||
export function PanelHead({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="px-1 pt-2 pb-1 text-2xs uppercase tracking-wide text-white/50">{children}</div>
|
||||
);
|
||||
}
|
||||
export function Slider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
seconds,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
/** A symbol after the number ("%", "px"). */
|
||||
suffix?: string;
|
||||
/** The value is seconds: named in the UI language's unit ("0.5 sec" / "0,5 mp"). */
|
||||
seconds?: boolean;
|
||||
onChange: (v: number) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const shown = seconds
|
||||
? formatUnit(value, "second", lang)
|
||||
: `${formatDecimal(value, lang, 1, 0)}${suffix ?? ""}`;
|
||||
return (
|
||||
<div className="px-1 py-1">
|
||||
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
|
||||
<span>{label}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="tabular-nums text-white/60">{shown}</span>
|
||||
{onReset && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={onReset}
|
||||
label={t("common.resetNamed", { name: label })}
|
||||
className="-my-1.5"
|
||||
>
|
||||
<RotateCcw className="size-3.5!" />
|
||||
</IconButton>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Range
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
aria-label={label}
|
||||
onChange={(v) => onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function Toggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
// The whole row toggles and grows to 44 on touch (CheckRow); the box sits at the row's end.
|
||||
return (
|
||||
<CheckRow
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="w-full flex-row-reverse justify-between px-1 py-1.5 text-[0.75rem] text-white/80"
|
||||
>
|
||||
{label}
|
||||
</CheckRow>
|
||||
);
|
||||
}
|
||||
|
||||
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
||||
// viewport, unlike a native title tooltip on the bottom control bar).
|
||||
export function Ctrl({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
align = "center",
|
||||
buttonRef,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||||
align?: "start" | "center" | "end";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pos =
|
||||
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||
// `will-change-[opacity]` keeps each tooltip on its own compositor layer from the start. Without it
|
||||
// the browser promotes a tooltip only while its fade runs, and inside the player's scale(1.25)
|
||||
// the neighbouring glyphs got re-layered with it: every button in the row shimmered as the
|
||||
// pointer crossed one (seen by the owner).
|
||||
return (
|
||||
<div className="relative flex group/ctrl">
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
noTitle
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
<span
|
||||
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 will-change-[opacity] transition group-hover/ctrl:opacity-100`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The owner's pick "A": TMDB enriches shows as it does movies — studios, countries, keywords, key
|
||||
// crew — and the show page showed none of it, beside a hero whose text stopped at ~55% of the panel. Its
|
||||
// hero now has the movie page's second column (facts over crew) and the keywords under the text.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const detail = {
|
||||
show: {
|
||||
id: "show",
|
||||
title: "Fargo",
|
||||
summary: "Various chronicles.",
|
||||
year: 2014,
|
||||
thumb: "",
|
||||
art: "",
|
||||
content_rating: "TV-MA",
|
||||
studio: "26 Keys",
|
||||
genres: ["Crime"],
|
||||
cast: [],
|
||||
status: "new",
|
||||
episode_count: 0,
|
||||
collection_keys: [],
|
||||
tmdb: {
|
||||
keywords: ["minnesota", "winter"],
|
||||
crew: [{ name: "Noah Hawley", job: "Creator" }],
|
||||
studios: ["26 Keys Productions", "MGM Television"],
|
||||
production_countries: [{ code: "US", name: "United States" }],
|
||||
recommendations: [],
|
||||
backdrops: [],
|
||||
},
|
||||
},
|
||||
seasons: [],
|
||||
related: [],
|
||||
};
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
plexShow: () => Promise.resolve(detail),
|
||||
updatePrefs: () => Promise.resolve({}),
|
||||
},
|
||||
EMPTY_PLEX_FILTERS: {},
|
||||
plexFilterCount: () => 0,
|
||||
}));
|
||||
|
||||
import { PlexShowView } from "./plexViews";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexShowView
|
||||
showId="show"
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onFilter, onOpenPerson };
|
||||
}
|
||||
|
||||
describe("the show page's facts, keywords and crew", () => {
|
||||
it("shows the facts over the crew beside the text, and the keywords under it", async () => {
|
||||
const { onFilter, onOpenPerson } = renderShow();
|
||||
const aside = await screen.findByTestId("detail-aside");
|
||||
expect(aside.textContent).toContain("MGM Television");
|
||||
expect(aside.textContent).toContain("Noah Hawley");
|
||||
expect(aside.parentElement!.className).toContain("@5xl:grid-cols-");
|
||||
const main = aside.parentElement!.children[0]!;
|
||||
expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true);
|
||||
expect(main.contains(screen.getByText("Various chronicles."))).toBe(true);
|
||||
// They act as on the movie page: a keyword filters, a crew name opens the person.
|
||||
fireEvent.click(screen.getByText("minnesota"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["minnesota"] });
|
||||
fireEvent.click(screen.getByText("Noah Hawley"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Noah Hawley");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// PLEX_SUB_URL encode/decode round-trip + guards. Extracted from PlexBrowse for this test (a
|
||||
// code review's deferred gap). Pure — no DOM, no history.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
|
||||
|
||||
describe("PLEX_SUB_URL encode", () => {
|
||||
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
|
||||
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid
|
||||
expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2)
|
||||
expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2)
|
||||
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
|
||||
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
|
||||
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
|
||||
expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe");
|
||||
expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12");
|
||||
expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5");
|
||||
});
|
||||
|
||||
it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => {
|
||||
expect(U.encode({ kind: "discover" })).toBeNull();
|
||||
expect(U.encode({ kind: "player", id: "m9" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLEX_SUB_URL round-trip", () => {
|
||||
const cases: Sub[] = [
|
||||
{ kind: "grid" },
|
||||
{ kind: "playlists" },
|
||||
{ kind: "collections" },
|
||||
{ kind: "show", id: "s1" },
|
||||
{ kind: "season", showId: "s1", seasonId: "se2" },
|
||||
{ kind: "info", id: "m9" },
|
||||
{ kind: "person", name: "Jane Doe" },
|
||||
{ kind: "playlist", id: 12 },
|
||||
{ kind: "collection", id: "c5" },
|
||||
];
|
||||
it.each(cases)("encode→decode is identity for %o", (view) => {
|
||||
const raw = U.encode(view);
|
||||
expect(raw).not.toBeNull();
|
||||
expect(U.decode(raw as string)).toEqual(view);
|
||||
});
|
||||
|
||||
it("a person name containing ':' round-trips (tail after the first colon)", () => {
|
||||
const view: Sub = { kind: "person", name: "Dr: Strange: Origins" };
|
||||
expect(U.decode(U.encode(view) as string)).toEqual(view);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLEX_SUB_URL decode guards", () => {
|
||||
it("rejects a playlist id that is not a positive integer", () => {
|
||||
expect(U.decode("playlist:")).toBeNull(); // Number("")===0, not >0
|
||||
expect(U.decode("playlist:0")).toBeNull();
|
||||
expect(U.decode("playlist:-1")).toBeNull();
|
||||
expect(U.decode("playlist:1.5")).toBeNull();
|
||||
expect(U.decode("playlist:abc")).toBeNull();
|
||||
expect(U.decode("playlist:12")).toEqual({ kind: "playlist", id: 12 });
|
||||
});
|
||||
|
||||
it("requires both ids for a season", () => {
|
||||
expect(U.decode("season:s1")).toBeNull();
|
||||
expect(U.decode("season:")).toBeNull();
|
||||
expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" });
|
||||
});
|
||||
|
||||
it("requires a non-empty arg for show / info / person / collection", () => {
|
||||
expect(U.decode("show:")).toBeNull();
|
||||
expect(U.decode("info:")).toBeNull();
|
||||
expect(U.decode("person:")).toBeNull();
|
||||
expect(U.decode("collection:")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown kind or garbage", () => {
|
||||
expect(U.decode("grid")).toBeNull();
|
||||
expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable
|
||||
expect(U.decode("bogus")).toBeNull();
|
||||
expect(U.decode("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PLEX_SUB_URL entry", () => {
|
||||
// The Restart click must not replay on a reload / Forward onto the player entry.
|
||||
it("keeps the player without its one-shot fromStart, and every other view whole", () => {
|
||||
const entry = U.entry!;
|
||||
expect(entry({ kind: "player", id: "7", queue: ["7", "8"], fromStart: true })).toEqual({
|
||||
kind: "player",
|
||||
id: "7",
|
||||
queue: ["7", "8"],
|
||||
fromStart: undefined,
|
||||
});
|
||||
const info = { kind: "info", id: "7" } as const;
|
||||
expect(entry(info)).toBe(info);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { SubviewUrl } from "../../lib/history";
|
||||
import { parsePositiveIntId } from "../../lib/parseId";
|
||||
|
||||
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
|
||||
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
|
||||
// (and its lazy hls.js player) into the test graph.
|
||||
export type Sub =
|
||||
| { kind: "discover" }
|
||||
| { kind: "grid" }
|
||||
| { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode)
|
||||
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "season"; showId: string; seasonId: string }
|
||||
// `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point.
|
||||
| { kind: "player"; id: string; queue?: string[]; fromStart?: boolean }
|
||||
| { kind: "info"; id: string }
|
||||
| { kind: "person"; name: string }
|
||||
| { kind: "playlist"; id: number }
|
||||
| { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key)
|
||||
|
||||
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
|
||||
// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing, so it (and the
|
||||
// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a
|
||||
// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing
|
||||
// ":" round-trips.
|
||||
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
key: "v",
|
||||
encode: (v) => {
|
||||
switch (v.kind) {
|
||||
case "grid":
|
||||
return "browse";
|
||||
case "playlists":
|
||||
return "playlists";
|
||||
case "collections":
|
||||
return "collections";
|
||||
case "show":
|
||||
return `show:${v.id}`;
|
||||
case "season":
|
||||
return `season:${v.showId}:${v.seasonId}`;
|
||||
case "info":
|
||||
return `info:${v.id}`;
|
||||
case "person":
|
||||
return `person:${v.name}`;
|
||||
case "playlist":
|
||||
return `playlist:${v.id}`;
|
||||
case "collection":
|
||||
return `collection:${v.id}`;
|
||||
default:
|
||||
return null; // discover (root) + the ephemeral player are not URL-mirrored
|
||||
}
|
||||
},
|
||||
// `fromStart` is the Restart CLICK, not a property of the entry: kept in history.state it restarted
|
||||
// the item from 0 again on every reload or Forward onto the player, and the progress checkpoint
|
||||
// then overwrote the resume point it had skipped.
|
||||
entry: (v) => (v.kind === "player" && v.fromStart ? { ...v, fromStart: undefined } : v),
|
||||
decode: (raw) => {
|
||||
const i = raw.indexOf(":");
|
||||
const kind = i === -1 ? raw : raw.slice(0, i);
|
||||
const arg = i === -1 ? "" : raw.slice(i + 1);
|
||||
switch (kind) {
|
||||
case "browse":
|
||||
return { kind: "grid" };
|
||||
case "playlists":
|
||||
return { kind: "playlists" };
|
||||
case "collections":
|
||||
return { kind: "collections" };
|
||||
case "show":
|
||||
return arg ? { kind: "show", id: arg } : null;
|
||||
case "info":
|
||||
return arg ? { kind: "info", id: arg } : null;
|
||||
case "person":
|
||||
return arg ? { kind: "person", name: arg } : null;
|
||||
case "playlist": {
|
||||
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
|
||||
// sub-view for a non-existent id (shares parsePositiveIntId with readSelectedId's ?pl guard).
|
||||
const id = parsePositiveIntId(arg);
|
||||
return id != null ? { kind: "playlist", id } : null;
|
||||
}
|
||||
case "collection":
|
||||
return arg ? { kind: "collection", id: arg } : null;
|
||||
case "season": {
|
||||
const [showId, seasonId] = arg.split(":");
|
||||
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { type Sub } from "./plexSubUrl";
|
||||
|
||||
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
|
||||
// out of that file so it exports only components (Fast Refresh).
|
||||
export type PlexViewControls = {
|
||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
|
||||
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
|
||||
|
||||
export const usePlexViewControls = () => useContext(PlexViewReadContext);
|
||||
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The show's and the season's restart button. It said only "Play from start" — of what? (the owner,
|
||||
// 2026-09-27) — and it resumed the first episode at its saved point instead of at 0. Now it names its
|
||||
// object and the episode ("Season from the start · S6 · E1"), and it asks the player for 0.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const ep = (id: string, season: number, episode: number, position = 0) => ({
|
||||
id,
|
||||
type: "episode",
|
||||
title: `Ep ${id}`,
|
||||
season_number: season,
|
||||
episode_number: episode,
|
||||
duration_seconds: 1500,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: position > 0 ? "in_progress" : "new",
|
||||
position_seconds: position,
|
||||
});
|
||||
|
||||
// The first episode is half-watched: a restart that resumed it would start at 600, not 0.
|
||||
const s1 = [ep("e11", 1, 1, 600), ep("e12", 1, 2)];
|
||||
const s2 = [ep("e21", 2, 1, 300), ep("e22", 2, 2)];
|
||||
const detail = {
|
||||
show: {
|
||||
id: "show",
|
||||
title: "Show",
|
||||
thumb: "",
|
||||
art: "",
|
||||
genres: [],
|
||||
cast: [],
|
||||
status: "in_progress",
|
||||
resume: s2[1],
|
||||
first: s1[0],
|
||||
episode_count: 4,
|
||||
collection_keys: [],
|
||||
},
|
||||
seasons: [
|
||||
{
|
||||
id: "s1",
|
||||
season_number: 1,
|
||||
title: "Season 1",
|
||||
thumb: "",
|
||||
episode_count: 2,
|
||||
status: "in_progress",
|
||||
resume: s1[1],
|
||||
first: s1[0],
|
||||
episodes: s1,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
season_number: 2,
|
||||
title: "Season 2",
|
||||
thumb: "",
|
||||
episode_count: 2,
|
||||
status: "in_progress",
|
||||
resume: s2[1],
|
||||
first: s2[0],
|
||||
episodes: s2,
|
||||
},
|
||||
],
|
||||
related: [],
|
||||
};
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
plexShow: () => Promise.resolve(detail),
|
||||
plexSetState: () => Promise.resolve(),
|
||||
plexSeasonState: () => Promise.resolve(),
|
||||
},
|
||||
EMPTY_PLEX_FILTERS: {},
|
||||
plexFilterCount: () => 0,
|
||||
}));
|
||||
|
||||
import { PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
const ALL = ["e11", "e12", "e21", "e22"];
|
||||
|
||||
describe("the restart button on a show and a season", () => {
|
||||
it("a season's names the season and its first episode, and starts that episode at 0", async () => {
|
||||
const onPlay = vi.fn();
|
||||
wrap(
|
||||
<PlexSeasonView
|
||||
showId="show"
|
||||
seasonId="s2"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onInfoCard={() => {}}
|
||||
/>,
|
||||
);
|
||||
const btn = await screen.findByRole("button", { name: /plex\.series\.seasonFromStart/ });
|
||||
expect(btn.textContent).toContain("S2 · E1");
|
||||
fireEvent.click(btn);
|
||||
expect(onPlay).toHaveBeenCalledWith("e21", ALL, true);
|
||||
});
|
||||
|
||||
it("a show's names the show and its first episode, and starts that episode at 0", async () => {
|
||||
const onPlay = vi.fn();
|
||||
wrap(
|
||||
<PlexShowView
|
||||
showId="show"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
/>,
|
||||
);
|
||||
const btn = await screen.findByRole("button", { name: /plex\.series\.showFromStart/ });
|
||||
expect(btn.textContent).toContain("S1 · E1");
|
||||
fireEvent.click(btn);
|
||||
expect(onPlay).toHaveBeenCalledWith("e11", ALL, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the show page's summary", () => {
|
||||
it("is clamped with a More, so a long one can still be read in full", async () => {
|
||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||
Object.assign(detail.show, { summary: "A long summary.\n\nThat goes on." });
|
||||
try {
|
||||
wrap(
|
||||
<PlexShowView
|
||||
showId="show"
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
onPlayShow={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
Object.assign(detail.show, { summary: undefined });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,974 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Layers,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Star,
|
||||
Tv2,
|
||||
Undo2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
type PlexCard,
|
||||
type PlexFilters,
|
||||
type PlexPersonDetail,
|
||||
type PlexSeasonDetail,
|
||||
} from "../../lib/api";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { pickRatingBadge } from "../../lib/plexRating";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { toggleWatchState } from "../../lib/plexWatch";
|
||||
import { showEpisodeKeys } from "../../lib/plexQueue";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
DetailCustomizeMenu,
|
||||
DetailHero,
|
||||
Filterable,
|
||||
WatchedToggle,
|
||||
} from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
import {
|
||||
epLabel,
|
||||
seasonTitle,
|
||||
tmdbBackdropUrl,
|
||||
useCrossfadeBackdrop,
|
||||
useDetailPrefs,
|
||||
useDetailAside,
|
||||
} from "../../lib/plexDetail";
|
||||
import {
|
||||
CastStrip,
|
||||
EpisodeCard,
|
||||
PlexPosterCard,
|
||||
RelatedStrip,
|
||||
SeasonCard,
|
||||
} from "../../features/plexCards/plexCards";
|
||||
import { PlexMenu } from "./PlexMenu";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { Carousel } from "../../components/ui/Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "../../features/plexCards/PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
import { vocab } from "../../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../../lib/format";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
|
||||
// detail-page UI from plexDetailUi.
|
||||
|
||||
// Lazy: the rich movie info panel loads only when an info page is opened.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
export function PlexInfoView({
|
||||
id,
|
||||
onBack,
|
||||
onPlay,
|
||||
onPlayItem,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
onOpenShow,
|
||||
onOpenSeason,
|
||||
onOpenInfo,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson: (name: string) => void;
|
||||
onOpenShow: (showId: string) => void;
|
||||
onOpenSeason: (showId: string, seasonId: string) => void;
|
||||
onOpenInfo: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||
return (
|
||||
<div className="w-[90%] max-w-400 mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !q.data ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={q.data.library ?? undefined}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
onOpenShow={onOpenShow}
|
||||
onOpenSeason={onOpenSeason}
|
||||
onOpenInfo={onOpenInfo}
|
||||
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// How often, and for how long, a person page asks again for a bio that is on its way. The
|
||||
// background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one.
|
||||
const BIO_POLL_MS = 1500;
|
||||
// From this many "Known for" titles the rail gets half the hero. Fewer (about one person in five) sit
|
||||
// right beside the text, at their posters' width, and what is left over is the panel's end — not a hole
|
||||
// between the text and a lone poster at the far edge (owner pick "b").
|
||||
const KNOWN_FOR_HALF = 4;
|
||||
const BIO_WAIT_MS = 20_000;
|
||||
|
||||
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
|
||||
export function PlexPersonView({
|
||||
name,
|
||||
onBack,
|
||||
onOpenCard,
|
||||
onRestartCard,
|
||||
onInfoCard,
|
||||
onFilter,
|
||||
}: {
|
||||
name: string;
|
||||
onBack: () => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
|
||||
// lists on one screen, and the popover anchors where it opened, so a second controller would only
|
||||
// mean a second closed popover to keep shut (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const lang = plexLang(i18n.language);
|
||||
const personKey = qk.plexPerson(name, lang);
|
||||
// A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens;
|
||||
// the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
||||
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
|
||||
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
|
||||
// once (the next open tries again). The page is keyed on the person, so the wait starts per person;
|
||||
// a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing.
|
||||
const [waitOver, setWaitOver] = useState(false);
|
||||
const [waitRound, setWaitRound] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [waitRound]);
|
||||
const q = useQuery({
|
||||
queryKey: personKey,
|
||||
queryFn: () => api.plexPerson(name, lang),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.enriching && !waitOver && query.state.status !== "error"
|
||||
? BIO_POLL_MS
|
||||
: false,
|
||||
});
|
||||
const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError;
|
||||
// Cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
||||
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
|
||||
const { artBg, crossfade } = useDetailPrefs();
|
||||
const pd = q.data;
|
||||
useCrossfadeBackdrop(
|
||||
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
|
||||
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
|
||||
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||
// helper; also invalidate the library so its grid agrees.
|
||||
const toggle = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||
known_for: old.known_for.map((k) =>
|
||||
k.id === card.id ? { ...k, status: next } : k,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
|
||||
const actions = usePlexCardActions(
|
||||
{ onOpen: onOpenCard, onRestart: onRestartCard, onInfo: onInfoCard, onToggleWatched: toggle },
|
||||
menu.open,
|
||||
);
|
||||
const cardMenu = (c: PlexCard) => ({
|
||||
onRestart: () => onRestartCard(c),
|
||||
onAdd: actions.add,
|
||||
menuProps: menu.triggerProps,
|
||||
menuButtonProps: menu.buttonProps,
|
||||
menuOpen: menu.open && menu.target === c,
|
||||
});
|
||||
|
||||
// Not `section`: a local named like the <section> it renders confused the React Compiler's scope
|
||||
// analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
|
||||
const renderSection = (titleKey: string, cards: PlexCard[]) =>
|
||||
cards.length > 0 ? (
|
||||
<section className="mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-3">
|
||||
{cards.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onToggleWatched={toggle}
|
||||
{...cardMenu(c)}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
const d = q.data;
|
||||
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||
return (
|
||||
<div className="w-[90%] max-w-400 mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<Button variant="glass" size="xs" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError && !d ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero (owner pick "A"): the person — headshot, facts, a clamped bio — on the left,
|
||||
what they are known for on the right, so the bio doesn't sit in a full-width panel with an
|
||||
empty half beside it. One column where the panel is narrow. */}
|
||||
<section className={`${SURFACE.panel} @container p-4 sm:p-5 mb-6`}>
|
||||
<div
|
||||
className={
|
||||
d.known_for.length >= KNOWN_FOR_HALF
|
||||
? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
|
||||
: d.known_for.length > 0
|
||||
? "grid gap-6 @3xl:grid-cols-[auto_auto] @3xl:justify-start"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* The person's column is at most the bio's reading measure (text-sm, as the bio is), so a
|
||||
short rail can sit right beside it. */}
|
||||
<div className="measure min-w-0 text-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
<RemoteImage
|
||||
src={d.photo}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
fallback={
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
||||
{d.known_for_department && (
|
||||
<p className="text-xs font-medium text-accent">
|
||||
{vocab(t, "department", d.known_for_department)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||
</p>
|
||||
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
||||
{(d.birthday || d.place_of_birth) && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||
{[formatDate(d.birthday, lang), d.place_of_birth]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.deathday && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
|
||||
{formatDate(d.deathday, lang)}
|
||||
</p>
|
||||
)}
|
||||
{d.also_known_as.length > 0 && (
|
||||
<p className="truncate">
|
||||
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
||||
{d.also_known_as.slice(0, 4).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
{d.biography ? (
|
||||
<ClampedText
|
||||
text={d.biography}
|
||||
lines={8}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-4"
|
||||
/>
|
||||
) : bioOnItsWay ? (
|
||||
<LoadingState shape="text" label={t("plex.person.bioLoading")} className="mt-4" />
|
||||
) : (
|
||||
// An ask that failed while the bio was on its way is said, not left looking like a
|
||||
// person with no bio; the retry asks (and waits) again.
|
||||
d.enriching &&
|
||||
q.isError && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => {
|
||||
setWaitOver(false);
|
||||
setWaitRound((n) => n + 1);
|
||||
void q.refetch();
|
||||
}}
|
||||
>
|
||||
{t("plex.person.bioFailed")}
|
||||
</StateMessage>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "Known for" — the person's most-popular titles that are in our library. */}
|
||||
{d.known_for.length > 0 && (
|
||||
<div className="min-w-0">
|
||||
<Carousel
|
||||
title={t("plex.person.knownFor")}
|
||||
ariaLabel={t("plex.person.knownFor")}
|
||||
keyboardNav
|
||||
>
|
||||
{d.known_for.map((c, i) => (
|
||||
<div key={c.id} className="grid w-32 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
shelf
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onPage={onInfoCard}
|
||||
onToggleWatched={toggle}
|
||||
{...cardMenu(c)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{renderSection("plex.person.movies", d.movies)}
|
||||
{renderSection("plex.person.shows", d.shows)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PlexMenu menu={menu} actions={actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
||||
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
||||
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
||||
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
|
||||
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
|
||||
// Add the whole show to a collection.
|
||||
|
||||
export function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onPlayShow,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
onOpenSeason: (seasonId: string) => void;
|
||||
onOpenShow: (id: string) => void;
|
||||
/** A related show's picture: the episode it would play, as on every show card. */
|
||||
onPlayShow: (show: PlexCard, fromStart: boolean) => void;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [collOpen, setCollOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const relatedMenu = useContextMenu<PlexCard>();
|
||||
const related = usePlexCardActions(
|
||||
{
|
||||
onOpen: (c) => onPlayShow(c, false),
|
||||
onRestart: (c) => onPlayShow(c, true),
|
||||
onInfo: (c) => onOpenShow(c.id),
|
||||
},
|
||||
relatedMenu.open,
|
||||
);
|
||||
const seasonMenu = useContextMenu<PlexSeasonDetail>();
|
||||
const copy = useCopyItems();
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = show?.status === "watched";
|
||||
const {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
crossfade,
|
||||
toggleArtBg,
|
||||
toggleCast,
|
||||
toggleRelated,
|
||||
toggleCrossfade,
|
||||
} = useDetailPrefs();
|
||||
// The facts / keywords / crew: TMDB enriches shows too, and the page showed none of it.
|
||||
const aside = useDetailAside(show ?? {});
|
||||
useCrossfadeBackdrop(
|
||||
[
|
||||
show?.art,
|
||||
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
|
||||
...(d?.related ?? []).map((r) => r.art),
|
||||
],
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markSeason(seasonRk: string, w: boolean) {
|
||||
try {
|
||||
await api.plexSeasonState(seasonRk, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
// A season's add target (none for an empty season, which has nothing to add).
|
||||
const addSeason = (se: PlexSeasonDetail) =>
|
||||
show && se.episodes.length > 0
|
||||
? () =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: se.episodes.map((e) => e.id),
|
||||
title: `${show.title} — ${seasonTitle(se, t)}`,
|
||||
})
|
||||
: undefined;
|
||||
// A season's one action list: the rows its card shows on hover, plus copy.
|
||||
const seasonItems = (se: PlexSeasonDetail) => {
|
||||
const seWatched = se.status === "watched";
|
||||
const add = addSeason(se);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
label: t("plex.series.openSeason"),
|
||||
icon: <Tv2 className="w-4 h-4" />,
|
||||
onSelect: () => onOpenSeason(se.id),
|
||||
},
|
||||
{
|
||||
id: "watched",
|
||||
label: seWatched
|
||||
? t("plex.series.markSeasonUnwatched")
|
||||
: t("plex.series.markSeasonWatched"),
|
||||
icon: seWatched ? <Undo2 className="w-4 h-4" /> : <Check className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void markSeason(se.id, !seWatched),
|
||||
},
|
||||
...(add
|
||||
? [
|
||||
{
|
||||
id: "playlist",
|
||||
label: t("plex.playlist.addSeason"),
|
||||
icon: <ListPlus className="w-4 h-4" />,
|
||||
onSelect: add,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !show ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<DetailHero
|
||||
thumb={show.thumb}
|
||||
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!show.resume}
|
||||
showPlayOverlay={!!show.resume}
|
||||
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={show.cast.length > 0}
|
||||
showRelated={showRelated}
|
||||
onToggleRelated={toggleRelated}
|
||||
hasRelated={d.related.length > 0}
|
||||
extra={<DetailAsideToggles aside={aside} />}
|
||||
/>
|
||||
}
|
||||
aside={
|
||||
aside.column && (
|
||||
<DetailAside
|
||||
facts={show}
|
||||
shown={aside.shown}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
||||
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
|
||||
text, spaced by the gap alone (a '·' between chips reads as noise). */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
|
||||
{show.year != null && (
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<Filterable
|
||||
tag
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{vocab(t, "certificate", show.content_rating)}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.season_count != null && (
|
||||
<span>{t("plex.seasons", { count: show.season_count })}</span>
|
||||
)}
|
||||
{(() => {
|
||||
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||
// with no imdb:// entry (imdb_rating NULL since the IMDb-only change) still shows a
|
||||
// rating instead of dropping the badge entirely.
|
||||
const badge = pickRatingBadge(show.imdb_rating, show.rating);
|
||||
if (!badge) return null;
|
||||
const { score } = badge;
|
||||
return (
|
||||
<Pressable
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
title={
|
||||
onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
|
||||
>
|
||||
{/* the rating gold — its own token, not --warn: a score is not a warning */}
|
||||
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
|
||||
{formatDecimal(score, lang)}
|
||||
</Pressable>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{show.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
tag
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{show.summary && (
|
||||
<ClampedText
|
||||
text={show.summary}
|
||||
lines={5}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(show.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(show.resume)}
|
||||
/>
|
||||
)}
|
||||
{show.first && show.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(show.first!.id, allKeys, true)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.showFromStart")}
|
||||
sub={epLabel(show.first)}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markShowUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markShowWatched")}
|
||||
/>
|
||||
{allKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addShow")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && showLib && (
|
||||
<ActionBtn
|
||||
onClick={() => setCollOpen(true)}
|
||||
icon={Layers}
|
||||
label={t("plex.series.addShowCollection")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{aside.shown.keywords && (
|
||||
<DetailKeywords
|
||||
keywords={show.tmdb?.keywords ?? []}
|
||||
onFilter={onFilter}
|
||||
className="mt-5"
|
||||
/>
|
||||
)}
|
||||
</DetailHero>
|
||||
|
||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||
<section
|
||||
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(8.75rem,1fr))] gap-3">
|
||||
{d.seasons.map((se, i) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
navIndex={i}
|
||||
se={se}
|
||||
onOpen={() => onOpenSeason(se.id)}
|
||||
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||
onAdd={addSeason(se)}
|
||||
menuProps={seasonMenu.triggerProps}
|
||||
menuButtonProps={seasonMenu.buttonProps}
|
||||
menuOpen={seasonMenu.open && seasonMenu.target === se}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</section>
|
||||
|
||||
{show.cast.length > 0 && showCast && (
|
||||
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||
)}
|
||||
{d.related.length > 0 && showRelated && (
|
||||
<RelatedStrip
|
||||
related={d.related}
|
||||
onOpen={(c) => onOpenShow(c.id)}
|
||||
onPlay={(c) => onPlayShow(c, false)}
|
||||
onRestart={(c) => onPlayShow(c, true)}
|
||||
menuProps={relatedMenu.triggerProps}
|
||||
menuButtonProps={relatedMenu.buttonProps}
|
||||
openId={relatedMenu.open ? (relatedMenu.target?.id ?? null) : null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PlexMenu menu={relatedMenu} actions={related} />
|
||||
<ContextMenu
|
||||
menu={seasonMenu}
|
||||
ariaLabel={t("plex.menu")}
|
||||
items={seasonMenu.target ? seasonItems(seasonMenu.target) : []}
|
||||
/>
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && showLib && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: show.id, title: show.title }}
|
||||
library={showLib}
|
||||
memberOf={show.collection_keys}
|
||||
onClose={() => setCollOpen(false)}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlexSeasonView({
|
||||
showId,
|
||||
seasonId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onInfoCard,
|
||||
}: {
|
||||
showId: string;
|
||||
seasonId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||
const epMenu = useContextMenu<PlexCard>();
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||
useCrossfadeBackdrop(
|
||||
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
|
||||
artBg,
|
||||
crossfade,
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markEpisode(ep: PlexCard) {
|
||||
const next = ep.status === "watched" ? "new" : "watched";
|
||||
try {
|
||||
await api.plexSetState(ep.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
// The episode grid's one menu (an episode is not a comfort-shelf kind, so no pin).
|
||||
const episodes = usePlexCardActions(
|
||||
{
|
||||
onOpen: (ep) => onPlay(ep.id, allKeys),
|
||||
onRestart: (ep) => onPlay(ep.id, allKeys, true),
|
||||
onInfo: onInfoCard,
|
||||
onToggleWatched: (ep) => void markEpisode(ep),
|
||||
},
|
||||
epMenu.open,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-300 mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !se ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
thumb={se.thumb}
|
||||
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!se.resume}
|
||||
showPlayOverlay={!!se.resume}
|
||||
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
||||
posterSizeClass="w-28 sm:w-40"
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
|
||||
{d.show.title}
|
||||
</Button>
|
||||
<PageHeading
|
||||
className="text-2xl font-semibold leading-tight"
|
||||
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
|
||||
>
|
||||
{seasonTitle(se, t)}
|
||||
</PageHeading>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{se.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(se.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(se.resume)}
|
||||
/>
|
||||
)}
|
||||
{se.first && se.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(se.first!.id, allKeys, true)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.seasonFromStart")}
|
||||
sub={epLabel(se.first)}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markSeasonUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markSeasonWatched")}
|
||||
/>
|
||||
{seasonKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: seasonKeys,
|
||||
title: `${d.show.title} — ${seasonTitle(se, t)}`,
|
||||
})
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addSeason")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(14.375rem,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
onPlay={() => onPlay(ep.id, allKeys)}
|
||||
onPage={() => onInfoCard(ep)}
|
||||
onRestart={() => onPlay(ep.id, allKeys, true)}
|
||||
onAdd={() => episodes.add(ep)}
|
||||
onToggleWatched={() => markEpisode(ep)}
|
||||
menuProps={epMenu.triggerProps}
|
||||
menuButtonProps={epMenu.buttonProps}
|
||||
menuOpen={epMenu.open && epMenu.target === ep}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
<PlexMenu menu={epMenu} actions={episodes} pin={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A show's card plays (the owner, 2026-10-09): the click asks the server which episode, every time —
|
||||
// a preview answer from before a watch elsewhere must not decide it.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const notified = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: notified }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "hu" } }),
|
||||
}));
|
||||
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePlayShow } from "./usePlayShow";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
notified.mockReset();
|
||||
});
|
||||
|
||||
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "" };
|
||||
const ep = (id: string): PlexCard => ({ id, type: "episode", title: id, thumb: "" });
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const play = vi.fn();
|
||||
const openShow = vi.fn();
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
|
||||
{
|
||||
initialProps: { here: "grid" as unknown },
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
return { qc, play, openShow, playShow: result.current, rerender, unmount };
|
||||
}
|
||||
|
||||
/** Answers the test hands out one by one, in the order it chooses. */
|
||||
function answers() {
|
||||
const pending: ((v: { next: PlexCard | null }) => void)[] = [];
|
||||
vi.spyOn(api, "plexShowNext").mockImplementation(() => new Promise((r) => void pending.push(r)));
|
||||
return pending;
|
||||
}
|
||||
|
||||
describe("usePlayShow", () => {
|
||||
it("plays the episode the server names, from the start when asked to", async () => {
|
||||
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e7") });
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, true));
|
||||
expect(ask).toHaveBeenCalledWith("sh", "hu");
|
||||
expect(play).toHaveBeenCalledWith("e7", true);
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks again rather than play what the preview cached before a watch", async () => {
|
||||
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e8") });
|
||||
const { qc, play, playShow } = mount();
|
||||
qc.setQueryData(qk.plexShowNext("sh", "hu"), { next: ep("e7") });
|
||||
await act(() => playShow(show, false));
|
||||
expect(ask).toHaveBeenCalledTimes(1);
|
||||
expect(play).toHaveBeenCalledWith("e8", false);
|
||||
});
|
||||
|
||||
it("opens the page of a show with nothing to play", async () => {
|
||||
vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: null });
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, false));
|
||||
expect(openShow).toHaveBeenCalledWith("sh");
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acts on the last click only: an earlier, slower answer is dropped", async () => {
|
||||
const pending = answers();
|
||||
const { play, playShow } = mount();
|
||||
const first = playShow({ ...show, id: "a" }, false);
|
||||
const second = playShow({ ...show, id: "b" }, false);
|
||||
await act(async () => pending[1]!({ next: ep("b1") }));
|
||||
await act(async () => pending[0]!({ next: ep("a1") }));
|
||||
await act(() => Promise.all([first, second]));
|
||||
expect(play.mock.calls).toEqual([["b1", false]]);
|
||||
});
|
||||
|
||||
it("drops an answer that lands after the viewer has moved on, or left", async () => {
|
||||
const pending = answers();
|
||||
const { play, openShow, playShow, rerender, unmount } = mount();
|
||||
const moved = playShow(show, false);
|
||||
rerender({ here: "info" });
|
||||
await act(async () => pending[0]!({ next: ep("e1") }));
|
||||
await act(() => moved);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
const left = playShow(show, false);
|
||||
unmount();
|
||||
await act(async () => pending[1]!({ next: null }));
|
||||
await act(() => left);
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when the question fails, and opens nothing", async () => {
|
||||
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||
const { play, openShow, playShow } = mount();
|
||||
await act(() => playShow(show, false));
|
||||
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
|
||||
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
|
||||
* (`openShow`) and says so; a failed question is a toast, and nothing opens.
|
||||
*
|
||||
* The answer acts only for the LAST click, and only where it was made (`here`, the caller's view): a
|
||||
* slow answer to a click the viewer has since moved on from (another card, Back, another module)
|
||||
* would otherwise pull them into a player they no longer asked for. */
|
||||
export function usePlayShow(
|
||||
play: (episodeId: string, fromStart: boolean) => void,
|
||||
openShow: (showId: string) => void,
|
||||
here: unknown,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const at = useLatest(here);
|
||||
const asked = useRef(0);
|
||||
// Unmounted, nothing of it may navigate any more.
|
||||
useEffect(() => () => void ++asked.current, []);
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
// `undefined` when the question failed (said in the toast).
|
||||
const next = (id: string) =>
|
||||
qc
|
||||
.fetchQuery({
|
||||
queryKey: qk.plexShowNext(id, lang),
|
||||
queryFn: () => api.plexShowNext(id, lang),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then(
|
||||
(r) => r.next,
|
||||
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||
);
|
||||
return async (show: PlexCard, fromStart: boolean) => {
|
||||
const ask = ++asked.current;
|
||||
const from = at.current;
|
||||
const ep = await next(show.id);
|
||||
if (ask !== asked.current || at.current !== from || ep === undefined) return;
|
||||
if (ep) play(ep.id, fromStart);
|
||||
else openShow(show.id);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Guards a behavior change: the active-filter chip row (usePlexActiveFilters) still emits chips
|
||||
// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip —
|
||||
// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and
|
||||
// the provider is mocked so the hook can be driven with a non-default sort present.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const plexState = {
|
||||
scope: "both",
|
||||
show: "watched",
|
||||
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
|
||||
sort: { key: "year", dir: "asc" },
|
||||
filters: {
|
||||
...EMPTY_PLEX_FILTERS,
|
||||
genres: ["Action"],
|
||||
contentRatings: ["Not Rated"],
|
||||
// "A" was picked in the sidebar (its title came along); "B" arrived from a shared LINK, which
|
||||
// carries ids only — the case that used to render a raw rating_key.
|
||||
collections: ["A", "B"],
|
||||
collectionTitles: { A: "Marvel" },
|
||||
},
|
||||
};
|
||||
const setShow = vi.fn();
|
||||
const setFilters = vi.fn();
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => plexState,
|
||||
usePlexActions: () => ({ setShow, setFilters }),
|
||||
}));
|
||||
|
||||
import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
// The hook resolves collection titles it does not already have through react-query, so it needs a
|
||||
// client. "B" has no title in the state, so the hook DOES ask for it — stubbed here, so no test issues
|
||||
// a real request (an unmocked one used to fire from the first test and retry after it had finished).
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections: [] } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
|
||||
describe("usePlexActiveFilters", () => {
|
||||
it("emits filter + watch-state chips but never a sort chip", () => {
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const keys = result.current.map((c) => c.key);
|
||||
// The in-panel narrowings still surface...
|
||||
expect(keys).toContain("genre:Action");
|
||||
expect(keys).toContain("show");
|
||||
// ...but sort does not, even though a non-default sort is active in the shared state.
|
||||
expect(keys).not.toContain("sort");
|
||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||
});
|
||||
|
||||
// The chip read Plex's English word ("Not Rated") under a Hungarian UI.
|
||||
it("names a certificate chip through the vocabulary, keeping Plex's value as the key", () => {
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const chip = result.current.find((c) => c.key === "contentRating:Not Rated");
|
||||
expect(chip?.label).toBe("vocab.certificate.NotRated");
|
||||
});
|
||||
|
||||
it("labels a link-supplied collection with its resolved title, not the bare id", () => {
|
||||
// Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label)
|
||||
// rather than the network.
|
||||
qc.setQueryData(qk.plexCollections("titles", "B"), {
|
||||
collections: [{ id: "B", title: "Pirates of the Caribbean Collection" }],
|
||||
});
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const byKey = Object.fromEntries(result.current.map((c) => [c.key, c.label]));
|
||||
expect(byKey["collection:B"]).toBe("Pirates of the Caribbean Collection");
|
||||
// …and the one the filter state already knew about is untouched.
|
||||
expect(byKey["collection:A"]).toBe("Marvel");
|
||||
});
|
||||
|
||||
it("marks a link-supplied collection chip pending while its title loads, not the known one", () => {
|
||||
qc.removeQueries({ queryKey: qk.plexCollections("titles", "B") });
|
||||
vi.spyOn(api, "plexCollections").mockReturnValue(new Promise(() => {}) as never);
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const pending = Object.fromEntries(result.current.map((c) => [c.key, c.pending]));
|
||||
expect(pending["collection:B"]).toBe(true);
|
||||
expect(pending["collection:A"]).toBe(false);
|
||||
});
|
||||
|
||||
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
|
||||
setFilters.mockClear();
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const chip = result.current.find((c) => c.key === "collection:A");
|
||||
expect(chip).toBeDefined();
|
||||
chip!.remove();
|
||||
// patch() merges into the full filters object; assert the two touched fields directly. No residual
|
||||
// { A: "Marvel" } and no empty {} (which would still split the query-key cache from the pristine
|
||||
// no-collection state) — collectionTitles must be undefined.
|
||||
const arg = setFilters.mock.calls[0]![0] as {
|
||||
collections: string[];
|
||||
collectionTitles?: unknown;
|
||||
};
|
||||
expect(arg.collections).toEqual(["B"]);
|
||||
expect(arg.collectionTitles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vocabI18nKey } from "../../lib/vocab";
|
||||
import { type ActiveFilter } from "../../lib/useActiveFilters";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../../lib/api";
|
||||
import { PLEX_SORT_FALLBACK } from "../../lib/plexSort";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
|
||||
|
||||
// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state)
|
||||
// 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). Sort is NOT a chip here —
|
||||
// it's an always-visible control above the grid, like the feed. Lives here, not in lib, because
|
||||
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
const { show, filters } = usePlex();
|
||||
const { setShow, setFilters } = usePlexActions();
|
||||
const { t } = useTranslation();
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
// A link opened cold knows the collection ids but not their names — resolve the missing ones for
|
||||
// display (see usePlexCollectionTitles for why they are NOT written back into the filter state).
|
||||
const collections = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
|
||||
return buildPlexActiveFilters(
|
||||
filters,
|
||||
show,
|
||||
patch,
|
||||
setShow,
|
||||
t,
|
||||
collections.titles,
|
||||
collections.pending,
|
||||
);
|
||||
}
|
||||
|
||||
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
|
||||
// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured
|
||||
// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row.
|
||||
export function buildPlexActiveFilters(
|
||||
filters: PlexFilters,
|
||||
show: string,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
setShow: (s: string) => void,
|
||||
t: TFn,
|
||||
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
|
||||
* the saved-views manager, which describes a stored view and has only the captured titles. */
|
||||
collectionTitles?: Record<string, string>,
|
||||
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
|
||||
collectionsPending = false,
|
||||
): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
const has = (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: vocabI18nKey("certificate", r)
|
||||
? t(vocabI18nKey("certificate", r), { defaultValue: r })
|
||||
: r,
|
||||
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
|
||||
});
|
||||
}
|
||||
if (has(filters.yearMin) || has(filters.yearMax)) {
|
||||
const range =
|
||||
has(filters.yearMin) && has(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: has(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
out.push({
|
||||
key: "year",
|
||||
label: `${t("plex.filter.year")}: ${range}`,
|
||||
remove: () => patch({ yearMin: null, yearMax: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.ratingMin)) {
|
||||
out.push({
|
||||
key: "ratingMin",
|
||||
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
|
||||
remove: () => patch({ ratingMin: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.durationMin) || has(filters.durationMax)) {
|
||||
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
|
||||
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
|
||||
const u = t("plex.filter.durationUnit");
|
||||
const range =
|
||||
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
|
||||
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) }),
|
||||
});
|
||||
}
|
||||
for (const p of filters.playable) {
|
||||
out.push({
|
||||
key: `playable:${p}`,
|
||||
label: t(`plex.filter.playableOpt.${p}`),
|
||||
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||
});
|
||||
}
|
||||
for (const k of filters.keywords) {
|
||||
out.push({
|
||||
key: `keyword:${k}`,
|
||||
label: k,
|
||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||
});
|
||||
}
|
||||
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
|
||||
if (show !== "all") {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("plex.filter.showOpt." + show),
|
||||
remove: () => setShow("all"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The one "reset every Plex narrowing" callback, shared by the sidebar's Clear-filters and the chip
|
||||
// row's Clear-all so the two can't drift: expanded filters + watch-state + sort all go back to default.
|
||||
export function usePlexClearFilters(): () => void {
|
||||
const { setShow, setSort, setFilters } = usePlexActions();
|
||||
return () => {
|
||||
setFilters(EMPTY_PLEX_FILTERS);
|
||||
setShow("all");
|
||||
setSort(PLEX_SORT_FALLBACK);
|
||||
};
|
||||
}
|
||||
|
||||
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
|
||||
// load), else by the ones the state carries, else the generic word.
|
||||
function collectionChips(
|
||||
filters: PlexFilters,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
t: TFn,
|
||||
collectionTitles: Record<string, string> | undefined,
|
||||
pending: boolean,
|
||||
): ActiveFilter[] {
|
||||
return filters.collections.map((c) => ({
|
||||
key: `collection:${c}`,
|
||||
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
pending: pending && collectionTitles?.[c] === undefined,
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
// one (an empty `{}` still hashes differently from an absent key).
|
||||
remove: () => {
|
||||
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
|
||||
patch({
|
||||
collections: filters.collections.filter((x) => x !== c),
|
||||
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex playlist's reorder is a dnd-kit drag, and neither of dnd-kit's sensors activates under
|
||||
// jsdom — so `commit()`'s failure path, the one edit written as a promise chain (the easiest to lose
|
||||
// in an async/await refactor), was unreachable from any test. The writes live
|
||||
// in this hook now, and are driven directly.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn(
|
||||
(_n: { level: string; message: string; action?: { label: string; onClick: () => void } }) => 0,
|
||||
),
|
||||
}));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
notify.mockClear();
|
||||
});
|
||||
|
||||
const movie = (id: string) => ({ id, type: "movie", title: `Movie ${id}` }) as unknown as PlexCard;
|
||||
const ids = (items: PlexCard[]) => items.map((i) => i.id);
|
||||
// The failure is said under the action's own title (`notifyActionError(err, title)`).
|
||||
const errors = () =>
|
||||
notify.mock.calls
|
||||
.filter(([n]) => n.level === "error")
|
||||
.map(([n]) => (n as { title?: string }).title);
|
||||
|
||||
function setup(items: PlexCard[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const detail = vi
|
||||
.spyOn(api, "plexPlaylist")
|
||||
.mockResolvedValue({ id: 5, title: "F", items } as never);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(() => usePlexPlaylistEdits(5), { wrapper });
|
||||
return { qc, hook, detail };
|
||||
}
|
||||
|
||||
describe("usePlexPlaylistEdits · the reorder", () => {
|
||||
it("says a failed reorder, and puts the rows back in the order the server holds", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2"), movie("3")]);
|
||||
await waitFor(() => expect(ids(hook.result.current.items)).toEqual(["1", "2", "3"]));
|
||||
vi.spyOn(api, "plexReorderPlaylist").mockRejectedValue(new Error("offline"));
|
||||
|
||||
act(() => hook.result.current.commit([movie("3"), movie("1"), movie("2")]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["3", "1", "2"]); // optimistic
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("writes an accepted order into the detail cache, which a rollback and a re-entry read", async () => {
|
||||
const { hook, qc, detail } = setup([movie("1"), movie("2")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
// The follow-up refetch never answers, so only the accepted write can put the order there.
|
||||
detail.mockReturnValue(new Promise(() => {}) as never);
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
|
||||
|
||||
act(() => hook.result.current.commit([movie("2"), movie("1")]));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"]));
|
||||
await waitFor(() =>
|
||||
expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))!.items)).toEqual([
|
||||
"2",
|
||||
"1",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// B2, settled here rather than in a browser: `putBack` must read the cache AT ROLLBACK TIME. A
|
||||
// removal that landed while the reorder was in flight refreshed the list; a rollback bound to the
|
||||
// render that STARTED the reorder put the removed row back on screen, where no refetch cleared it
|
||||
// and the next drag persisted an order containing it.
|
||||
it("rolls back to the list as it is NOW, not as it was when the reorder began", async () => {
|
||||
const { hook, qc, detail } = setup([movie("1"), movie("2"), movie("3")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.spyOn(api, "plexReorderPlaylist").mockReturnValue(
|
||||
new Promise((_, reject) => (fail = reject)) as never,
|
||||
);
|
||||
|
||||
act(() => hook.result.current.commit([movie("3"), movie("2"), movie("1")]));
|
||||
// "2" was removed elsewhere meanwhile, and the refreshed list reached the cache. The server
|
||||
// answers the same from now on — so the rollback's own refetch comes back deep-equal, keeps
|
||||
// the same `q.data`, and could never correct a stale rollback.
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
|
||||
act(() => {
|
||||
qc.setQueryData(qk.plexPlaylist(5, "en"), {
|
||||
id: 5,
|
||||
title: "F",
|
||||
items: [movie("1"), movie("3")],
|
||||
});
|
||||
});
|
||||
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
|
||||
// (react-query batches its notification, so without this wait the refresh landed only after the
|
||||
// rollback and quietly corrected a stale one, which proved nothing.)
|
||||
await waitFor(() => expect(ids(hook.result.current.items)).toEqual(["1", "3"]));
|
||||
await act(async () => fail(new Error("offline")));
|
||||
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.reorderFailed"]));
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlexPlaylistEdits · the removal", () => {
|
||||
it("puts a row back when its removal fails, and says why", async () => {
|
||||
const { hook } = setup([movie("1"), movie("2")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(new Error("offline"));
|
||||
|
||||
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
|
||||
expect(errors()).toEqual(["plex.playlist.removeFailed"]);
|
||||
expect(ids(hook.result.current.items)).toEqual(["1", "2"]);
|
||||
expect(hook.result.current.busy).toBe(false);
|
||||
// A failed removal offers no Undo: there is nothing to put back.
|
||||
expect(notify.mock.calls.some(([n]) => n.action)).toBe(false);
|
||||
});
|
||||
|
||||
// No confirm — the toast's Undo is the way back, and it puts each row where it stood.
|
||||
it("says what went, and its Undo puts the rows back at their own indices in one request", async () => {
|
||||
const { hook, detail } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(4));
|
||||
vi.spyOn(api, "plexPlaylistRemoveBulk").mockImplementation(async () => {
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
|
||||
return {} as never;
|
||||
});
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
|
||||
// A season whose episodes do not sit together: rows 2 and 4.
|
||||
await act(() => hook.result.current.onRemove(["4", "2"], "Show · Season 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
expect(toast.message).toBe("plex.playlist.removedToast");
|
||||
expect(toast.action!.label).toBe("common.undo");
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
|
||||
expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]);
|
||||
});
|
||||
|
||||
// A raw index drifts when the list changes before the Undo — another tab added a
|
||||
// row at the top — and the row came back one place off. It goes back after its old neighbour.
|
||||
it("puts a row back after its neighbour when the list changed before the Undo", async () => {
|
||||
const { hook, detail } = setup([movie("1"), movie("2"), movie("3")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
|
||||
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
|
||||
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
|
||||
detail.mockResolvedValue({
|
||||
id: 5,
|
||||
title: "F",
|
||||
items: [movie("x"), movie("1"), movie("3")],
|
||||
} as never);
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [2]));
|
||||
});
|
||||
|
||||
it("says so when the Undo fails", async () => {
|
||||
const { hook, detail } = setup([movie("1"), movie("2")]);
|
||||
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("2")] } as never);
|
||||
return {} as never;
|
||||
});
|
||||
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
|
||||
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
|
||||
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
|
||||
act(() => toast.action!.onClick());
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.playlist.undoRemoveFailed"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { reinsertAt } from "../../lib/reinsert";
|
||||
|
||||
/** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal.
|
||||
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
|
||||
* driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
|
||||
export function usePlexPlaylistEdits(playlistId: number) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The rows' titles are in the reader's language, so the cache entry is per language — and ONE
|
||||
// key serves the read and every write below (setQueryData matches exactly).
|
||||
const lang = plexLang(i18n.language);
|
||||
const key = qk.plexPlaylist(playlistId, lang);
|
||||
const q = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.plexPlaylist(playlistId, lang),
|
||||
});
|
||||
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
|
||||
const [items, setItems] = useState<PlexCard[]>([]);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the local copy is OPTIMISTIC — drag and remove write it before the server answers — so it cannot be derived from the query. This re-syncs it to the server's answer, and the rollback path below deliberately writes it too.
|
||||
if (q.data) setItems(q.data.items);
|
||||
}, [q.data]);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
};
|
||||
|
||||
// Every edit here is optimistic on the local copy, and none of them used to handle a failure: a
|
||||
// removal that failed stayed removed on screen (the resync never ran), and a 404 — the playlist
|
||||
// deleted on another device, now a structured `not_found` — was an unhandled rejection that said
|
||||
// nothing at all (the YouTube side was fixed first). A failure is said,
|
||||
// and the rows are put back from what the server last sent — explicitly, because a resync that
|
||||
// returns the same list keeps the same `q.data` and the re-sync effect above never runs.
|
||||
// Read the cache AT ROLLBACK TIME, not the `q.data` of the render that bound the failing handler:
|
||||
// a removal completing while a reorder was in flight refreshed the list, and the reorder's stale
|
||||
// closure then put the removed row back on screen — where the deep-equal refetch could not clear
|
||||
// it, and the next drag persisted an order containing it. `Playlists.tsx`'s
|
||||
// sibling `restoreFromCache` reads it live for the same reason.
|
||||
const putBack = () => {
|
||||
const cur = qc.getQueryData<{ items: PlexCard[] }>(key);
|
||||
if (cur) setItems(cur.items);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
function commit(next: PlexCard[]) {
|
||||
if (busy) return; // a reorder racing a removal is what makes a rollback ambiguous
|
||||
setItems(next);
|
||||
api
|
||||
.plexReorderPlaylist(
|
||||
playlistId,
|
||||
next.map((i) => i.id),
|
||||
)
|
||||
.then(() => {
|
||||
// Write the accepted order into the DETAIL cache, not just the list. That cache is what
|
||||
// `putBack` rolls back to and what a re-entry inside the 30 s staleTime renders without
|
||||
// refetching — so after an accepted reorder it showed the pre-reorder order again, and the
|
||||
// next drag from there persisted it, silently undoing the one the server took. The
|
||||
// YouTube sibling (`Playlists.tsx`) got this first; this half was left behind.
|
||||
// One accepted write, one cache entry that matches it.
|
||||
qc.setQueryData<{ items: PlexCard[] }>(key, (old) => (old ? { ...old, items: next } : old));
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
// …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and
|
||||
// the server only reorders the keys it was given — so anything that landed meanwhile (a row
|
||||
// removed from another surface, an item added elsewhere) is clobbered by that write. With
|
||||
// only the list key invalidated nothing corrected it: staleTime is 30 s and
|
||||
// refetchOnWindowFocus is off, so the wrong membership stayed on screen for as long as the
|
||||
// view was mounted and the next drag persisted an order built from it.
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("plex.playlist.reorderFailed"));
|
||||
putBack();
|
||||
});
|
||||
}
|
||||
|
||||
/** Take `keys` out — one row, a season or a whole show — with no confirm: the toast's Undo puts
|
||||
* each row back where it stood (C1 in DESIGN.md: the same bargain as a YouTube playlist's rows) — after
|
||||
* its old neighbour in the list as it is at Undo time (`reinsertAt`), since a raw index drifts
|
||||
* once anything changes in between. `title` names what went in that toast. */
|
||||
async function onRemove(keys: string[], title: string) {
|
||||
if (busy || !keys.length) return;
|
||||
setBusy(true);
|
||||
// The list as it stands now, which is the server's order: a season's episodes need not sit
|
||||
// together (a reorder inside the show interleaves them), so each key keeps its own neighbour.
|
||||
const before = items.map((i) => i.id);
|
||||
const set = new Set(keys);
|
||||
setItems((prev) => prev.filter((i) => !set.has(i.id)));
|
||||
let removed = true;
|
||||
try {
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
removed = false;
|
||||
}
|
||||
setBusy(false);
|
||||
if (!removed) return;
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("plex.playlist.removedToast", { title }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () =>
|
||||
void qc
|
||||
.fetchQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.plexPlaylist(playlistId, lang),
|
||||
staleTime: 0,
|
||||
})
|
||||
.then((now) => {
|
||||
const back = reinsertAt(
|
||||
before,
|
||||
keys,
|
||||
now.items.map((i) => i.id),
|
||||
);
|
||||
if (back.keys.length)
|
||||
return api.plexPlaylistAddBulk(playlistId, back.keys, back.positions);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
|
||||
.finally(invalidate),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A watched toggle's optimistic step on the local copy (the query refetch then confirms it). */
|
||||
const setStatus = (id: string, status: string) =>
|
||||
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status } : i)));
|
||||
|
||||
return { q, items, busy, invalidate, commit, onRemove, setStatus };
|
||||
}
|
||||
Reference in New Issue
Block a user