fix(player): a held key on faded bands stays one blind press; a play asked for mid-restart is the new session's
The repeats of a Space/Enter that landed on faded bands pressed the control once the bands were up; they are swallowed with it. A play or pause while a session restart is out now sets that session's intent instead of playing the outgoing stream. Tests: Skip intro outside the bands keeps its keys, every restore takeover (wheel, finger, scrollbar, key) is covered, and the chip handoff test tells the chip before from the row's first control.
This commit is contained in:
@@ -178,6 +178,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
|
||||
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
|
||||
const clockLiveRef = useRef(false);
|
||||
// Whether the session being asked for plays once it can — null while none is out. Set from
|
||||
// loadSession's `resumePlay` and then the viewer's to change: until the answer attaches, the media
|
||||
// is the OUTGOING one, and a play() there sounded the old stream while the new one still attached
|
||||
// paused (E28 review round 4). `said`: the viewer changed it, so it is what they now see.
|
||||
const pendingPlayRef = useRef<{ play: boolean; said?: boolean } | null>(null);
|
||||
// The key of the last press that landed on faded bands, while it is held (see onKey).
|
||||
const blindKeyRef = useRef<string | null>(null);
|
||||
// The item whose session is attached to the <video> right now (see loadSession's failure path).
|
||||
const attachedIdRef = useRef<string | null>(null);
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
@@ -393,6 +400,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
};
|
||||
};
|
||||
clockLiveRef.current = false;
|
||||
const intent: { play: boolean; said?: boolean } = { play: resumePlay };
|
||||
pendingPlayRef.current = intent;
|
||||
// Answered (or failed): the media from here on is this session's, and a Space is its own.
|
||||
const settled = () => {
|
||||
if (pendingPlayRef.current === intent) pendingPlayRef.current = null;
|
||||
};
|
||||
loadStartRef.current = startAt;
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
@@ -409,6 +422,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
|
||||
} catch (e) {
|
||||
if (!current()) return; // superseded or left: not this request's error to show
|
||||
settled();
|
||||
// A failed seek-restart leaves the old session attached and its clock still true; a failed
|
||||
// move to another item leaves the previous item's media, which is not this item's clock.
|
||||
clockLiveRef.current = attachedIdRef.current === id;
|
||||
@@ -443,19 +457,19 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// timeupdate its teardown queues already reads the new start.
|
||||
clockLiveRef.current = true;
|
||||
attachedIdRef.current = id;
|
||||
settled();
|
||||
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
|
||||
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
|
||||
// the video paused after a seek even though the user was watching. Retry once the element can
|
||||
// actually play. Idempotent — only acts if still paused.
|
||||
if (resumePlay) {
|
||||
listen(
|
||||
"canplay",
|
||||
() => {
|
||||
if (videoRef.current?.paused) videoRef.current.play().catch(() => {});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
// actually play. Idempotent — only acts if still paused. The intent is read when it fires: the
|
||||
// viewer may have asked for play or pause since the request went out.
|
||||
listen(
|
||||
"canplay",
|
||||
() => {
|
||||
if (intent.play && videoRef.current?.paused) videoRef.current.play().catch(() => {});
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
@@ -491,7 +505,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
parsed = true;
|
||||
setReady(true);
|
||||
if (aliveRef.current && resumePlay) video.play().catch(() => {});
|
||||
if (aliveRef.current && intent.play) video.play().catch(() => {});
|
||||
});
|
||||
// Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on
|
||||
// MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and
|
||||
@@ -567,7 +581,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
|
||||
void loadSessionRef.current(
|
||||
playhead(absRef, loadStartRef),
|
||||
parsed ? !video.paused : resumePlay,
|
||||
parsed ? !video.paused : intent.play,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -583,7 +597,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
() => {
|
||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||
setReady(true);
|
||||
if (resumePlay) video.play().catch(() => {});
|
||||
if (intent.play) video.play().catch(() => {});
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -826,12 +840,30 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
|
||||
// --- controls --------------------------------------------------------------------------------
|
||||
// While a session is being asked for, play and pause are the pending session's intent (see
|
||||
// pendingPlayRef): the outgoing media is never played, only paused if the viewer asks for a pause.
|
||||
const requestPlay = useCallback((want: boolean) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const pending = pendingPlayRef.current;
|
||||
if (pending) {
|
||||
pending.play = want;
|
||||
pending.said = true;
|
||||
if (!want && !v.paused) v.pause();
|
||||
setPlaying(want);
|
||||
return;
|
||||
}
|
||||
if (want) v.play().catch(() => {});
|
||||
else v.pause();
|
||||
}, []);
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) v.play().catch(() => {});
|
||||
else v.pause();
|
||||
}, []);
|
||||
// What the viewer sees: the outgoing media's state, until they have said otherwise.
|
||||
const pending = pendingPlayRef.current;
|
||||
const shown = pending?.said ? pending.play : !v.paused;
|
||||
requestPlay(!shown);
|
||||
}, [requestPlay]);
|
||||
|
||||
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
|
||||
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
|
||||
@@ -858,7 +890,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
|
||||
// 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(() => {});
|
||||
else requestPlay(true);
|
||||
return;
|
||||
}
|
||||
if (otherId) {
|
||||
@@ -887,7 +919,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
setId(otherId);
|
||||
}
|
||||
},
|
||||
[id, seekTo, settleProgress, finishProgress],
|
||||
[id, seekTo, requestPlay, settleProgress, finishProgress],
|
||||
);
|
||||
|
||||
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
|
||||
@@ -1150,15 +1182,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
isActivationTarget(e) &&
|
||||
!!(e.target as Element | null)?.closest?.("[data-player-band]") &&
|
||||
(e.key === " " || e.key === "Enter");
|
||||
// Held, the key repeats — and by its first repeat the bands are up, so the repeat would press
|
||||
// the control after all (Enter clicks on every repeat, Space re-arms its keyup click). A held
|
||||
// key is one press (keymap.ts): its repeats are as blind as it was.
|
||||
if (!e.repeat) blindKeyRef.current = blind ? e.key : null;
|
||||
const swallow = blind || (e.repeat && e.key === blindKeyRef.current);
|
||||
// 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
|
||||
// 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).
|
||||
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
|
||||
wake();
|
||||
if (blind) {
|
||||
if (swallow) {
|
||||
e.preventDefault();
|
||||
if (e.key === " ") togglePlay();
|
||||
if (e.key === " " && !e.repeat) togglePlay();
|
||||
return;
|
||||
}
|
||||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||||
|
||||
Reference in New Issue
Block a user