fix(player): a Restart of the paused film plays the new session; a key on faded bands only shows them
The same-film restart read the paused state, restarted the session without play, and resumed the outgoing stream beside it. Space or Enter on a control the bands had faded from pressed it unseen; now it only brings them back, and Space plays or pauses.
This commit is contained in:
@@ -273,6 +273,46 @@ describe("keyboard focus in the bands", () => {
|
|||||||
expect(bandHidden()).toBe(false);
|
expect(bandHidden()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a Space on a control the bands faded from only brings them back, and plays or pauses", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
video()._paused = true;
|
||||||
|
act(() => void video().play());
|
||||||
|
const mute = screen.getByLabelText("plex.player.mute");
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||||
|
act(() => mute.focus());
|
||||||
|
fireEvent.keyDown(mute, { key: "ArrowUp" });
|
||||||
|
act(() => vi.advanceTimersByTime(10_500));
|
||||||
|
expect(bandHidden()).toBe(true);
|
||||||
|
const pauses = pause.mock.calls.length;
|
||||||
|
// prevented: the unseen Mute is not pressed on keyup
|
||||||
|
expect(fireEvent.keyDown(mute, { key: " " })).toBe(false);
|
||||||
|
expect(bandHidden()).toBe(false);
|
||||||
|
expect(pause.mock.calls.length).toBe(pauses + 1);
|
||||||
|
// the bands are up again: now Space is the focused control's own
|
||||||
|
expect(fireEvent.keyDown(mute, { key: " " })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an Enter on a control the bands faded from only brings them back", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
video()._paused = true;
|
||||||
|
act(() => void video().play());
|
||||||
|
const mute = screen.getByLabelText("plex.player.mute");
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||||
|
act(() => mute.focus());
|
||||||
|
fireEvent.keyDown(mute, { key: "ArrowUp" });
|
||||||
|
act(() => vi.advanceTimersByTime(10_500));
|
||||||
|
expect(bandHidden()).toBe(true);
|
||||||
|
const pauses = pause.mock.calls.length;
|
||||||
|
expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false);
|
||||||
|
expect(bandHidden()).toBe(false);
|
||||||
|
expect(pause.mock.calls.length).toBe(pauses);
|
||||||
|
expect(video().paused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("a tap leaving a tapped button for the picture shows the hidden bands", async () => {
|
it("a tap leaving a tapped button for the picture shows the hidden bands", async () => {
|
||||||
mount({ details: { A: item("A") } });
|
mount({ details: { A: item("A") } });
|
||||||
await playingAt("/media/A", 100);
|
await playingAt("/media/A", 100);
|
||||||
@@ -423,6 +463,55 @@ describe("a collection sibling in the info overlay", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a Restart of the paused film whose session starts past 0", () => {
|
||||||
|
it("plays the new session, and never the one it replaces", { timeout: 10000 }, async () => {
|
||||||
|
const self: PlexItemDetail = {
|
||||||
|
...item("A"),
|
||||||
|
cast: [],
|
||||||
|
genres: [],
|
||||||
|
directors: [],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
title: "Saga",
|
||||||
|
source: "plex",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "A",
|
||||||
|
type: "movie",
|
||||||
|
title: "Title A",
|
||||||
|
thumb: "",
|
||||||
|
status: "in_progress",
|
||||||
|
position_seconds: 1500,
|
||||||
|
duration_seconds: 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as PlexItemDetail;
|
||||||
|
const { session } = mount({ details: { A: self } });
|
||||||
|
// A session opened at the resume point: 0 is outside it, so a restart asks for a new one.
|
||||||
|
session.mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ ...direct(id), start_s: 1500 } as Awaited<
|
||||||
|
ReturnType<typeof api.plexSession>
|
||||||
|
>),
|
||||||
|
);
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
const asked = session.mock.calls.length;
|
||||||
|
act(() => void video().pause());
|
||||||
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||||
|
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
||||||
|
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
|
||||||
|
const plays = play.mock.calls.length;
|
||||||
|
fireEvent.click(restart);
|
||||||
|
await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1));
|
||||||
|
expect(lastStart(session)).toEqual(["A", 0]);
|
||||||
|
expect(play.mock.calls.length).toBe(plays); // the outgoing stream stays paused
|
||||||
|
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
|
||||||
|
expect(play.mock.calls.length).toBe(plays + 1); // and the new one plays
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the C5 row", () => {
|
describe("the C5 row", () => {
|
||||||
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
|
it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => {
|
||||||
mount({ details: { A: item("A") } });
|
mount({ details: { A: item("A") } });
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||||
const {
|
const {
|
||||||
uiVisible,
|
uiVisible,
|
||||||
|
controlsShown,
|
||||||
wake,
|
wake,
|
||||||
holdOnHover,
|
holdOnHover,
|
||||||
holdOnFocus,
|
holdOnFocus,
|
||||||
@@ -277,6 +278,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
|
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
|
||||||
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
|
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
|
||||||
const holdWhileHovered = {
|
const holdWhileHovered = {
|
||||||
|
"data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below)
|
||||||
onPointerEnter: () => holdOnHover(true),
|
onPointerEnter: () => holdOnHover(true),
|
||||||
onPointerLeave: () => holdOnHover(false),
|
onPointerLeave: () => holdOnHover(false),
|
||||||
onFocus: (e: React.FocusEvent<HTMLElement>) => holdOnFocus(e.target.matches(":focus-visible")),
|
onFocus: (e: React.FocusEvent<HTMLElement>) => holdOnFocus(e.target.matches(":focus-visible")),
|
||||||
@@ -792,10 +794,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
|
|
||||||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||||
const seekTo = useCallback(
|
const seekTo = useCallback(
|
||||||
(target: number) => {
|
(target: number, play?: boolean) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
|
// Play/pause intent across the seek: the caller's when it has one (a Restart plays), else
|
||||||
|
// whatever the video was doing.
|
||||||
|
const wasPlaying = play ?? !video.paused;
|
||||||
const dur = durationRef.current || target;
|
const dur = durationRef.current || target;
|
||||||
const T = Math.max(0, Math.min(target, dur - 1));
|
const T = Math.max(0, Math.min(target, dur - 1));
|
||||||
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
||||||
@@ -813,6 +817,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5
|
rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5
|
||||||
) {
|
) {
|
||||||
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
|
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
|
||||||
|
if (play) video.play().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
|
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
|
||||||
}
|
}
|
||||||
@@ -849,9 +854,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
if (otherId && otherId === id) {
|
if (otherId && otherId === id) {
|
||||||
// The item already playing (the info overlay's collection strip lists the film itself):
|
// 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
|
// 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).
|
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). The play
|
||||||
if (fromZero) seekTo(0);
|
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
|
||||||
videoRef.current?.play().catch(() => {});
|
// and a play() beside that resumed the outgoing stream while the new one stayed paused.
|
||||||
|
if (fromZero) seekTo(0, true);
|
||||||
|
else videoRef.current?.play().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (otherId) {
|
if (otherId) {
|
||||||
@@ -1134,12 +1141,26 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
go(id);
|
go(id);
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// A Space or Enter on a control the bands have faded out from under (a key user's focus stays
|
||||||
|
// put when they hide) must not press it unseen — Back leaving the player, Next skipping the
|
||||||
|
// episode. That key only brings the bands back; Space also does what a viewer who sees no
|
||||||
|
// button means by it, and pauses or plays. Read before `wake` below shows them.
|
||||||
|
const blind =
|
||||||
|
!controlsShown() &&
|
||||||
|
isActivationTarget(e) &&
|
||||||
|
!!(e.target as Element | null)?.closest?.("[data-player-band]") &&
|
||||||
|
(e.key === " " || e.key === "Enter");
|
||||||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||||
// A slider is not a text field: its arrows are the remote too (E28 review E6).
|
// A slider is not a text field: its arrows are the remote too (E28 review E6).
|
||||||
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
|
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
|
||||||
wake();
|
wake();
|
||||||
|
if (blind) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.key === " ") togglePlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||||||
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
||||||
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
||||||
@@ -1256,6 +1277,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
nextId,
|
nextId,
|
||||||
onClose,
|
onClose,
|
||||||
wake,
|
wake,
|
||||||
|
controlsShown,
|
||||||
handleBack,
|
handleBack,
|
||||||
skipProgressRef,
|
skipProgressRef,
|
||||||
cancelAutoSkipRef,
|
cancelAutoSkipRef,
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export function useControlsVisibility(opts: {
|
|||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
}): {
|
}): {
|
||||||
uiVisible: boolean;
|
uiVisible: boolean;
|
||||||
|
/** Whether the bands are up right now — for a key handler that runs before the next render. */
|
||||||
|
controlsShown: () => boolean;
|
||||||
wake: () => void;
|
wake: () => void;
|
||||||
holdOnHover: (over: boolean) => void;
|
holdOnHover: (over: boolean) => void;
|
||||||
holdOnFocus: (on: boolean) => void;
|
holdOnFocus: (on: boolean) => void;
|
||||||
@@ -132,6 +134,7 @@ export function useControlsVisibility(opts: {
|
|||||||
[wake, menuOpenRef, setVisible],
|
[wake, menuOpenRef, setVisible],
|
||||||
);
|
);
|
||||||
const toggle = useCallback(() => show(!visibleRef.current), [show]);
|
const toggle = useCallback(() => show(!visibleRef.current), [show]);
|
||||||
|
const controlsShown = useCallback(() => visibleRef.current, []);
|
||||||
const holdOnHover = useCallback(
|
const holdOnHover = useCallback(
|
||||||
(over: boolean) => {
|
(over: boolean) => {
|
||||||
hovered.current = over;
|
hovered.current = over;
|
||||||
@@ -162,7 +165,7 @@ export function useControlsVisibility(opts: {
|
|||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return { uiVisible, wake, holdOnHover, holdOnFocus, toggle, show };
|
return { uiVisible, controlsShown, wake, holdOnHover, holdOnFocus, toggle, show };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
|
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
|
||||||
|
|||||||
Reference in New Issue
Block a user