From cda626be10a0230421426a803e1ebbf5ef0f94b2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 19:42:53 +0200 Subject: [PATCH] fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in place instead of pausing it on a frozen 0:00. - Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false). - A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap leaving a tapped button no longer re-shows the bands its own toggle then hid. - Next leaving under the focus hands it to its neighbour (the player keeps its focus). - The chosen audio / subtitle track wears the MenuCheck tick. --- .../PlexPlayer.controls.dom.test.tsx | 118 ++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 38 ++++-- .../src/components/ui/anchoredPopoverHooks.ts | 6 + frontend/src/lib/plexPlayerHooks.ts | 27 +++- 4 files changed, 176 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 2d4b641b..37b6b6d8 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -253,6 +253,48 @@ describe("keyboard focus in the bands", () => { act(() => vi.advanceTimersByTime(3000)); expect(bandHidden()).toBe(true); }); + + it("but not over the whole film: 10 s after the last key they fade, and a key brings them back", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + vi.useFakeTimers(); + video()._paused = true; + act(() => void video().play()); + const vol = screen.getByLabelText("plex.player.volume"); + fireEvent.keyDown(document.body, { key: "Tab" }); + act(() => vol.focus()); + fireEvent.keyDown(vol, { key: "ArrowUp" }); + act(() => vi.advanceTimersByTime(9000)); + expect(bandHidden()).toBe(false); + act(() => vi.advanceTimersByTime(1500)); + expect(bandHidden()).toBe(true); + expect(document.activeElement).toBe(vol); + fireEvent.keyDown(vol, { key: "ArrowUp" }); + expect(bandHidden()).toBe(false); + }); + + it("a tap leaving a tapped button for the picture shows the hidden bands", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + vi.useFakeTimers(); + video()._paused = true; + act(() => void video().play()); + const btn = screen.getByLabelText("plex.player.mute"); + // A finger's focus is not :focus-visible (jsdom calls every focus visible), so it holds nothing. + const matches = Element.prototype.matches; + vi.spyOn(Element.prototype, "matches").mockImplementation(function ( + this: Element, + sel: string, + ) { + return sel === ":focus-visible" ? false : matches.call(this, sel); + }); + act(() => btn.focus()); + act(() => vi.advanceTimersByTime(3000)); + expect(bandHidden()).toBe(true); + act(() => btn.blur()); // the tap on the picture takes the focus first… + pressPicture("touch"); // …then toggles + expect(bandHidden()).toBe(false); + }); }); describe("a double-tap on a side third skips 10 s (C5)", () => { @@ -351,6 +393,34 @@ describe("a collection sibling in the info overlay", () => { await waitFor(() => expect(lastStart(session)).toEqual(["B", 0])); expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed }); + + it( + "the film itself in its own collection restarts in place, and the clock runs on", + { timeout: 10000 }, + async () => { + const self = withSibling("A"); + self.collections![0]!.items[0] = { + ...self.collections![0]!.items[0]!, + id: "A", + title: "Title A", + }; + const { session } = mount({ details: { A: self } }); + await playingAt("/media/A", 100); + const asked = session.mock.calls.length; + fireEvent.click(screen.getByLabelText("plex.player.more")); + fireEvent.click(await menuItem("plex.player.infoBtn")); + fireEvent.click( + await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }), + ); + await waitFor(() => expect(video()._t).toBe(0)); + expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload + expect(video().paused).toBe(false); + const v = video(); + v._t = 42; + act(() => void v.dispatchEvent(new Event("timeupdate"))); + expect(screen.getByText(/^0:42 \//)).toBeTruthy(); + }, + ); }); describe("the C5 row", () => { @@ -395,6 +465,42 @@ describe("the C5 row", () => { // E28 review D3: the ⋯ was a hand-built
of buttons — no menu role, no arrow keys, and Escape // closed it with the focus left on . It is a menu on the popover engine now. + it("↑/↓ on the closed ⋯ and tracks buttons are the volume, not an open menu", async () => { + const two = item("A"); + two.audio_streams = [ + ...two.audio_streams, + { ord: 1, label: "Magyar", language: "hu", default: false }, + ] as PlexItemDetail["audio_streams"]; + mount({ details: { A: two } }); + await playingAt("/media/A", 100); + for (const label of ["plex.player.more", "plex.player.tracks"]) { + const trigger = screen.getByLabelText(label); + act(() => trigger.focus()); + const before = video().volume; + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(video().volume).toBeLessThan(before); + } + }); + + it("marks the chosen track with the tick of a one-of-several menu", async () => { + const two = item("A"); + two.audio_streams = [ + ...two.audio_streams, + { ord: 1, label: "Magyar", language: "hu", default: false }, + ] as PlexItemDetail["audio_streams"]; + mount({ details: { A: two } }); + await playingAt("/media/A", 100); + fireEvent.click(screen.getByLabelText("plex.player.tracks")); + const rows = await screen.findAllByRole("menuitemradio", { hidden: true }); + const ticked = rows.map((r) => { + const tick = r.querySelector("svg"); + return tick != null && !tick.getAttribute("class")!.includes("opacity-0"); + }); + expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]); + expect(ticked).toEqual([true, false]); + }); + it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => { mount({ details: { A: item("A") } }); await playingAt("/media/A", 100); @@ -449,6 +555,18 @@ describe("the C5 row", () => { expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next")); }); + it("Next leaving under the focus (onto the last item) hands the focus to its neighbour", async () => { + mount({ details: { A: item("A", 0, "B"), B: item("B") } }); + await playingAt("/media/A", 100); + const next = screen.getByLabelText("plex.player.next"); + act(() => next.focus()); + fireEvent.click(next); + await waitFor(() => expect(screen.queryByLabelText("plex.player.next")).toBeNull()); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByLabelText("plex.player.mute")), + ); + }); + it("Next shows only when there is a next item", async () => { mount({ details: { A: item("A") } }); await playingAt("/media/A", 100); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 63413c58..1acf94aa 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { plexLang } from "../i18n"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; +import { useFocusKeeper } from "../lib/useFocusKeeper"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; import { @@ -82,6 +83,7 @@ import { } from "../lib/layerStack"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; +import { MenuCheck } from "./ui/MenuCheck"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Slider } from "./ui/NativeControls"; @@ -109,6 +111,10 @@ const BACK_TO_PREV_S = 3; const PLAYER_MENU = "rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl"; const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10"; +// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which +// the highlight's white text cannot wash out the way it did the accent-coloured label (round 2, P6). +const TRACK_ROW = + "flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10"; const TAP_SKIP_S = 10; const DOUBLE_TAP_MS = 300; @@ -149,6 +155,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // can't start a detached
@@ -1714,9 +1736,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } checked={subOrd == null} label={t("plex.player.subOff")} onSelect={() => changeSubtitle(null)} - className={`${PLAYER_ROW} ${subOrd == null ? "text-accent" : "text-white"}`} + className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`} > - {t("plex.player.subOff")} + + {t("plex.player.subOff")} {textSubs.map((sub) => ( changeSubtitle(sub.ord)} - className={`${PLAYER_ROW} truncate ${ + className={`${TRACK_ROW} ${ subOrd === sub.ord ? "text-accent" : "text-white" }`} > - {sub.label} + + {sub.label} ))} diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts index 9a729da8..909bcae8 100644 --- a/frontend/src/components/ui/anchoredPopoverHooks.ts +++ b/frontend/src/components/ui/anchoredPopoverHooks.ts @@ -72,6 +72,11 @@ export interface AnchoredPopoverOptions { * scrolls under it while its actions still target the row you opened it on (E10 review round 1). * This is also what a native context menu does. */ dismissOnScroll?: boolean; + /** Menu/listbox only: ↑/↓ on the CLOSED trigger open the panel (the APG menu-button default, on). + * Turn it off where those keys already mean something on that trigger — a media player's row, + * where ↑/↓ are the volume from anywhere, and a trigger that swallowed them killed it (E28 review + * round 2, P2). Enter/Space/click still open it, and the arrows still rove once it is open. */ + openOnArrowKey?: boolean; } export function useAnchoredPopover(opts: AnchoredPopoverOptions) { @@ -199,6 +204,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { setHoverIndex(null); }, selectedIndex: opts.selectedIndex ?? null, + openOnArrowKeyDown: opts.openOnArrowKey ?? true, // Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads // each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a // pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27 diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 7655c5e1..45f02636 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -73,12 +73,17 @@ export function useFullscreen(wrapRef: RefObject): { // `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube // and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it // starts a fresh 3 s. `holdOnFocus` is the same for a KEYBOARD focus in the chrome (E28 review E6): the -// bar must not fade from under the control a key user is on. +// bar must not fade from under the control a key user is on — but only for a while after the last +// key (FOCUS_HOLD_MS). The focus never leaves the bands on its own (Escape hands it back to the ⋯), so +// a hold without an end kept them over the whole film (round 2, P4); any key wakes them again. // // On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the // bar stayed up until the first tap however long the video played — and a tap on the picture only // shows or hides the bar (`toggle`), never pauses. `show(false)` hides at once: a double-tap skip // takes back the show/hide its first tap did. +const HIDE_MS = 3000; +const FOCUS_HOLD_MS = 10_000; + export function useControlsVisibility(opts: { barAutoHideRef: RefObject; menuOpenRef: RefObject; @@ -105,11 +110,17 @@ export function useControlsVisibility(opts: { setVisible(true); if (hideTimer.current) window.clearTimeout(hideTimer.current); if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar - hideTimer.current = window.setTimeout(() => { - // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. - if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current && !focused.current) + const wokeAt = Date.now(); + const arm = (ms: number) => { + hideTimer.current = window.setTimeout(() => { + // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. + if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return; + const held = FOCUS_HOLD_MS - (Date.now() - wokeAt); + if (focused.current && held > 0) return arm(held); setVisible(false); - }, 3000); + }, ms); + }; + arm(HIDE_MS); }, [barAutoHideRef, menuOpenRef, videoRef, setVisible]); const show = useCallback( (visible: boolean) => { @@ -130,8 +141,12 @@ export function useControlsVisibility(opts: { ); const holdOnFocus = useCallback( (on: boolean) => { + const was = focused.current; focused.current = on; - if (!on) wake(); + // Only a hold that ENDS restarts the timer. A tap moving the focus out of the bands (a finger + // never holds: its focus is not :focus-visible) re-showed them on its way to the picture, whose + // own toggle then hid them again — the tap did nothing on Android (round 2, P3). + if (was && !on) wake(); }, [wake], );