fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end

- 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.
This commit is contained in:
2026-09-27 19:42:53 +02:00
parent b7ce1d467c
commit cda626be10
4 changed files with 176 additions and 13 deletions
+31 -7
View File
@@ -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 <video> playing audio in the background.
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body> (round 2, P5). The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount is dropped — it used to
@@ -291,6 +301,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
placement: "top-end",
offset: 8,
// ↑/↓ are the volume wherever the focus is in the player, the ⋯ included.
openOnArrowKey: false,
});
const tracksPop = useAnchoredPopover({
open: tracksOpen,
@@ -298,6 +310,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
placement: "top-end",
offset: 8,
fitHeight: true,
openOnArrowKey: false,
});
const tracksFade = useScrollFade();
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
@@ -824,6 +837,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const go = useCallback(
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>, fromZero = false) => {
if (otherId && otherId === id) {
// The item already playing (the info overlay's collection strip lists the film itself):
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1).
if (fromZero) seekTo(0);
videoRef.current?.play().catch(() => {});
return;
}
if (otherId) {
startAtZeroRef.current = fromZero ? otherId : null;
// A played-through item settles as watched, with no checkpoint at the credits (see
@@ -850,7 +871,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
setId(otherId);
}
},
[settleProgress, finishProgress],
[id, seekTo, settleProgress, finishProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
@@ -1696,11 +1717,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={(audioOrd ?? 0) === a.ord}
label={a.label}
onSelect={() => changeAudio(a.ord)}
className={`${PLAYER_ROW} truncate ${
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{a.label}</span>
</PopoverItem>
))}
</div>
@@ -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")}
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
@@ -1726,11 +1749,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd === sub.ord}
label={sub.label}
onSelect={() => changeSubtitle(sub.ord)}
className={`${PLAYER_ROW} truncate ${
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
{sub.label}
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{sub.label}</span>
</PopoverItem>
))}
</div>