diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index 15b8b750..1f36b1f1 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -171,10 +171,10 @@
"skipCredits": "Skip credits",
"playPause": "Play / pause (space)",
"restart": "Restart from beginning",
- "rewind10": "Back 10 seconds",
- "forward10": "Forward 10 seconds",
"prev": "Previous episode",
"next": "Next episode",
+ "tapBack": "−{{n}} s",
+ "tapForward": "+{{n}} s",
"mute": "Mute (m)",
"volume": "Volume",
"fullscreen": "Fullscreen (f)",
@@ -186,9 +186,9 @@
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
"errGeneric": "Playback couldn't start. Please try again.",
- "stop": "Stop",
"help": "Keyboard shortcuts",
"infoBtn": "Media info",
+ "more": "More",
"upNext": {
"title": "Up next",
"playNow": "Play now",
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index 9dd8560b..1c718c8d 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -171,10 +171,10 @@
"skipCredits": "Stáblista átugrása",
"playPause": "Lejátszás / szünet (szóköz)",
"restart": "Újra az elejéről",
- "rewind10": "10 másodperc vissza",
- "forward10": "10 másodperc előre",
"prev": "Előző rész",
"next": "Következő rész",
+ "tapBack": "−{{n}} mp",
+ "tapForward": "+{{n}} mp",
"mute": "Némítás (m)",
"volume": "Hangerő",
"fullscreen": "Teljes képernyő (f)",
@@ -186,9 +186,9 @@
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
- "stop": "Leállítás",
"help": "Billentyűparancsok",
"infoBtn": "Média infó",
+ "more": "Továbbiak",
"upNext": {
"title": "Következik",
"playNow": "Lejátszás most",
diff --git a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
index f6eff430..4db4896e 100644
--- a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
+++ b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
@@ -7,12 +7,13 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useControlsVisibility } from "./plexPlayerHooks";
-const setup = () => {
- const video = { paused: false } as HTMLVideoElement;
+const setup = (menuOpen = false) => {
+ const video = document.createElement("video");
+ Object.defineProperty(video, "paused", { configurable: true, value: false });
return renderHook(() =>
useControlsVisibility({
barAutoHideRef: { current: true },
- menuOpenRef: { current: false },
+ menuOpenRef: { current: menuOpen },
videoRef: { current: video },
}),
);
@@ -44,4 +45,19 @@ describe("useControlsVisibility", () => {
act(() => vi.advanceTimersByTime(100));
expect(result.current.uiVisible).toBe(false);
});
+
+ // C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
+ it("toggle hides a shown bar at once and shows a hidden one", () => {
+ const { result } = setup();
+ act(() => result.current.toggle());
+ expect(result.current.uiVisible).toBe(false);
+ act(() => result.current.toggle());
+ expect(result.current.uiVisible).toBe(true);
+ });
+
+ it("a tap does not hide the bar under an open menu", () => {
+ const { result } = setup(true);
+ act(() => result.current.toggle());
+ expect(result.current.uiVisible).toBe(true);
+ });
});
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index e5c4f3e6..03693593 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -73,25 +73,50 @@ 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.
+//
+// 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.
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject;
menuOpenRef: RefObject;
videoRef: RefObject;
-}): { uiVisible: boolean; wake: () => void; holdOnHover: (over: boolean) => void } {
+}): {
+ uiVisible: boolean;
+ wake: () => void;
+ holdOnHover: (over: boolean) => void;
+ toggle: () => void;
+ show: (visible: boolean) => void;
+} {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
+ const visibleRef = useRef(true);
const hideTimer = useRef(null);
const hovered = useRef(false);
+ const setVisible = useCallback((v: boolean) => {
+ visibleRef.current = v;
+ setUiVisible(v);
+ }, []);
const wake = useCallback(() => {
- setUiVisible(true);
+ 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)
- setUiVisible(false);
+ if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) setVisible(false);
}, 3000);
- }, [barAutoHideRef, menuOpenRef, videoRef]);
+ }, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
+ const show = useCallback(
+ (visible: boolean) => {
+ if (visible) return wake();
+ if (hideTimer.current) window.clearTimeout(hideTimer.current);
+ // A tap on a picture with a menu open closes nothing and must not strand that menu unseen.
+ if (!menuOpenRef.current) setVisible(false);
+ },
+ [wake, menuOpenRef, setVisible],
+ );
+ const toggle = useCallback(() => show(!visibleRef.current), [show]);
const holdOnHover = useCallback(
(over: boolean) => {
hovered.current = over;
@@ -99,7 +124,19 @@ export function useControlsVisibility(opts: {
},
[wake],
);
- return { uiVisible, wake, holdOnHover };
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.addEventListener("play", wake);
+ return () => video.removeEventListener("play", wake);
+ }, [videoRef, wake]);
+ useEffect(
+ () => () => {
+ if (hideTimer.current) window.clearTimeout(hideTimer.current);
+ },
+ [],
+ );
+ return { uiVisible, wake, holdOnHover, toggle, show };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
diff --git a/frontend/src/lib/zLayer.contract.test.ts b/frontend/src/lib/zLayer.contract.test.ts
index 0a11ee95..6f0c2f92 100644
--- a/frontend/src/lib/zLayer.contract.test.ts
+++ b/frontend/src/lib/zLayer.contract.test.ts
@@ -46,7 +46,7 @@ describe("the Plex player's layers", () => {
const popovers = classes(
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
);
- expect(popovers).toHaveLength(2);
+ expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
});