From 931f00f647459ac0a5256137ef105cda60e40647 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 21:30:17 +0000 Subject: [PATCH] Fix season browser focus: land on first episode on down, ignore parked-cursor hover - Add data-nav-entry-col-from-above so moving down from the season selector always focuses the first episode instead of the tile visually closest to the selected season poster; moving back up still returns to the current season. - Require recent real mouse movement before treating mouseover as mouse intent: scroll/re-render under a stationary cursor no longer activates mouse mode, so sideways season browsing no longer loses focus to the episode tile under the parked pointer. --- frontend/src/components/SeriesFullView.vue | 3 +++ frontend/src/composables/useInputModality.ts | 10 +++++++++ .../src/composables/useKeyboardNavigation.ts | 21 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 01ef34b..8a1f75a 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -548,6 +548,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) { ...navAttrs(coords.row, coords.col), "data-season-index": seasonIndex, "data-episode-index": episodeIndex, + // Entering the episode grid from above (the season selector) always + // lands on the first episode instead of the visually closest tile. + ...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}), } } diff --git a/frontend/src/composables/useInputModality.ts b/frontend/src/composables/useInputModality.ts index 624668d..f022e3a 100644 --- a/frontend/src/composables/useInputModality.ts +++ b/frontend/src/composables/useInputModality.ts @@ -6,6 +6,7 @@ type InputModality = "mouse" | "keyboard" | "gamepad" const MOUSE_IDLE_MS = 1400 const MOUSE_INTENT_DISTANCE_PX = 28 const MOUSE_INTENT_WINDOW_MS = 700 +const MOUSE_OVER_INTENT_RECENCY_MS = 500 const MOUSE_INTENT_SELECTOR = [ "[data-nav-focusable]", "button", @@ -28,6 +29,7 @@ let mouseIdleTimer: number | null = null let pointerVisible = false let mouseTravelPx = 0 let lastMouseMoveAt = 0 +let lastAnyMouseMoveAt = 0 function clearMouseIdleTimer() { if (mouseIdleTimer !== null) { @@ -95,6 +97,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean { function handleMouseMove(event: MouseEvent) { reportUserActivity() showPointerFromMotion() + lastAnyMouseMoveAt = performance.now() if (modality === "mouse") { applyInputState(true) @@ -111,6 +114,13 @@ function handleMouseOver(event: MouseEvent) { if (modality === "mouse") return if (!isMouseIntentTarget(event.target)) return + // Browsers fire mouseover/mouseenter when scrolling or re-rendering moves + // content under a stationary cursor (e.g. sideways season browsing while + // the pointer happens to rest over the episode grid). Without recent real + // pointer motion this is not mouse intent — activating mouse input here + // would let hover handlers steal keyboard/gamepad focus. + if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return + // Entering an interactive target indicates likely mouse intent. activateMouseInput() } diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts index 06ec321..35c1be6 100644 --- a/frontend/src/composables/useKeyboardNavigation.ts +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable" const ROW_ATTR = "data-nav-row" const COL_ATTR = "data-nav-col" const ENTRY_COL_ATTR = "data-nav-entry-col" +// Like ENTRY_COL_ATTR, but only applies when entering the row from above +// (ArrowDown). Lets a row declare a fixed landing column for downward entry +// without hijacking upward or horizontal moves. +const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above" const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row" const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group" const DEFAULT_SYNC_SCROLL_GROUP = "browse" @@ -672,8 +676,23 @@ function findNextElement( desiredCol.value = currentCol } - const entryTarget = findElementAt(targetRow, targetCol, true) const targetRowElements = byRow.get(targetRow) ?? [] + + if (direction === "down") { + for (const el of targetRowElements) { + const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR) + if (fromAboveCol === null) continue + const fromAboveTarget = targetRowElements.find( + (e) => e.col === parseInt(fromAboveCol, 10), + ) + if (fromAboveTarget) { + desiredCol.value = fromAboveTarget.col + return fromAboveTarget.element + } + } + } + + const entryTarget = findElementAt(targetRow, targetCol, true) const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR)) if (hasEntryOverride) { return entryTarget?.element || null