diff --git a/frontend/src/components/PlexPlayer.panels.dom.test.tsx b/frontend/src/components/PlexPlayer.panels.dom.test.tsx new file mode 100644 index 00000000..ba24e08a --- /dev/null +++ b/frontend/src/components/PlexPlayer.panels.dom.test.tsx @@ -0,0 +1,160 @@ +// @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 every "back" (⌫, Escape, the mouse/browser Back) peels the panel +// first, then the stats, and only then leaves the player. +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()), + 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>); + vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never); + const onClose = vi.fn(); + render( + + + , + ); + 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 })); + }); + +// `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 act(async () => { + const landed = new Promise((resolve) => + window.addEventListener("popstate", resolve, { once: true }), + ); + window.history.back(); + await landed; + }); + + await waitFor(() => expect(moreMenu()).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); + }); +}); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 95173569..e5293b41 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -128,6 +128,7 @@ const DOUBLE_TAP_MS = 300; // `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it // moves on to starts at its own resume point. type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean }; +type Panel = "none" | "more" | "tracks" | "settings" | "info" | "upNext"; export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { const { t, i18n } = useTranslation(); @@ -253,15 +254,23 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } const langPrefsRef = useLatest({ audioLang: prefs.audioLang, subLang: prefs.subLang }); const seekStepRef = useLatest(prefs.seekStep); const fineSeekStepRef = useLatest(prefs.fineSeekStep); - const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings - // The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file. - const [moreOpen, setMoreOpen] = useState(false); - const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks + // What is open over the video, one at a time: the fixed ⋯ (C5, the rare tools), the quick tracks + // menu, the tabbed tuning panel (from ⋯ → Settings), the rich info overlay ("I") and the up-next + // end-card. One value rather than a flag each, so two of them can never stand on top of each + // other and Back/Escape have exactly one thing to close. The stats readout ("D") is not in it: a + // corner readout, not a panel, which stays up beside them (the sync offset is tuned watching it). + const [panel, setPanel] = useState("none"); + const panelRef = useLatest(panel); + const infoOpenRef = useLatest(panel === "info"); // the wheel is the overlay's scroll, not volume + // A panel closes only itself: a popover's late "closed" must not shut the panel that replaced it. + const closePanel = useCallback((p: Panel) => setPanel((cur) => (cur === p ? "none" : cur)), []); + const togglePanel = useCallback((p: Panel) => setPanel((cur) => (cur === p ? "none" : p)), []); + // The tuning panel's tab outlives its closing: it reopens where it was left. const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">( "sync", ); - // keep the control bar up while one is open - const menuOpenRef = useLatest(menuOpen || tracksOpen || moreOpen); + // keep the control bar up while a menu is open + const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings"); const menuRef = useRef(null); const menuTriggerRef = useRef(null); // Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing). @@ -293,17 +302,17 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely). // It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is // what closes it (below). - useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]); + useDismiss(panel === "settings", () => closePanel("settings"), [menuRef, menuTriggerRef]); // The ⋯ and the tracks list are real menus on the popover engine: roving arrows, // Home/End, Escape as a layer, and the focus back on the trigger however they close. They were // hand-built
s — no roles, no arrows, and Escape left the focus on . The engine portals // them into the fullscreen element when there is one, so they show in fullscreen too. const morePop = useAnchoredPopover({ - open: moreOpen, + open: panel === "more", // The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it. onOpenChange: (open) => { - if (open && menuOpen) setMenuOpen(false); - else setMoreOpen(open); + if (!open) closePanel("more"); + else setPanel((cur) => (cur === "settings" ? "none" : "more")); }, placement: "top-end", offset: 8, @@ -311,8 +320,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } openOnArrowKey: false, }); const tracksPop = useAnchoredPopover({ - open: tracksOpen, - onOpenChange: setTracksOpen, + open: panel === "tracks", + onOpenChange: (open) => (open ? setPanel("tracks") : closePanel("tracks")), placement: "top-end", offset: 8, fitHeight: true, @@ -352,15 +361,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } }, [], ); - // "I" toggles a rich info overlay (poster/cast/ratings) over the running video. - const [infoOpen, setInfoOpen] = useState(false); - const infoOpenRef = useLatest(infoOpen); // "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…). const [statsOpen, setStatsOpen] = useState(false); const statsOpenRef = useLatest(statsOpen); - // Up-next end-card: shown at episode end (with a next item) instead of an instant jump. - const [upNextOpen, setUpNextOpen] = useState(false); - const upNextOpenRef = useLatest(upNextOpen); const upNextPrefRef = useLatest(prefs.upNext); const now = useWallClock(); @@ -756,13 +759,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } id: "settings", label: t("plex.player.settings"), icon: , - run: () => setMenuOpen(true), + run: () => setPanel("settings"), }, { id: "info", label: t("plex.player.infoBtn"), icon: , - run: () => setInfoOpen((v) => !v), + run: () => togglePanel("info"), }, ] : []), @@ -802,7 +805,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel) // and let IT advance — binge stays in the viewer's control. The card only appears here, // on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it. - if (nextId && upNextPrefRef.current) setUpNextOpen(true); + if (nextId && upNextPrefRef.current) setPanel("upNext"); else if (nextId) go(nextId); // Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape), // instead of stranding a black, ended
- {menuOpen && ( + {panel === "settings" && ( setUpNextOpen(false)} + onCancel={() => closePanel("upNext")} /> )} {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */} - {infoOpen && detail && ( + {panel === "info" && detail && (
{ e.stopPropagation(); - setInfoOpen(false); + closePanel("info"); }} >
setInfoOpen(false)} + onClose={() => closePanel("info")} // A collection sibling plays right here, as Next would. onPlayItem={(other, fromZero) => { - setInfoOpen(false); + closePanel("info"); go(other, undefined, fromZero); }} />