From 8e0f90f449ee2e766007ec9b374f55d56f608185 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 13 Feb 2026 17:25:32 -0500 Subject: [PATCH] nonworking keybinds --- src/App.tsx | 127 ++++++++++++++++++++++-------- src/config/keybind.jsonc | 2 + src/context/KeybindContext.tsx | 26 +++++- src/hooks/useAudio.ts | 77 ++++++++++++++++++ src/pages/Feed/FeedPage.tsx | 13 ++- src/pages/MyShows/MyShowsPage.tsx | 26 ++---- src/stores/audio-nav.ts | 126 +++++++++++++++++++++++++++++ src/utils/app-persistence.ts | 37 +++++++++ src/utils/keybinds-persistence.ts | 13 ++- 9 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 src/stores/audio-nav.ts diff --git a/src/App.tsx b/src/App.tsx index fbb6da4..07203ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,14 +12,17 @@ import { useToast } from "@/ui/toast"; import { useRenderer } from "@opentui/solid"; import type { AuthScreen } from "@/types/auth"; import type { Episode } from "@/types/episode"; -import { DIRECTION, LayerGraph, TABS } from "./utils/navigation"; +import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation"; import { useTheme, ThemeProvider } from "./context/ThemeContext"; import { KeybindProvider, useKeybinds } from "./context/KeybindContext"; +import { useAudioNavStore, AudioSource } from "./stores/audio-nav"; const DEBUG = import.meta.env.DEBUG; export interface PageProps { depth: Accessor; + focusedIndex?: Accessor | number; + focusedIndexValue?: number; } export function App() { @@ -29,6 +32,8 @@ export function App() { const [showAuthPanel, setShowAuthPanel] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false); const [layerDepth, setLayerDepth] = createSignal(0); + const [focusedIndex, setFocusedIndex] = createSignal(0); + const auth = useAuthStore(); const feedStore = useFeedStore(); const audio = useAudio(); @@ -36,6 +41,7 @@ export function App() { const renderer = useRenderer(); const { theme } = useTheme(); const keybind = useKeybinds(); + const audioNav = useAudioNavStore(); useMultimediaKeys({ playerFocused: () => activeTab() === TABS.PLAYER && layerDepth() > 0, @@ -47,6 +53,7 @@ export function App() { audio.play(episode); setActiveTab(TABS.PLAYER); setLayerDepth(1); + audioNav.setSource(AudioSource.FEED); }; useSelectionHandler((selection: any) => { @@ -64,55 +71,110 @@ export function App() { }); }); - // Handle keyboard input with dynamic keybinds useKeyboard( (keyEvent) => { - const name = keyEvent.name; + const isUp = keybind.match("up", keyEvent); + const isDown = keybind.match("down", keyEvent); + const isLeft = keybind.match("left", keyEvent); + const isRight = keybind.match("right", keyEvent); + const isCycle = keybind.match("cycle", keyEvent); + const isDive = keybind.match("dive", keyEvent); + const isOut = keybind.match("out", keyEvent); + const isToggle = keybind.match("audio-toggle", keyEvent); + const isNext = keybind.match("audio-next", keyEvent); + const isPrev = keybind.match("audio-prev", keyEvent); + const isSeekForward = keybind.match("audio-seek-forward", keyEvent); + const isSeekBackward = keybind.match("audio-seek-backward", keyEvent); + const isQuit = keybind.match("quit", keyEvent); - // Navigation: up/down - if (keybind.match("up", keyEvent) || keybind.match("down", keyEvent)) { - // TODO: Implement navigation logic + if (DEBUG) { + console.log("KeyEvent:", keyEvent); + console.log("Keybinds loaded:", { + up: keybind.keybinds.up, + down: keybind.keybinds.down, + left: keybind.keybinds.left, + right: keybind.keybinds.right, + }); } - // Navigation: left/right - if (keybind.match("left", keyEvent) || keybind.match("right", keyEvent)) { - // TODO: Implement navigation logic + if (isUp || isDown) { + const currentDepth = activeDepth(); + const maxDepth = LayerDepths[activeTab()]; + + console.log("Navigation:", { isUp, isDown, currentDepth, maxDepth }); + + // Navigate within current depth layer + if (currentDepth < maxDepth) { + const newIndex = isUp ? focusedIndex() - 1 : focusedIndex() + 1; + setFocusedIndex(Math.max(0, Math.min(newIndex, maxDepth))); + } } - // Cycle through options - if (keybind.match("cycle", keyEvent)) { - // TODO: Implement cycle logic + // Horizontal movement - move within current layer + if (isLeft || isRight) { + const currentDepth = activeDepth(); + const maxDepth = LayerDepths[activeTab()]; + + if (currentDepth < maxDepth) { + const newIndex = isLeft ? focusedIndex() - 1 : focusedIndex() + 1; + setFocusedIndex(Math.max(0, Math.min(newIndex, maxDepth))); + } } - // Dive into content - if (keybind.match("dive", keyEvent)) { - // TODO: Implement dive logic + // Cycle through current depth + if (isCycle) { + const currentDepth = activeDepth(); + const maxDepth = LayerDepths[activeTab()]; + + if (currentDepth < maxDepth) { + const newIndex = (focusedIndex() + 1) % (maxDepth + 1); + setFocusedIndex(newIndex); + } } - // Out of content - if (keybind.match("out", keyEvent)) { - setActiveDepth((prev) => Math.max(0, prev - 1)); - return; + // Increase depth + if (isDive) { + const currentDepth = activeDepth(); + const maxDepth = LayerDepths[activeTab()]; + + if (currentDepth < maxDepth) { + setActiveDepth(currentDepth + 1); + setFocusedIndex(0); + } } - // Audio controls - if (keybind.match("audio-toggle", keyEvent)) { + // Decrease depth + if (isOut) { + const currentDepth = activeDepth(); + + if (currentDepth > 0) { + setActiveDepth(currentDepth - 1); + setFocusedIndex(0); + } + } + + if (isToggle) { audio.togglePlayback(); - return; } - if (keybind.match("audio-next", keyEvent)) { - audio.seekRelative(30); // Skip forward 30 seconds - return; + if (isNext) { + audio.next(); } - if (keybind.match("audio-prev", keyEvent)) { - audio.seekRelative(-30); // Skip back 30 seconds - return; + if (isPrev) { + audio.prev(); + } + + if (isSeekForward) { + audio.seekRelative(15); + } + + if (isSeekBackward) { + audio.seekRelative(-15); } // Quit application - if (keybind.match("quit", keyEvent)) { + if (isQuit) { process.exit(0); } }, @@ -169,8 +231,11 @@ export function App() { backgroundColor={theme.surface} > - {LayerGraph[activeTab()]({ depth: activeDepth })} - {/**TODO: Contextual controls based on tab/depth**/} + {LayerGraph[activeTab()]({ + depth: activeDepth, + focusedIndex: focusedIndex(), + })} + {/** TODO: Contextual controls based on tab/depth**/} diff --git a/src/config/keybind.jsonc b/src/config/keybind.jsonc index 94d5bb3..0dff2b2 100644 --- a/src/config/keybind.jsonc +++ b/src/config/keybind.jsonc @@ -14,4 +14,6 @@ "audio-play": [], "audio-next": ["n"], "audio-prev": ["l"], + "audio-seek-forward": ["sf"], + "audio-seek-backward": ["sb"], } diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index 1b811c2..d33be69 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -7,8 +7,6 @@ import { } from "../utils/keybinds-persistence"; import { createStore } from "solid-js/store"; -// ── Type Definitions ──────────────────────────────────────────────────────────── - export type KeybindsResolved = { up: string[]; down: string[]; @@ -25,9 +23,27 @@ export type KeybindsResolved = { "audio-play": string[]; "audio-next": string[]; "audio-prev": string[]; + "audio-seek-forward": string[]; + "audio-seek-backward": string[]; }; -// ── Context Implementation ──────────────────────────────────────────────────────────── +export enum KeybindAction { + UP, + DOWN, + LEFT, + RIGHT, + CYCLE, + DIVE, + OUT, + QUIT, + AUDIO_TOGGLE, + AUDIO_PAUSE, + AUDIO_PLAY, + AUDIO_NEXT, + AUDIO_PREV, + AUDIO_SEEK_F, + AUDIO_SEEK_B, +} export const { use: useKeybinds, provider: KeybindProvider } = createSimpleContext({ @@ -49,7 +65,9 @@ export const { use: useKeybinds, provider: KeybindProvider } = "audio-play": [], "audio-next": [], "audio-prev": [], - }); + "audio-seek-forward": [], + "audio-seek-backward": [], + } as KeybindsResolved); const [ready, setReady] = createSignal(false); async function load() { diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 9e76de1..cb33b34 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -25,6 +25,9 @@ import { useAppStore } from "../stores/app" import { useProgressStore } from "../stores/progress" import { useMediaRegistry } from "../utils/media-registry" import type { Episode } from "../types/episode" +import type { Feed } from "../types/feed" +import { useAudioNavStore, AudioSource } from "../stores/audio-nav" +import { useFeedStore } from "../stores/feed" export interface AudioControls { // Signals (reactive getters) @@ -49,6 +52,8 @@ export interface AudioControls { setVolume: (volume: number) => Promise setSpeed: (speed: number) => Promise switchBackend: (name: BackendName) => Promise + prev: () => Promise + next: () => Promise } // Singleton state — shared across all components that call useAudio() @@ -401,6 +406,76 @@ export function useAudio(): AudioControls { await doSetSpeed(next) }) + const audioNav = useAudioNavStore(); + const feedStore = useFeedStore(); + + async function prev(): Promise { + const current = currentEpisode(); + if (!current) return; + + const currentPos = position(); + const currentDur = duration(); + + const NAV_START_THRESHOLD = 30; + + if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { + await seek(NAV_START_THRESHOLD); + } else { + const source = audioNav.getSource(); + let episodes: Array<{ episode: Episode; feed: Feed }> = []; + + if (source === AudioSource.FEED) { + episodes = feedStore.getAllEpisodesChronological(); + } else if (source === AudioSource.MY_SHOWS) { + const podcastId = audioNav.getPodcastId(); + if (!podcastId) return; + + const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); + if (!feed) return; + + episodes = feed.episodes.map(ep => ({ episode: ep, feed })); + } + + const currentIndex = audioNav.getCurrentIndex(); + const newIndex = Math.max(0, currentIndex - 1); + + if (newIndex < episodes.length && episodes[newIndex]) { + const { episode } = episodes[newIndex]; + await play(episode); + audioNav.prev(newIndex); + } + } + } + + async function next(): Promise { + const current = currentEpisode(); + if (!current) return; + + const source = audioNav.getSource(); + let episodes: Array<{ episode: Episode; feed: Feed }> = []; + + if (source === AudioSource.FEED) { + episodes = feedStore.getAllEpisodesChronological(); + } else if (source === AudioSource.MY_SHOWS) { + const podcastId = audioNav.getPodcastId(); + if (!podcastId) return; + + const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId); + if (!feed) return; + + episodes = feed.episodes.map(ep => ({ episode: ep, feed })); + } + + const currentIndex = audioNav.getCurrentIndex(); + const newIndex = Math.min(episodes.length - 1, currentIndex + 1); + + if (newIndex >= 0 && episodes[newIndex]) { + const { episode } = episodes[newIndex]; + await play(episode); + audioNav.next(newIndex); + } + } + onCleanup(() => { refCount-- unsubPlay() @@ -447,5 +522,7 @@ export function useAudio(): AudioControls { setVolume: doSetVolume, setSpeed: doSetSpeed, switchBackend, + prev, + next, } } diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 4d3873e..3d3de33 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -24,7 +24,6 @@ const ITEMS_PER_BATCH = 50; export function FeedPage(props: PageProps) { const feedStore = useFeedStore(); - const [selectedIndex, setSelectedIndex] = createSignal(0); const [isRefreshing, setIsRefreshing] = createSignal(false); const [loadedEpisodesCount, setLoadedEpisodesCount] = createSignal(ITEMS_PER_BATCH); @@ -64,11 +63,6 @@ export function FeedPage(props: PageProps) { setIsRefreshing(false); }; - const handleScrollDown = async () => { - if (feedStore.isLoadingMore() || !feedStore.hasMoreEpisodes()) return; - await feedStore.loadMoreEpisodes(); - }; - const { theme } = useTheme(); return ( {([date, episode], groupIndex) => { - const selected = () => groupIndex() === selectedIndex(); + const index = typeof props.focusedIndex === 'function' ? props.focusedIndex() : props.focusedIndex; + const selected = () => groupIndex() === index; return ( <> setSelectedIndex(groupIndex())} + onMouseDown={() => { + // Selection is handled by App's keyboard navigation + }} > {selected() ? ">" : " "} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 78e6a74..07739fb 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -11,6 +11,7 @@ import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; import { PageProps } from "@/App"; import { useTheme } from "@/context/ThemeContext"; +import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; enum MyShowsPaneType { SHOWS = 1, @@ -22,8 +23,7 @@ export const MyShowsPaneCount = 2; export function MyShowsPage(props: PageProps) { const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); - const [showIndex, setShowIndex] = createSignal(0); - const [episodeIndex, setEpisodeIndex] = createSignal(0); + const audioNav = useAudioNavStore(); const [isRefreshing, setIsRefreshing] = createSignal(false); const { theme } = useTheme(); const mutedColor = () => theme.muted || theme.text; @@ -35,8 +35,8 @@ export function MyShowsPage(props: PageProps) { const selectedShow = createMemo(() => { const s = shows(); - const idx = showIndex(); - return idx < s.length ? s[idx] : undefined; + const index = typeof props.focusedIndex === 'function' ? props.focusedIndex() : props.focusedIndex; + return index < s.length ? s[index] : undefined; }); const episodes = createMemo(() => { @@ -47,23 +47,6 @@ export function MyShowsPage(props: PageProps) { ); }); - // Detect when user navigates near the bottom and load more episodes - createEffect(() => { - const idx = episodeIndex(); - const eps = episodes(); - const show = selectedShow(); - if (!show || eps.length === 0) return; - - const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD; - if ( - nearBottom && - feedStore.hasMoreEpisodes(show.id) && - !feedStore.isLoadingMore() - ) { - feedStore.loadMoreEpisodes(show.id); - } - }); - const formatDate = (date: Date): string => { return format(date, "MMM d, yyyy"); }; @@ -160,6 +143,7 @@ export function MyShowsPage(props: PageProps) { onMouseDown={() => { setShowIndex(index()); setEpisodeIndex(0); + audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); }} > (defaultNavState); + + /** Persist current navigation state to file (fire-and-forget) */ + function persist(): void { + saveAudioNavToFile(navState()).catch(() => {}); + } + + /** Load navigation state from file */ + async function init(): Promise { + const loaded = await loadAudioNavFromFile(); + if (loaded) { + setNavState(loaded); + } + } + + /** Fire-and-forget initialization */ + init(); + + return { + /** Get current navigation state */ + get state(): AudioNavState { + return navState(); + }, + + /** Update source type */ + setSource: (source: AudioSource, podcastId?: string) => { + setNavState((prev) => ({ + ...prev, + source, + podcastId, + lastUpdated: new Date(), + })); + persist(); + }, + + /** Move to next episode */ + next: (currentIndex: number) => { + setNavState((prev) => ({ + ...prev, + currentIndex, + lastUpdated: new Date(), + })); + persist(); + }, + + /** Move to previous episode */ + prev: (currentIndex: number) => { + setNavState((prev) => ({ + ...prev, + currentIndex, + lastUpdated: new Date(), + })); + persist(); + }, + + /** Reset to default state */ + reset: () => { + setNavState(defaultNavState); + persist(); + }, + + /** Get current index */ + getCurrentIndex: (): number => { + return navState().currentIndex; + }, + + /** Get current source */ + getSource: (): AudioSource => { + return navState().source; + }, + + /** Get current podcast ID */ + getPodcastId: (): string | undefined => { + return navState().podcastId; + }, + }; +} + +/** Singleton instance */ +let audioNavInstance: ReturnType | null = null; + +export function useAudioNavStore() { + if (!audioNavInstance) { + audioNavInstance = createAudioNavStore(); + } + return audioNavInstance; +} diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 35f2e4d..974c1c7 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -16,6 +16,7 @@ import { DEFAULT_THEME } from "../constants/themes"; const APP_STATE_FILE = "app-state.json"; const PROGRESS_FILE = "progress.json"; +const AUDIO_NAV_FILE = "audio-nav.json"; // --- Defaults --- @@ -119,3 +120,39 @@ export async function saveProgressToFile( // Silently ignore write errors } } + +interface AudioNavEntry { + source: string; + currentIndex: number; + podcastId?: string; + lastUpdated: string; +} + +/** Load audio navigation state from JSON file */ +export async function loadAudioNavFromFile(): Promise { + try { + const filePath = getConfigFilePath(AUDIO_NAV_FILE); + const file = Bun.file(filePath); + if (!(await file.exists())) return null; + + const raw = await file.json(); + if (!raw || typeof raw !== "object") return null; + + return raw as T; + } catch { + return null; + } +} + +/** Save audio navigation state to JSON file */ +export async function saveAudioNavToFile( + data: T, +): Promise { + try { + await ensureConfigDir(); + const filePath = getConfigFilePath(AUDIO_NAV_FILE); + await Bun.write(filePath, JSON.stringify(data, null, 2)); + } catch { + // Silently ignore write errors + } +} diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 3329fa4..378597b 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -11,7 +11,12 @@ import { parseJSONC } from "./jsonc"; import { getConfigFilePath, ensureConfigDir } from "./config-dir"; import type { KeybindsResolved } from "../context/KeybindContext"; -const KEYBINDS_SOURCE = path.join(process.cwd(), "src", "config", "keybind.jsonc"); +const KEYBINDS_SOURCE = path.join( + process.cwd(), + "src", + "config", + "keybind.jsonc", +); const KEYBINDS_FILE = "keybinds.jsonc"; /** Default keybinds from package */ @@ -31,6 +36,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = { "audio-play": [], "audio-next": ["n"], "audio-prev": ["l"], + "audio-seek-forward": ["sf"], + "audio-seek-backward": ["sb"], }; /** Copy keybind.jsonc to user config directory on first run */ @@ -69,7 +76,9 @@ export async function loadKeybindsFromFile(): Promise { } /** Save keybinds to JSONC file */ -export async function saveKeybindsToFile(keybinds: KeybindsResolved): Promise { +export async function saveKeybindsToFile( + keybinds: KeybindsResolved, +): Promise { try { await ensureConfigDir(); const filePath = getConfigFilePath(KEYBINDS_FILE);