From 4926b72167f00f900ab2beb48423fd7ab11c8621 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 05:35:56 +0000 Subject: [PATCH] Add adjacent detail navigation for keyboard and gamepad --- frontend/src/App.vue | 116 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a72a5b0..0b4c972 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -636,6 +636,82 @@ function onGamepadAction(event: Event) { } } +function getSearchQueryFromPath(path: string | null): string { + if (!path || !path.startsWith("/search/")) return "" + const encoded = path.slice("/search/".length) + if (!encoded) return "" + try { + return normalizeSearchQuery(decodeURIComponent(encoded)) + } catch { + return normalizeSearchQuery(encoded) + } +} + +const searchResultsCache = new Map() + +function getSearchResultsForTerm(term: string): MediaItem[] { + const key = normalizeSearchQuery(term).toLowerCase() + if (!key) return [] + + const liveQuery = normalizeSearchQuery(searchQuery.value).toLowerCase() + if (liveQuery === key && searchResults.value.length > 0) { + return searchResults.value + } + + return searchResultsCache.get(key) || [] +} + +function getBrowseSequenceForItem(item: MediaItem): MediaItem[] { + if (item.type === "movies") { + return moviesByGenre.value.flatMap((category) => category.items) + } + return seriesByGenre.value.flatMap((category) => category.items) +} + +function getDetailAdjacentSequence(item: MediaItem): MediaItem[] { + const detailSearchPath = getDetailSearchPath() + if (detailSearchPath) { + const term = getSearchQueryFromPath(detailSearchPath) + const searchItems = getSearchResultsForTerm(term) + if (searchItems.length > 0) { + return searchItems + } + } + + return getBrowseSequenceForItem(item) +} + +function showAdjacentDetail(offset: -1 | 1): boolean { + const current = selectedItem.value + if (!current) return false + + const sequence = getDetailAdjacentSequence(current) + if (sequence.length < 2) return false + + const currentIndex = sequence.findIndex((item) => item.id === current.id && item.type === current.type) + if (currentIndex < 0) return false + + const nextIndex = currentIndex + offset + if (nextIndex < 0 || nextIndex >= sequence.length) return false + + showDetail(sequence[nextIndex]) + return true +} + +function onRawGamepadButton(event: Event) { + if (event.defaultPrevented || isMpcBeGamepadCaptured()) return + if (!selectedItem.value) return + + const customEvent = event as CustomEvent<{ button?: number }> + const button = customEvent.detail?.button + if (button !== 4 && button !== 5) return + + const moved = showAdjacentDetail(button === 4 ? -1 : 1) + if (moved) { + event.preventDefault() + } +} + // Focus episode info for navigating to series detail from search const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null) @@ -851,6 +927,26 @@ function handleEscapeKey(event: KeyboardEvent) { // From movies list -> do nothing (stop here) } +function handleDetailAdjacentKey(event: KeyboardEvent) { + if (!selectedItem.value) return + + // Don't hijack typing/navigation in editable controls. + const target = event.target as HTMLElement | null + if (target) { + const tag = target.tagName + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable) { + return + } + } + + if (event.key !== "PageUp" && event.key !== "PageDown") return + + const moved = showAdjacentDetail(event.key === "PageUp" ? -1 : 1) + if (moved) { + event.preventDefault() + } +} + onMounted(() => { void refreshPlayerStatus() void refreshResumePositions() @@ -858,12 +954,16 @@ onMounted(() => { detailPanelScrollTop.value = detailPanelRef.value?.scrollTop || 0 setActiveNavigationScope(isDetailOpen.value ? "detail" : "browse") document.addEventListener("keydown", handleEscapeKey) + document.addEventListener("keydown", handleDetailAdjacentKey) window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) + window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) }) onUnmounted(() => { document.removeEventListener("keydown", handleEscapeKey) + document.removeEventListener("keydown", handleDetailAdjacentKey) window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) + window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) stopMpcBePolling() }) @@ -994,7 +1094,10 @@ function showDetail(item: MediaItem) { router.push({ path: `/series/${epData.series.id}`, state: searchPath ? { searchPath } : undefined }) } } else { - const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null + const detailSearchPath = getDetailSearchPath() + const searchPath = searchQuery.value + ? getSearchPath(searchQuery.value) + : detailSearchPath router.push({ path: `/${item.type}/${item.id}`, state: searchPath ? { searchPath } : undefined }) } } @@ -1015,7 +1118,10 @@ function handleActorSearch(actorName: string) { } function handleSelectMovieFromDetail(movieId: string) { - const searchPath = searchQuery.value ? getSearchPath(searchQuery.value) : null + const detailSearchPath = getDetailSearchPath() + const searchPath = searchQuery.value + ? getSearchPath(searchQuery.value) + : detailSearchPath router.push({ path: `/movies/${movieId}`, state: searchPath ? { searchPath } : undefined }) } @@ -1426,6 +1532,12 @@ function runSearch() { }) } +watch([searchQuery, searchResults], ([query, results]) => { + const key = normalizeSearchQuery(query).toLowerCase() + if (!key || results.length === 0) return + searchResultsCache.set(key, [...results]) +}) + watch(searchQuery, runSearch) watch(mediaIndex, () => { workerHasIndex = false