diff --git a/src/components/folderBrowser/FolderBrowserColumn.tsx b/src/components/folderBrowser/FolderBrowserColumn.tsx new file mode 100644 index 00000000..5d6a1d43 --- /dev/null +++ b/src/components/folderBrowser/FolderBrowserColumn.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight, Folder, FolderOpen, Music } from 'lucide-react'; +import type { SubsonicDirectoryEntry } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import { + folderBrowserHasKeyModifiers, isFolderBrowserArrowKey, + type Column, +} from '../../utils/folderBrowserHelpers'; + +interface Props { + col: Column; + colIndex: number; + isCompact: boolean; + filterValue: string; + filterVisible: boolean; + filteredItems: SubsonicDirectoryEntry[]; + keyboardRowIndex: number | null; + contextRowIndex: number | null; + currentTrack: Track | null; + isPlaying: boolean; + isSelectedPathForCurrentTrack: boolean; + playingPathIds: string[]; + registerFilterInput: (el: HTMLInputElement | null) => void; + onFilterFocus: () => void; + onFilterBlur: () => void; + onFilterEscape: () => void; + onFilterArrowDown: () => void; + onFilterChange: (value: string) => void; + onRowClick: (item: SubsonicDirectoryEntry, rowIndex: number) => void; + onRowContextMenu: ( + e: React.MouseEvent, + rowIndex: number, + col: Column, + item: SubsonicDirectoryEntry, + ) => void; +} + +export default function FolderBrowserColumn({ + col, colIndex, isCompact, filterValue, filterVisible, filteredItems, + keyboardRowIndex, contextRowIndex, currentTrack, isPlaying, + isSelectedPathForCurrentTrack, playingPathIds, + registerFilterInput, onFilterFocus, onFilterBlur, onFilterEscape, onFilterArrowDown, onFilterChange, + onRowClick, onRowContextMenu, +}: Props) { + const { t } = useTranslation(); + + return ( +
+ {filterVisible && ( +
+ { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onFilterEscape(); + return; + } + if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) { + e.preventDefault(); + e.stopPropagation(); + onFilterArrowDown(); + } + }} + onChange={e => onFilterChange(e.target.value)} + /> +
+ )} + {col.loading ? ( +
+
+
+ ) : col.error ? ( +
+ {t('folderBrowser.error')} +
+ ) : filteredItems.length === 0 ? ( +
{t('folderBrowser.empty')}
+ ) : ( + filteredItems.map((item, rowIndex) => { + const isSelected = col.selectedId === item.id; + const isContextRow = contextRowIndex === rowIndex; + const isKeyboardRow = keyboardRowIndex === rowIndex; + const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id; + const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id)); + return ( + + ); + }) + )} +
+ ); +} diff --git a/src/hooks/useFolderBrowserKeyboardNav.ts b/src/hooks/useFolderBrowserKeyboardNav.ts new file mode 100644 index 00000000..f503669f --- /dev/null +++ b/src/hooks/useFolderBrowserKeyboardNav.ts @@ -0,0 +1,161 @@ +import { useCallback } from 'react'; +import type { SubsonicDirectoryEntry } from '../api/subsonicTypes'; +import { usePlayerStore } from '../store/playerStore'; +import { + entryToTrack, folderBrowserHasKeyModifiers, isFolderBrowserArrowKey, + type Column, type NavPos, +} from '../utils/folderBrowserHelpers'; + +interface Args { + columns: Column[]; + filteredItemsByCol: SubsonicDirectoryEntry[][]; + columnFilters: Record; + filterFocusCol: number | null; + keyboardPos: NavPos | null; + isContextMenuOpen: boolean; + filterInputRefs: React.RefObject>; + wrapperRef: React.RefObject; + setKeyboardNavActive: React.Dispatch>; + setKeyboardPos: React.Dispatch>; + setContextAnchorPos: React.Dispatch>; + setFilterFocusCol: React.Dispatch>; + preferredRowIndex: (colIndex: number) => number; + fallbackNavPos: (cols: Column[]) => NavPos | null; + handleActivate: (colIndex: number, item: SubsonicDirectoryEntry) => void; + handleDirClick: (colIndex: number, item: SubsonicDirectoryEntry) => void; + setSelectedInColumn: (colIndex: number, itemId: string) => void; + clearSelectedInColumn: (colIndex: number) => void; + openContextMenuForEntry: (col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => void; + clearFiltersRightOf: (colIndex: number) => void; +} + +export function useFolderBrowserKeyboardNav({ + columns, filteredItemsByCol, columnFilters, filterFocusCol, keyboardPos, + isContextMenuOpen, filterInputRefs, wrapperRef, + setKeyboardNavActive, setKeyboardPos, setContextAnchorPos, setFilterFocusCol, + preferredRowIndex, fallbackNavPos, + handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, + openContextMenuForEntry, clearFiltersRightOf, +}: Args) { + const enqueue = usePlayerStore(s => s.enqueue); + + return useCallback((e: React.KeyboardEvent) => { + if (isContextMenuOpen) return; + const target = e.target as HTMLElement; + const inFilterInput = + target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true'; + if (inFilterInput) return; + const key = e.key; + if (e.ctrlKey && e.code === 'KeyF') { + e.preventDefault(); + const current = keyboardPos ?? fallbackNavPos(columns); + if (!current) return; + const colIndex = current.colIndex; + setFilterFocusCol(colIndex); + requestAnimationFrame(() => { + const input = filterInputRefs.current?.[colIndex]; + if (!input) return; + input.focus(); + input.select(); + }); + return; + } + if (isFolderBrowserArrowKey(e) && folderBrowserHasKeyModifiers(e)) return; + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return; + setKeyboardNavActive(true); + const current = keyboardPos ?? fallbackNavPos(columns); + if (!current) return; + + const col = columns[current.colIndex]; + const visibleItems = filteredItemsByCol[current.colIndex] ?? []; + const item = visibleItems[current.rowIndex]; + if (!col || !item) return; + + e.preventDefault(); + + if (key === 'Enter' && e.ctrlKey) { + setContextAnchorPos(current); + const rowEl = wrapperRef.current?.querySelector( + `.folder-col-row[data-col-index="${current.colIndex}"][data-row-index="${current.rowIndex}"]`, + ); + const rect = rowEl?.getBoundingClientRect(); + const x = rect ? rect.left + 24 : 24; + const y = rect ? rect.top + rect.height / 2 : 24; + openContextMenuForEntry(col, item, x, y); + return; + } + + if (key === 'ArrowUp') { + if (current.rowIndex > 0) { + const nextRowIndex = current.rowIndex - 1; + const nextItem = visibleItems[nextRowIndex]; + setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); + if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); + else setSelectedInColumn(current.colIndex, nextItem.id); + } else if ( + current.rowIndex === 0 && + (filterFocusCol === current.colIndex || !!columnFilters[current.colIndex]) + ) { + setFilterFocusCol(current.colIndex); + requestAnimationFrame(() => { + const input = filterInputRefs.current?.[current.colIndex]; + if (!input) return; + input.focus(); + input.select(); + }); + } + return; + } + if (key === 'ArrowDown') { + if (current.rowIndex < visibleItems.length - 1) { + const nextRowIndex = current.rowIndex + 1; + const nextItem = visibleItems[nextRowIndex]; + setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); + if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); + else setSelectedInColumn(current.colIndex, nextItem.id); + } + return; + } + if (key === 'ArrowLeft') { + if (current.colIndex > 0) { + clearSelectedInColumn(current.colIndex); + const nextColIndex = current.colIndex - 1; + clearFiltersRightOf(nextColIndex); + const rowIndex = preferredRowIndex(nextColIndex); + if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex }); + } + return; + } + if (key === 'ArrowRight') { + const nextColIndex = current.colIndex + 1; + if (nextColIndex < columns.length) { + const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? []; + const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1); + if (rowIndex >= 0) { + const nextItem = nextVisibleItems[rowIndex]; + setSelectedInColumn(nextColIndex, nextItem.id); + setKeyboardPos({ colIndex: nextColIndex, rowIndex }); + return; + } + } + if (item.isDir) handleActivate(current.colIndex, item); + return; + } + if (key === 'Enter') { + if (e.shiftKey && !item.isDir) { + const toAppend = (filteredItemsByCol[current.colIndex] ?? []) + .filter(it => !it.isDir) + .map(entryToTrack); + if (toAppend.length > 0) enqueue(toAppend); + return; + } + handleActivate(current.colIndex, item); + } + }, [ + keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, + setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, + filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf, + filterInputRefs, wrapperRef, setKeyboardNavActive, setKeyboardPos, setContextAnchorPos, + setFilterFocusCol, + ]); +} diff --git a/src/hooks/useFolderBrowserNowPlayingPath.ts b/src/hooks/useFolderBrowserNowPlayingPath.ts new file mode 100644 index 00000000..93b074b6 --- /dev/null +++ b/src/hooks/useFolderBrowserNowPlayingPath.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary'; +import type { SubsonicDirectoryEntry } from '../api/subsonicTypes'; +import type { Track } from '../store/playerStoreTypes'; +import type { Column, NavPos } from '../utils/folderBrowserHelpers'; + +let persistedPlayingPathIds: string[] = []; + +interface Args { + columns: Column[]; + currentTrack: Track | null; + isPlaying: boolean; + setColumns: React.Dispatch>; + setKeyboardPos: React.Dispatch>; +} + +interface Result { + playingPathIds: string[]; + setPlayingPathIds: React.Dispatch>; + isSelectedPathForCurrentTrack: boolean; +} + +export function useFolderBrowserNowPlayingPath({ + columns, currentTrack, isPlaying, setColumns, setKeyboardPos, +}: Args): Result { + const [playingPathIds, setPlayingPathIds] = useState(persistedPlayingPathIds); + const autoResolvedTrackRef = useRef(null); + const prevTrackIdRef = useRef(null); + const lastHotkeyRevealTsRef = useRef(null); + const location = useLocation(); + + useEffect(() => { + if (!currentTrack?.id) { + setPlayingPathIds([]); + return; + } + setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : [])); + }, [currentTrack?.id]); + + useEffect(() => { + if (!isPlaying || !currentTrack?.id) return; + const selectedChain = columns + .map(c => c.selectedId) + .filter((id): id is string => !!id); + if (selectedChain.length === 0) return; + + const lastSelectedId = selectedChain[selectedChain.length - 1]; + const leafColumn = [...columns].reverse().find(c => c.selectedId); + const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId); + if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return; + + setPlayingPathIds(prev => { + if ( + prev.length === selectedChain.length && + prev.every((id, idx) => id === selectedChain[idx]) + ) { + return prev; + } + return selectedChain; + }); + }, [columns, currentTrack?.id, isPlaying]); + + useEffect(() => { + persistedPlayingPathIds = playingPathIds; + }, [playingPathIds]); + + const resolveColumnsForTrack = useCallback(async ( + track: Track, + roots: SubsonicDirectoryEntry[], + ): Promise => { + for (const root of roots) { + let indexes: SubsonicDirectoryEntry[]; + try { + indexes = await getMusicIndexes(root.id); + } catch { + continue; + } + + const artistEntry = + indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ?? + indexes.find(it => it.isDir && it.title === track.artist); + if (!artistEntry) continue; + + let artistChildren: SubsonicDirectoryEntry[]; + try { + artistChildren = (await getMusicDirectory(artistEntry.id)).child; + } catch { + continue; + } + + const albumEntry = artistChildren.find(it => + it.isDir && + ( + (!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) || + (!!track.album && (it.album === track.album || it.title === track.album)) + ), + ); + if (!albumEntry) continue; + + let albumChildren: SubsonicDirectoryEntry[]; + try { + albumChildren = (await getMusicDirectory(albumEntry.id)).child; + } catch { + continue; + } + const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id); + if (!songEntry) continue; + + return [ + { id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' }, + { id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' }, + { id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' }, + { id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' }, + ]; + } + return null; + }, []); + + useEffect(() => { + if (!currentTrack?.id) { + autoResolvedTrackRef.current = null; + return; + } + + const hotkeyRevealTs = (location.state as { folderBrowserRevealTs?: number } | null)?.folderBrowserRevealTs ?? null; + const hotkeyRevealRequested = hotkeyRevealTs !== null && hotkeyRevealTs !== lastHotkeyRevealTsRef.current; + const forceReveal = hotkeyRevealRequested; + if (autoResolvedTrackRef.current === currentTrack.id && !forceReveal) return; + + const rootCol = columns[0]; + if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return; + + const selectedLeafId = + [...columns].reverse().find(c => c.selectedId)?.selectedId ?? null; + const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current; + if (selectedLeafId === currentTrack.id) { + autoResolvedTrackRef.current = currentTrack.id; + if (hotkeyRevealRequested) { + lastHotkeyRevealTsRef.current = hotkeyRevealTs; + } + return; + } + if (!forceReveal && !wasOnPreviousTrackPath) return; + + let cancelled = false; + resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => { + if (cancelled || !resolved) return; + setColumns(resolved); + const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id); + setPlayingPathIds(path); + const leafColIndex = resolved.length - 1; + const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id); + if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex }); + autoResolvedTrackRef.current = currentTrack.id; + if (hotkeyRevealRequested) { + lastHotkeyRevealTsRef.current = hotkeyRevealTs; + } + }); + + return () => { cancelled = true; }; + }, [columns, currentTrack, resolveColumnsForTrack, location.state, setColumns, setKeyboardPos]); + + useEffect(() => { + prevTrackIdRef.current = currentTrack?.id ?? null; + }, [currentTrack?.id]); + + const isSelectedPathForCurrentTrack = + isPlaying && !!currentTrack && playingPathIds[playingPathIds.length - 1] === currentTrack.id; + + return { + playingPathIds, + setPlayingPathIds, + isSelectedPathForCurrentTrack, + }; +} diff --git a/src/hooks/useFolderBrowserScrolling.ts b/src/hooks/useFolderBrowserScrolling.ts new file mode 100644 index 00000000..6f80b9c9 --- /dev/null +++ b/src/hooks/useFolderBrowserScrolling.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Column, NavPos } from '../utils/folderBrowserHelpers'; + +interface Args { + columns: Column[]; + keyboardPos: NavPos | null; + keyboardNavActive: boolean; + setKeyboardNavActive: React.Dispatch>; +} + +interface Result { + wrapperRef: React.RefObject; + columnsViewportWidth: number; +} + +export function useFolderBrowserScrolling({ + columns, keyboardPos, keyboardNavActive, setKeyboardNavActive, +}: Args): Result { + const wrapperRef = useRef(null); + const [columnsViewportWidth, setColumnsViewportWidth] = useState(0); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + requestAnimationFrame(() => { + el.scrollLeft = el.scrollWidth; + }); + }, [columns.length]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + setColumnsViewportWidth(el.clientWidth); + const observer = new ResizeObserver(() => { + setColumnsViewportWidth(el.clientWidth); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!wrapperRef.current) return; + requestAnimationFrame(() => { + columns.forEach((col, colIndex) => { + const selectedId = col.selectedId; + if (!selectedId) return; + const row = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`, + ); + row?.scrollIntoView({ block: 'nearest' }); + }); + + if (keyboardPos) { + const kbdRow = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${keyboardPos.colIndex}"] .folder-col-row[data-row-index="${keyboardPos.rowIndex}"]`, + ); + kbdRow?.scrollIntoView({ block: 'nearest' }); + } + + const fallbackColIndex = [...columns] + .map((c, i) => (c.selectedId ? i : -1)) + .filter(i => i >= 0) + .pop(); + const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1); + const focusColIndex = Math.min(Math.max(0, columns.length - 1), baseColIndex + 1); + const focusCol = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${focusColIndex}"]`, + ); + focusCol?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + }, [columns, keyboardPos]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const hasRows = columns.some(c => !c.loading && !c.error && c.items.length > 0); + if (!hasRows) return; + requestAnimationFrame(() => { + el.focus({ preventScroll: true }); + }); + }, [columns]); + + useEffect(() => { + if (!keyboardNavActive) return; + const onMouseMove = () => setKeyboardNavActive(false); + window.addEventListener('mousemove', onMouseMove, { once: true }); + return () => window.removeEventListener('mousemove', onMouseMove); + }, [keyboardNavActive, setKeyboardNavActive]); + + return { wrapperRef, columnsViewportWidth }; +} diff --git a/src/pages/FolderBrowser.tsx b/src/pages/FolderBrowser.tsx index f69b8ba1..dd620204 100644 --- a/src/pages/FolderBrowser.tsx +++ b/src/pages/FolderBrowser.tsx @@ -1,94 +1,16 @@ import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary'; -import type { SubsonicDirectoryEntry, SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes'; -import type { Track } from '../store/playerStoreTypes'; +import type { SubsonicDirectoryEntry, SubsonicArtist } from '../api/subsonicTypes'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; -import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react'; -import { useLocation } from 'react-router-dom'; - -type ColumnKind = 'roots' | 'indexes' | 'directory'; -type NavPos = { colIndex: number; rowIndex: number }; -let persistedPlayingPathIds: string[] = []; - -type Column = { - id: string; - name: string; - items: SubsonicDirectoryEntry[]; - selectedId: string | null; - loading: boolean; - error: boolean; - kind: ColumnKind; -}; - -/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */ -function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null { - if (!item.isDir) return null; - const albumId = item.albumId ?? (item.album ? item.id : undefined); - if (!albumId) return null; - return { - id: albumId, - name: item.album ?? item.title, - artist: item.artist ?? '', - artistId: item.artistId ?? '', - coverArt: item.coverArt, - year: item.year, - genre: item.genre, - starred: item.starred, - userRating: item.userRating, - songCount: 0, - duration: 0, - }; -} - -function entryToTrack(e: SubsonicDirectoryEntry): Track { - return { - id: e.id, - title: e.title, - artist: e.artist ?? '', - album: e.album ?? '', - albumId: e.albumId ?? '', - artistId: e.artistId, - coverArt: e.coverArt, - duration: e.duration ?? 0, - track: e.track, - year: e.year, - bitRate: e.bitRate, - suffix: e.suffix, - genre: e.genre, - starred: e.starred, - userRating: e.userRating, - }; -} - -function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean { - return ( - e.key === 'ArrowUp' || - e.key === 'ArrowDown' || - e.key === 'ArrowLeft' || - e.key === 'ArrowRight' || - e.code === 'ArrowUp' || - e.code === 'ArrowDown' || - e.code === 'ArrowLeft' || - e.code === 'ArrowRight' - ); -} - -/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */ -function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean { - const n = e.nativeEvent; - if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true; - if (typeof n.getModifierState === 'function') { - return ( - n.getModifierState('Control') || - n.getModifierState('Alt') || - n.getModifierState('Shift') || - n.getModifierState('Meta') || - n.getModifierState('OS') - ); - } - return false; -} +import { + entryToAlbumIfPresent, entryToTrack, + type Column, type ColumnKind, type NavPos, +} from '../utils/folderBrowserHelpers'; +import FolderBrowserColumn from '../components/folderBrowser/FolderBrowserColumn'; +import { useFolderBrowserNowPlayingPath } from '../hooks/useFolderBrowserNowPlayingPath'; +import { useFolderBrowserScrolling } from '../hooks/useFolderBrowserScrolling'; +import { useFolderBrowserKeyboardNav } from '../hooks/useFolderBrowserKeyboardNav'; export default function FolderBrowser() { const { t } = useTranslation(); @@ -96,17 +18,10 @@ export default function FolderBrowser() { const [columnFilters, setColumnFilters] = useState>({}); const [filterFocusCol, setFilterFocusCol] = useState(null); const [keyboardNavActive, setKeyboardNavActive] = useState(false); - const [playingPathIds, setPlayingPathIds] = useState(persistedPlayingPathIds); - const wrapperRef = useRef(null); const filterInputRefs = useRef>({}); const pendingNavColRef = useRef(null); - const autoResolvedTrackRef = useRef(null); - const prevTrackIdRef = useRef(null); - const lastHotkeyRevealTsRef = useRef(null); const [keyboardPos, setKeyboardPos] = useState(null); const [contextAnchorPos, setContextAnchorPos] = useState(null); - const [columnsViewportWidth, setColumnsViewportWidth] = useState(0); - const location = useLocation(); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const playTrack = usePlayerStore(s => s.playTrack); @@ -114,6 +29,13 @@ export default function FolderBrowser() { const openContextMenu = usePlayerStore(s => s.openContextMenu); const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); + const { wrapperRef, columnsViewportWidth } = useFolderBrowserScrolling({ + columns, keyboardPos, keyboardNavActive, setKeyboardNavActive, + }); + + const { playingPathIds, setPlayingPathIds, isSelectedPathForCurrentTrack } = + useFolderBrowserNowPlayingPath({ columns, currentTrack, isPlaying, setColumns, setKeyboardPos }); + useEffect(() => { const placeholder: Column = { id: 'root', @@ -139,74 +61,6 @@ export default function FolderBrowser() { }); }, []); - useEffect(() => { - const el = wrapperRef.current; - if (!el) return; - requestAnimationFrame(() => { - el.scrollLeft = el.scrollWidth; - }); - }, [columns.length]); - - useEffect(() => { - const el = wrapperRef.current; - if (!el) return; - setColumnsViewportWidth(el.clientWidth); - const observer = new ResizeObserver(() => { - setColumnsViewportWidth(el.clientWidth); - }); - observer.observe(el); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!wrapperRef.current) return; - requestAnimationFrame(() => { - columns.forEach((col, colIndex) => { - const selectedId = col.selectedId; - if (!selectedId) return; - const row = wrapperRef.current?.querySelector( - `.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`, - ); - row?.scrollIntoView({ block: 'nearest' }); - }); - - if (keyboardPos) { - const kbdRow = wrapperRef.current?.querySelector( - `.folder-col[data-folder-col-index="${keyboardPos.colIndex}"] .folder-col-row[data-row-index="${keyboardPos.rowIndex}"]`, - ); - kbdRow?.scrollIntoView({ block: 'nearest' }); - } - - const fallbackColIndex = [...columns] - .map((c, i) => (c.selectedId ? i : -1)) - .filter(i => i >= 0) - .pop(); - const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1); - const focusColIndex = Math.min(Math.max(0, columns.length - 1), baseColIndex + 1); - const focusCol = wrapperRef.current?.querySelector( - `.folder-col[data-folder-col-index="${focusColIndex}"]`, - ); - focusCol?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - }); - }, [columns, keyboardPos]); - - useEffect(() => { - const el = wrapperRef.current; - if (!el) return; - const hasRows = columns.some(c => !c.loading && !c.error && c.items.length > 0); - if (!hasRows) return; - requestAnimationFrame(() => { - el.focus({ preventScroll: true }); - }); - }, [columns]); - - useEffect(() => { - if (!keyboardNavActive) return; - const onMouseMove = () => setKeyboardNavActive(false); - window.addEventListener('mousemove', onMouseMove, { once: true }); - return () => window.removeEventListener('mousemove', onMouseMove); - }, [keyboardNavActive]); - useEffect(() => { setColumnFilters(prev => { const next: Record = {}; @@ -225,41 +79,6 @@ export default function FolderBrowser() { if (!isContextMenuOpen) setContextAnchorPos(null); }, [isContextMenuOpen]); - useEffect(() => { - if (!currentTrack?.id) { - setPlayingPathIds([]); - return; - } - setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : [])); - }, [currentTrack?.id]); - - useEffect(() => { - if (!isPlaying || !currentTrack?.id) return; - const selectedChain = columns - .map(c => c.selectedId) - .filter((id): id is string => !!id); - if (selectedChain.length === 0) return; - - const lastSelectedId = selectedChain[selectedChain.length - 1]; - const leafColumn = [...columns].reverse().find(c => c.selectedId); - const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId); - if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return; - - setPlayingPathIds(prev => { - if ( - prev.length === selectedChain.length && - prev.every((id, idx) => id === selectedChain[idx]) - ) { - return prev; - } - return selectedChain; - }); - }, [columns, currentTrack?.id, isPlaying]); - - useEffect(() => { - persistedPlayingPathIds = playingPathIds; - }, [playingPathIds]); - const filteredItemsByCol = useMemo(() => { return columns.map((col, colIndex) => { const query = (columnFilters[colIndex] ?? '').trim().toLowerCase(); @@ -452,119 +271,14 @@ export default function FolderBrowser() { [openContextMenu], ); - const onColumnsKeyDown = useCallback((e: React.KeyboardEvent) => { - if (isContextMenuOpen) return; - const target = e.target as HTMLElement; - const inFilterInput = - target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true'; - if (inFilterInput) return; - const key = e.key; - if (e.ctrlKey && e.code === 'KeyF') { - e.preventDefault(); - const current = keyboardPos ?? fallbackNavPos(columns); - if (!current) return; - const colIndex = current.colIndex; - setFilterFocusCol(colIndex); - requestAnimationFrame(() => { - const input = filterInputRefs.current[colIndex]; - if (!input) return; - input.focus(); - input.select(); - }); - return; - } - if (isFolderBrowserArrowKey(e) && folderBrowserHasKeyModifiers(e)) return; - if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return; - setKeyboardNavActive(true); - const current = keyboardPos ?? fallbackNavPos(columns); - if (!current) return; - - const col = columns[current.colIndex]; - const visibleItems = filteredItemsByCol[current.colIndex] ?? []; - const item = visibleItems[current.rowIndex]; - if (!col || !item) return; - - e.preventDefault(); - - if (key === 'Enter' && e.ctrlKey) { - setContextAnchorPos(current); - const rowEl = wrapperRef.current?.querySelector( - `.folder-col-row[data-col-index="${current.colIndex}"][data-row-index="${current.rowIndex}"]`, - ); - const rect = rowEl?.getBoundingClientRect(); - const x = rect ? rect.left + 24 : 24; - const y = rect ? rect.top + rect.height / 2 : 24; - openContextMenuForEntry(col, item, x, y); - return; - } - - if (key === 'ArrowUp') { - if (current.rowIndex > 0) { - const nextRowIndex = current.rowIndex - 1; - const nextItem = visibleItems[nextRowIndex]; - setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); - if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); - else setSelectedInColumn(current.colIndex, nextItem.id); - } else if ( - current.rowIndex === 0 && - (filterFocusCol === current.colIndex || !!columnFilters[current.colIndex]) - ) { - setFilterFocusCol(current.colIndex); - requestAnimationFrame(() => { - const input = filterInputRefs.current[current.colIndex]; - if (!input) return; - input.focus(); - input.select(); - }); - } - return; - } - if (key === 'ArrowDown') { - if (current.rowIndex < visibleItems.length - 1) { - const nextRowIndex = current.rowIndex + 1; - const nextItem = visibleItems[nextRowIndex]; - setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); - if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); - else setSelectedInColumn(current.colIndex, nextItem.id); - } - return; - } - if (key === 'ArrowLeft') { - if (current.colIndex > 0) { - clearSelectedInColumn(current.colIndex); - const nextColIndex = current.colIndex - 1; - clearFiltersRightOf(nextColIndex); - const rowIndex = preferredRowIndex(nextColIndex); - if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex }); - } - return; - } - if (key === 'ArrowRight') { - const nextColIndex = current.colIndex + 1; - if (nextColIndex < columns.length) { - const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? []; - const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1); - if (rowIndex >= 0) { - const nextItem = nextVisibleItems[rowIndex]; - setSelectedInColumn(nextColIndex, nextItem.id); - setKeyboardPos({ colIndex: nextColIndex, rowIndex }); - return; - } - } - if (item.isDir) handleActivate(current.colIndex, item); - return; - } - if (key === 'Enter') { - if (e.shiftKey && !item.isDir) { - const toAppend = (filteredItemsByCol[current.colIndex] ?? []) - .filter(it => !it.isDir) - .map(entryToTrack); - if (toAppend.length > 0) enqueue(toAppend); - return; - } - handleActivate(current.colIndex, item); - } - }, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]); + const onColumnsKeyDown = useFolderBrowserKeyboardNav({ + columns, filteredItemsByCol, columnFilters, filterFocusCol, keyboardPos, + isContextMenuOpen, filterInputRefs, wrapperRef, + setKeyboardNavActive, setKeyboardPos, setContextAnchorPos, setFilterFocusCol, + preferredRowIndex, fallbackNavPos, + handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, + openContextMenuForEntry, clearFiltersRightOf, + }); const onRowContextMenu = useCallback( (e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => { @@ -576,61 +290,6 @@ export default function FolderBrowser() { [openContextMenuForEntry], ); - const resolveColumnsForTrack = useCallback(async ( - track: Track, - roots: SubsonicDirectoryEntry[], - ): Promise => { - for (const root of roots) { - let indexes: SubsonicDirectoryEntry[]; - try { - indexes = await getMusicIndexes(root.id); - } catch { - continue; - } - - const artistEntry = - indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ?? - indexes.find(it => it.isDir && it.title === track.artist); - if (!artistEntry) continue; - - let artistChildren: SubsonicDirectoryEntry[]; - try { - artistChildren = (await getMusicDirectory(artistEntry.id)).child; - } catch { - continue; - } - - const albumEntry = artistChildren.find(it => - it.isDir && - ( - (!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) || - (!!track.album && (it.album === track.album || it.title === track.album)) - ), - ); - if (!albumEntry) continue; - - let albumChildren: SubsonicDirectoryEntry[]; - try { - albumChildren = (await getMusicDirectory(albumEntry.id)).child; - } catch { - continue; - } - const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id); - if (!songEntry) continue; - - return [ - { id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' }, - { id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' }, - { id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' }, - { id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' }, - ]; - } - return null; - }, []); - - const isSelectedPathForCurrentTrack = - isPlaying && currentTrack && playingPathIds[playingPathIds.length - 1] === currentTrack.id; - const activeColIndex = useMemo(() => { if (keyboardPos) return keyboardPos.colIndex; const fromSelection = [...columns] @@ -657,54 +316,6 @@ export default function FolderBrowser() { return Math.abs(colIndex - visibleAnchorColIndex) > 1; }, [compactColumnsEnabled, visibleAnchorColIndex]); - useEffect(() => { - if (!currentTrack?.id) { - autoResolvedTrackRef.current = null; - return; - } - - const hotkeyRevealTs = (location.state as { folderBrowserRevealTs?: number } | null)?.folderBrowserRevealTs ?? null; - const hotkeyRevealRequested = hotkeyRevealTs !== null && hotkeyRevealTs !== lastHotkeyRevealTsRef.current; - const forceReveal = hotkeyRevealRequested; - if (autoResolvedTrackRef.current === currentTrack.id && !forceReveal) return; - - const rootCol = columns[0]; - if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return; - - const selectedLeafId = - [...columns].reverse().find(c => c.selectedId)?.selectedId ?? null; - const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current; - if (selectedLeafId === currentTrack.id) { - autoResolvedTrackRef.current = currentTrack.id; - if (hotkeyRevealRequested) { - lastHotkeyRevealTsRef.current = hotkeyRevealTs; - } - return; - } - if (!forceReveal && !wasOnPreviousTrackPath) return; - - let cancelled = false; - resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => { - if (cancelled || !resolved) return; - setColumns(resolved); - const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id); - setPlayingPathIds(path); - const leafColIndex = resolved.length - 1; - const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id); - if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex }); - autoResolvedTrackRef.current = currentTrack.id; - if (hotkeyRevealRequested) { - lastHotkeyRevealTsRef.current = hotkeyRevealTs; - } - }); - - return () => { cancelled = true; }; - }, [columns, currentTrack, resolveColumnsForTrack, location.state]); - - useEffect(() => { - prevTrackIdRef.current = currentTrack?.id ?? null; - }, [currentTrack?.id]); - return (

{t('sidebar.folderBrowser')}

@@ -715,120 +326,61 @@ export default function FolderBrowser() { onKeyDown={onColumnsKeyDown} > {columns.map((col, colIndex) => ( -
- {(filterFocusCol === colIndex || !!columnFilters[colIndex]) && ( -
- { filterInputRefs.current[colIndex] = el; }} - data-folder-filter-input="true" - className="folder-col-filter-input" - value={columnFilters[colIndex] ?? ''} - placeholder={t('playlists.searchPlaceholder')} - onFocus={() => setFilterFocusCol(colIndex)} - onBlur={() => { - if (!(columnFilters[colIndex] ?? '').trim()) { - setFilterFocusCol(prev => (prev === colIndex ? null : prev)); - } - }} - onKeyDown={e => { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - setColumnFilters(prev => ({ ...prev, [colIndex]: '' })); - setFilterFocusCol(null); - requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true })); - return; - } - if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) { - e.preventDefault(); - e.stopPropagation(); - const rowIndex = preferredRowIndex(colIndex); - if (rowIndex >= 0) { - const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex]; - if (nextItem) { - if (nextItem.isDir) handleDirClick(colIndex, nextItem); - else setSelectedInColumn(colIndex, nextItem.id); - } - setKeyboardPos({ colIndex, rowIndex }); - requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true })); - } - } - }} - onChange={e => { - const value = e.target.value; - setColumnFilters(prev => ({ ...prev, [colIndex]: value })); - setKeyboardPos(prev => { - if (!prev || prev.colIndex !== colIndex) return prev; - return { colIndex, rowIndex: 0 }; - }); - }} - /> -
- )} - {col.loading ? ( -
-
-
- ) : col.error ? ( -
- {t('folderBrowser.error')} -
- ) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? ( -
{t('folderBrowser.empty')}
- ) : ( - (filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => { - const isSelected = col.selectedId === item.id; - const isContextRow = - contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex; - const isKeyboardRow = - keyboardPos?.colIndex === colIndex && keyboardPos?.rowIndex === rowIndex; - const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id; - const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id)); - return ( - - ); - }) - )} -
+ col={col} + colIndex={colIndex} + isCompact={isColumnCompact(col, colIndex)} + filterValue={columnFilters[colIndex] ?? ''} + filterVisible={filterFocusCol === colIndex || !!columnFilters[colIndex]} + filteredItems={filteredItemsByCol[colIndex] ?? []} + keyboardRowIndex={keyboardPos?.colIndex === colIndex ? keyboardPos.rowIndex : null} + contextRowIndex={contextAnchorPos?.colIndex === colIndex ? contextAnchorPos.rowIndex : null} + currentTrack={currentTrack} + isPlaying={isPlaying} + isSelectedPathForCurrentTrack={!!isSelectedPathForCurrentTrack} + playingPathIds={playingPathIds} + registerFilterInput={el => { filterInputRefs.current[colIndex] = el; }} + onFilterFocus={() => setFilterFocusCol(colIndex)} + onFilterBlur={() => { + if (!(columnFilters[colIndex] ?? '').trim()) { + setFilterFocusCol(prev => (prev === colIndex ? null : prev)); + } + }} + onFilterEscape={() => { + setColumnFilters(prev => ({ ...prev, [colIndex]: '' })); + setFilterFocusCol(null); + requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true })); + }} + onFilterArrowDown={() => { + const rowIndex = preferredRowIndex(colIndex); + if (rowIndex >= 0) { + const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex]; + if (nextItem) { + if (nextItem.isDir) handleDirClick(colIndex, nextItem); + else setSelectedInColumn(colIndex, nextItem.id); + } + setKeyboardPos({ colIndex, rowIndex }); + requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true })); + } + }} + onFilterChange={value => { + setColumnFilters(prev => ({ ...prev, [colIndex]: value })); + setKeyboardPos(prev => { + if (!prev || prev.colIndex !== colIndex) return prev; + return { colIndex, rowIndex: 0 }; + }); + }} + onRowClick={(item, rowIndex) => { + setKeyboardPos({ colIndex, rowIndex }); + if (item.isDir) handleDirClick(colIndex, item); + else handleFileClick(colIndex, item); + }} + onRowContextMenu={(e, rowIndex, c, item) => { + setKeyboardPos({ colIndex, rowIndex }); + onRowContextMenu(e, colIndex, rowIndex, c, item); + }} + /> ))}
diff --git a/src/utils/folderBrowserHelpers.ts b/src/utils/folderBrowserHelpers.ts new file mode 100644 index 00000000..c249f234 --- /dev/null +++ b/src/utils/folderBrowserHelpers.ts @@ -0,0 +1,85 @@ +import type React from 'react'; +import type { SubsonicAlbum, SubsonicDirectoryEntry } from '../api/subsonicTypes'; +import type { Track } from '../store/playerStoreTypes'; + +export type ColumnKind = 'roots' | 'indexes' | 'directory'; +export type NavPos = { colIndex: number; rowIndex: number }; + +export type Column = { + id: string; + name: string; + items: SubsonicDirectoryEntry[]; + selectedId: string | null; + loading: boolean; + error: boolean; + kind: ColumnKind; +}; + +/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */ +export function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null { + if (!item.isDir) return null; + const albumId = item.albumId ?? (item.album ? item.id : undefined); + if (!albumId) return null; + return { + id: albumId, + name: item.album ?? item.title, + artist: item.artist ?? '', + artistId: item.artistId ?? '', + coverArt: item.coverArt, + year: item.year, + genre: item.genre, + starred: item.starred, + userRating: item.userRating, + songCount: 0, + duration: 0, + }; +} + +export function entryToTrack(e: SubsonicDirectoryEntry): Track { + return { + id: e.id, + title: e.title, + artist: e.artist ?? '', + album: e.album ?? '', + albumId: e.albumId ?? '', + artistId: e.artistId, + coverArt: e.coverArt, + duration: e.duration ?? 0, + track: e.track, + year: e.year, + bitRate: e.bitRate, + suffix: e.suffix, + genre: e.genre, + starred: e.starred, + userRating: e.userRating, + }; +} + +export function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean { + return ( + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.code === 'ArrowUp' || + e.code === 'ArrowDown' || + e.code === 'ArrowLeft' || + e.code === 'ArrowRight' + ); +} + +/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */ +export function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean { + const n = e.nativeEvent; + if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true; + if (typeof n.getModifierState === 'function') { + return ( + n.getModifierState('Control') || + n.getModifierState('Alt') || + n.getModifierState('Shift') || + n.getModifierState('Meta') || + n.getModifierState('OS') + ); + } + return false; +}