diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d342c2e..2b6995b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -211,6 +211,7 @@ import { } from "./api" import { useSettings } from "./composables/useSettings" import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" +import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation" import { useMediaWebSocket } from "./composables/useMediaWebSocket" import Header from "./components/Header.vue" import CollageHero from "./components/CollageHero.vue" @@ -219,7 +220,13 @@ import MediaDetail from "./components/MediaDetail.vue" import type { SearchResultItem, SearchResponseMessage } from "./search-worker" // Initialize keyboard navigation -const { getFocusState, restoreFocusState, focusElement } = useKeyboardNavigation() +const { + getFocusState, + restoreFocusState, + focusElement, + snapshotSyncedRowScroll, + restoreSyncedRowScroll, +} = useKeyboardNavigation() const router = useRouter() const route = useRoute() @@ -674,6 +681,23 @@ const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([]) const focusStateMap = new Map() // Track the last viewed item ID to restore focus to the right card const lastViewedItemId = ref(null) +let browseScrollSnapshot: { panelTop: number; rows: SyncedRowScrollSnapshot } | null = null + +function captureBrowseScrollSnapshot() { + browseScrollSnapshot = { + panelTop: browsePanelRef.value?.scrollTop ?? 0, + rows: snapshotSyncedRowScroll(), + } +} + +function restoreBrowseScrollSnapshot() { + if (!browseScrollSnapshot) return + if (browsePanelRef.value) { + browsePanelRef.value.scrollTop = browseScrollSnapshot.panelTop + } + restoreSyncedRowScroll(browseScrollSnapshot.rows) + browseScrollSnapshot = null +} // Save current focus state for a page function saveFocusForPage(page: string) { @@ -693,22 +717,24 @@ function restoreFocusForPage(page: string) { if (lastViewedItemId.value) { // Use nextTick + timeout to ensure DOM is updated after navigation setTimeout(() => { + restoreBrowseScrollSnapshot() const itemId = lastViewedItemId.value // Find the element with matching item id const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null if (element) { - focusElement(element) + focusElement(element, { preserveScroll: true }) lastViewedItemId.value = null return } // Fallback to saved focus state const state = focusStateMap.get(page) - restoreFocusState(state || null) + restoreFocusState(state || null, { preserveScroll: true }) lastViewedItemId.value = null }, 100) } else { + restoreBrowseScrollSnapshot() const state = focusStateMap.get(page) - restoreFocusState(state || null) + restoreFocusState(state || null, { preserveScroll: true }) } } @@ -1025,6 +1051,12 @@ function showDetail(item: MediaItem) { const currentPage = route.path === "/series" ? "series" : "movies" saveFocusForPage(currentPage) + // Capture the exact browse scroll positions to restore on return, + // but only when leaving the browse page (not for detail-to-detail hops) + if (!isDetailOpen.value) { + captureBrowseScrollSnapshot() + } + // Check if there are matched episodes to focus on if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) { const firstMatch = item.searchMatchInfo.matchedEpisodes[0] diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts index 964a34d..06ec321 100644 --- a/frontend/src/composables/useKeyboardNavigation.ts +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -702,7 +702,7 @@ function findNextElement( } } -function focusElement(element: HTMLElement | null) { +function focusElement(element: HTMLElement | null, options?: { preserveScroll?: boolean }) { if (!element) return if (!isElementInActiveScope(element)) return @@ -714,8 +714,10 @@ function focusElement(element: HTMLElement | null) { element.classList.add("nav-focused") element.focus({ preventScroll: true }) - ensureElementVisibleVertically(element) - syncRowsToElement(element) + if (!options?.preserveScroll) { + ensureElementVisibleVertically(element) + syncRowsToElement(element) + } focusedElement.value = element } @@ -727,13 +729,16 @@ function getFocusState(): { row: number; col: number } | null { return { row, col } } -function restoreFocusState(state: { row: number; col: number } | null) { +function restoreFocusState( + state: { row: number; col: number } | null, + options?: { preserveScroll?: boolean }, +) { if (!state) return const target = findElementAt(state.row, state.col) if (target) { setTimeout(() => { - focusElement(target.element) + focusElement(target.element, options) }, 50) } } @@ -893,6 +898,32 @@ export function installKeyboardNavigation() { }) } +export interface SyncedRowScrollSnapshot { + rows: [HTMLElement, number][] + offset: number + targetOffset: number +} + +function snapshotSyncedRowScroll(): SyncedRowScrollSnapshot { + return { + rows: getSyncedRows().map((row) => [row, row.scrollLeft]), + offset: syncedRowsCurrentOffset, + targetOffset: syncedRowsTargetOffset, + } +} + +function restoreSyncedRowScroll(snapshot: SyncedRowScrollSnapshot) { + stopSyncedRowAnimation() + for (const [row, scrollLeft] of snapshot.rows) { + if (row.isConnected) { + row.scrollLeft = scrollLeft + } + } + syncedRowsCurrentOffset = snapshot.offset + syncedRowsTargetOffset = snapshot.targetOffset + lastSyncedRowsAnimationAt = null +} + export function useKeyboardNavigation() { return { focusedElement, @@ -901,6 +932,8 @@ export function useKeyboardNavigation() { focusAt, getFocusState, restoreFocusState, + snapshotSyncedRowScroll, + restoreSyncedRowScroll, } }