diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 66fc8986..8bc2ea07 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -50,6 +50,8 @@ beforeAll(() => { // --- the fake IFrame API ------------------------------------------------------------------------ const clock = { current: 0, duration: 3000 }; const started: { videoId: string; start: number | undefined }[] = []; +// The last player's event handlers, so a test can say "the player is ready" when it wants to. +let playerEvents: { onReady?: () => void } = {}; const base = { getCurrentTime: () => clock.current, getDuration: () => clock.duration, @@ -61,8 +63,12 @@ const fakePlayer = new Proxy(base, { get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined), }); window.YT = { - Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) { + Player: function ( + _el: unknown, + opts: { videoId: string; playerVars: { start?: number }; events?: { onReady?: () => void } }, + ) { started.push({ videoId: opts.videoId, start: opts.playerVars.start }); + playerEvents = opts.events ?? {}; return fakePlayer; } as never, }; @@ -331,6 +337,40 @@ describe("PlayerModal — the dialog contract", () => { } }); + // The card takes the focus back on its own when it may have slipped into the iframe: the player + // turning ready, the pointer leaving the video, the window regaining focus. None of those may take + // it off a control the viewer tabbed to, or their next Tab starts over from the first button. + it.each([ + ["the player turns ready", () => playerEvents.onReady?.()], + [ + "the pointer leaves the video", + () => { + fireEvent.mouseLeave(document.querySelector(".player-stage")!); + }, + ], + [ + "the window regains focus", + () => { + fireEvent.focus(window); + }, + ], + ])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => { + open(); + await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); + const control = within(screen.getByTestId("player-modal")).getByTestId("player-close"); + control.focus(); + act(happen); + expect(document.activeElement).toBe(control); + }); + + it("takes the focus back from the iframe when the player turns ready", async () => { + open(); + await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function")); + (document.activeElement as HTMLElement).blur(); + act(() => playerEvents.onReady?.()); + expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true); + }); + it("keeps Tab inside the player", () => { open(); const dialog = screen.getByTestId("player-modal"); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 9d81e669..6001f090 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -242,6 +242,22 @@ export default function PlayerModal({ // useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their // dep lists too. const focusModal = useCallback(() => cardRef.current?.focus(), []); + // What the player does on its own (turning ready, the pointer leaving the video, the window + // regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have + // slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted + // their Tab from the first button. + const reclaimFocus = useCallback(() => { + const card = cardRef.current; + const active = document.activeElement; + if ( + card && + active instanceof HTMLElement && + active.tagName !== "IFRAME" && + card.contains(active) + ) + return; + card?.focus(); + }, []); // The card fits the window, video AND what sits under it (the queue strip, title, channel row): the // video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a // full-width card ran past the bottom and cut its channel row. `below` is that chrome's @@ -300,12 +316,12 @@ export default function PlayerModal({ const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({ playerRef, dialogRef, - focusModal, + reclaimFocus, }); const { nativeControls, setNativeControls } = useNativeControlsYield({ playerRef, stageRef, - focusModal, + reclaimFocus, }); const { isFullscreen, @@ -315,7 +331,7 @@ export default function PlayerModal({ toggleTrueFullscreen, exitBigView, setMaximized, - } = useYtBigView({ stageRef, playerRef, focusModal }); + } = useYtBigView({ stageRef, playerRef, reclaimFocus }); // The player can navigate to other videos (YouTube links in a description). The // currently-playing id may differ from the active item. @@ -550,7 +566,8 @@ export default function PlayerModal({ // Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer` // scope) so the help sheet lists them; only the behaviour behind each id is here. // These fire only while focus is on our page, not inside the cross-origin player iframe — so - // we focus the modal card on open and again on player-ready (autoplay can grab focus). + // we focus the modal card on open, and take it back on player-ready (autoplay can grab focus) + // unless the viewer has since tabbed to a control (`reclaimFocus`). // // The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads // whatever is current at the moment a key arrives, which is what the disabled dep list used to @@ -654,7 +671,7 @@ export default function PlayerModal({ setPlayerError, setLiveData, reapplyStored, - focusModal, + reclaimFocus, advanceOnEnd, sessionPosRef, qc, diff --git a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx index d2a3d327..de4adb42 100644 --- a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx @@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) { setPlayerError, setLiveData: () => {}, reapplyStored: () => {}, - focusModal: () => {}, + reclaimFocus: () => {}, advanceOnEnd: () => {}, sessionPosRef: { current: new Map() }, qc: new QueryClient(), diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx index 6d45f7ec..dbc10ceb 100644 --- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx @@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map) { setPlayerError: () => {}, setLiveData: () => {}, reapplyStored: () => {}, - focusModal: () => {}, + reclaimFocus: () => {}, advanceOnEnd: () => {}, sessionPosRef: { current: sessionPos }, qc, diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index e99411a7..1320f597 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise { export function useYtVolume({ playerRef, dialogRef, - focusModal, + reclaimFocus, }: { playerRef: RefObject; dialogRef: RefObject; - 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; stageRef: RefObject; - 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; playerRef: RefObject; - 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