fix(player): the player's own focus grabs leave a tabbed-to control alone

Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and
leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had
tabbed to a control right after opening started over from the first button on the next Tab.
They now take it back only when it sits in the iframe or off the card.
This commit is contained in:
2026-10-10 01:14:31 +02:00
parent c003a48244
commit 3438aeb695
5 changed files with 80 additions and 23 deletions
+15 -15
View File
@@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise<YTNamespace> {
export function useYtVolume({
playerRef,
dialogRef,
focusModal,
reclaimFocus,
}: {
playerRef: RefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
reclaimFocus: () => void;
}): {
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
volumeUi: number | null;
@@ -168,7 +168,7 @@ export function useYtVolume({
)
return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
reclaimFocus(); // re-grab the focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
});
useEffect(() => {
@@ -204,11 +204,11 @@ export function useYtVolume({
export function useNativeControlsYield({
playerRef,
stageRef,
focusModal,
reclaimFocus,
}: {
playerRef: RefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
reclaimFocus: () => void;
}): {
nativeControls: boolean;
setNativeControls: (on: boolean) => void;
@@ -221,7 +221,7 @@ export function useNativeControlsYield({
});
const rearmOurControls = useEffectEvent(() => {
setNativeControls(false);
focusModal();
reclaimFocus();
});
useEffect(() => {
// activeElement settles on the tick after blur, so defer the check.
@@ -249,11 +249,11 @@ export function useNativeControlsYield({
export function useYtBigView({
stageRef,
playerRef,
focusModal,
reclaimFocus,
}: {
stageRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
focusModal: () => void;
reclaimFocus: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
@@ -299,7 +299,7 @@ export function useYtBigView({
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
if (!fsEl) reclaimFocus();
});
useEffect(() => {
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
@@ -348,7 +348,7 @@ export function useYtProgress({
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
reclaimFocus,
advanceOnEnd,
sessionPosRef,
qc,
@@ -364,7 +364,7 @@ export function useYtProgress({
setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void;
focusModal: () => void;
reclaimFocus: () => void;
advanceOnEnd: () => void;
// Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not
@@ -382,7 +382,7 @@ export function useYtProgress({
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
reclaimFocus,
advanceOnEnd,
qc,
}));
@@ -396,7 +396,7 @@ export function useYtProgress({
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
reclaimFocus,
advanceOnEnd,
qc,
} = itemSnapshot();
@@ -458,7 +458,7 @@ export function useYtProgress({
},
events: {
onReady: () => {
focusModal();
reclaimFocus();
reapplyStored();
},
onStateChange: (e: YTPlayerEvent) => {
@@ -554,7 +554,7 @@ export function useYtProgress({
// and refs (which never change).
//
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
// `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
// `reclaimFocus`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
// player is built: they describe the video it was built for, and re-reading them mid-session
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What