diff --git a/web/src/App.tsx b/web/src/App.tsx index 2390a0b..cef941e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -239,6 +239,9 @@ export function App() { case "pop": playPop(action.freq); break; + case "mute": + setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT); + break; } } }, @@ -246,8 +249,8 @@ export function App() { ); // Held in a ref so the listener is installed once rather than on every state change. - const latest = useRef({ ui, results, run }); - latest.current = { ui, results, run }; + const latest = useRef({ ui, results, run, openAlbumTrackCount: 0 }); + latest.current = { ui, results, run, openAlbumTrackCount: openAlbum?.tracks.length ?? 0 }; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -256,7 +259,7 @@ export function App() { if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; const current = latest.current; - const actions = handleKey(event, current.ui, current.results); + const actions = handleKey(event, current.ui, current.results, current.openAlbumTrackCount); if (!actions.length) return; event.preventDefault(); current.run(actions); @@ -300,7 +303,7 @@ export function App() { return; } playPop(420); - setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex })); + setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 })); }; /** Clicking the title text (as opposed to the cover) starts the album right away - @@ -313,7 +316,13 @@ export function App() { const onOpenCurrentAlbum = () => { if (!currentAlbum) return; playPop(420); - setUi((previous) => ({ ...previous, openAlbumId: currentAlbum.id })); + // currentAlbum is by definition whatever's playing, so its own track_index is the + // track already highlighted - the modal opens showing where you actually are. + setUi((previous) => ({ + ...previous, + openAlbumId: currentAlbum.id, + modalTrackIndex: state?.track_index ?? 0, + })); }; const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); @@ -435,6 +444,7 @@ export function App() { album={openAlbum} currentAlbumId={state.album_id} currentTrackIndex={state.track_index} + selectedIndex={ui.modalTrackIndex} onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))} onPlay={(trackIndex) => play(openAlbum.id, trackIndex)} /> diff --git a/web/src/components/AlbumModal.tsx b/web/src/components/AlbumModal.tsx index 37910d0..9fe89a4 100644 --- a/web/src/components/AlbumModal.tsx +++ b/web/src/components/AlbumModal.tsx @@ -1,5 +1,7 @@ /** The album detail sheet: cover, metadata, and a numbered track list. */ +import { useEffect, useRef } from "react"; + import type { Album } from "../api/types"; import { isBook, unitLabel } from "../lib/covers"; import { clock } from "../lib/format"; @@ -9,6 +11,8 @@ interface Props { album: Album; currentAlbumId: string | null; currentTrackIndex: number; + /** The track Ctrl+j/k has highlighted, so the keyboard has something visible to move. */ + selectedIndex: number; onClose: () => void; onPlay: (trackIndex: number) => void; } @@ -17,13 +21,32 @@ export function AlbumModal({ album, currentAlbumId, currentTrackIndex, + selectedIndex, onClose, onPlay, }: Props) { const book = isBook(album); + const sheet = useRef(null); + + // Keep the Ctrl+j/k highlight on screen as it moves past the fold, the same way the + // browse grid keeps its own keyboard selection visible. + useEffect(() => { + const box = sheet.current; + const element = box?.querySelector(`[data-nav-index="${selectedIndex}"]`); + if (!box || !element) return; + const top = element.offsetTop - box.offsetTop; + const bottom = top + element.offsetHeight; + const pad = 12; + if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad); + else if (bottom + pad > box.scrollTop + box.clientHeight) { + box.scrollTop = bottom + pad - box.clientHeight; + } + }, [selectedIndex]); + return (
event.stopPropagation()} style={{ background: "oklch(96% 0.012 210)", @@ -128,6 +151,7 @@ export function AlbumModal({ return (