From a12d6a4015c7a8ccca7519590c87d2a6e7e66bc5 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sun, 12 Apr 2026 14:08:36 +0300 Subject: [PATCH] feat(folder-browser): add per-column filter and queue append hotkey flow Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes. When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue. --- src/pages/FolderBrowser.tsx | 207 ++++++++++++++++++++++++++++++------ src/styles/components.css | 29 +++++ 2 files changed, 203 insertions(+), 33 deletions(-) diff --git a/src/pages/FolderBrowser.tsx b/src/pages/FolderBrowser.tsx index c52f4a0f..7d1714ed 100644 --- a/src/pages/FolderBrowser.tsx +++ b/src/pages/FolderBrowser.tsx @@ -69,9 +69,12 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track { export default function FolderBrowser() { const { t } = useTranslation(); const [columns, setColumns] = useState([]); + 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); @@ -83,6 +86,7 @@ export default function FolderBrowser() { const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const playTrack = usePlayerStore(s => s.playTrack); + const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); @@ -179,6 +183,20 @@ export default function FolderBrowser() { return () => window.removeEventListener('mousemove', onMouseMove); }, [keyboardNavActive]); + useEffect(() => { + setColumnFilters(prev => { + const next: Record = {}; + let changed = false; + Object.entries(prev).forEach(([k, v]) => { + const idx = Number(k); + if (idx < columns.length) next[idx] = v; + else changed = true; + }); + return changed ? next : prev; + }); + setFilterFocusCol(prev => (prev !== null && prev >= columns.length ? null : prev)); + }, [columns.length]); + useEffect(() => { if (!isContextMenuOpen) setContextAnchorPos(null); }, [isContextMenuOpen]); @@ -218,18 +236,31 @@ export default function FolderBrowser() { persistedPlayingPathIds = playingPathIds; }, [playingPathIds]); - const preferredRowIndex = useCallback((col: Column): number => { - if (col.items.length === 0) return -1; - if (col.selectedId) { - const selectedIdx = col.items.findIndex(it => it.id === col.selectedId); + const filteredItemsByCol = useMemo(() => { + return columns.map((col, colIndex) => { + const query = (columnFilters[colIndex] ?? '').trim().toLowerCase(); + if (!query) return col.items; + return col.items.filter(item => { + const haystack = `${item.title} ${item.artist ?? ''} ${item.album ?? ''}`.toLowerCase(); + return haystack.includes(query); + }); + }); + }, [columns, columnFilters]); + + const preferredRowIndex = useCallback((colIndex: number): number => { + const items = filteredItemsByCol[colIndex] ?? []; + if (items.length === 0) return -1; + const selectedId = columns[colIndex]?.selectedId; + if (selectedId) { + const selectedIdx = items.findIndex(it => it.id === selectedId); if (selectedIdx >= 0) return selectedIdx; } return 0; - }, []); + }, [filteredItemsByCol, columns]); const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => { for (let c = 0; c < cols.length; c++) { - const rowIndex = preferredRowIndex(cols[c]); + const rowIndex = preferredRowIndex(c); if (rowIndex >= 0) return { colIndex: c, rowIndex }; } return null; @@ -239,15 +270,17 @@ export default function FolderBrowser() { if (pendingNavColRef.current !== null) { const targetColIndex = pendingNavColRef.current; const targetCol = columns[targetColIndex]; - if (targetCol && targetCol.items.length > 0 && !targetCol.loading && !targetCol.error) { - const rowIndex = preferredRowIndex(targetCol); - const targetItem = targetCol.items[rowIndex]; + const targetItems = filteredItemsByCol[targetColIndex] ?? []; + if (targetCol && targetItems.length > 0 && !targetCol.loading && !targetCol.error) { + const rowIndex = preferredRowIndex(targetColIndex); + const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1); + const targetItem = targetItems[safeRowIndex]; setColumns(prev => prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)), ); setKeyboardPos({ colIndex: targetColIndex, - rowIndex, + rowIndex: safeRowIndex, }); pendingNavColRef.current = null; return; @@ -258,15 +291,31 @@ export default function FolderBrowser() { if (!prev) return fallbackNavPos(columns); if (prev.colIndex >= columns.length) return fallbackNavPos(columns); const col = columns[prev.colIndex]; - if (col.loading || col.error || col.items.length === 0) return fallbackNavPos(columns); - if (prev.rowIndex >= col.items.length) { - return { colIndex: prev.colIndex, rowIndex: col.items.length - 1 }; + const visibleItems = filteredItemsByCol[prev.colIndex] ?? []; + if (col.loading || col.error || visibleItems.length === 0) return fallbackNavPos(columns); + if (prev.rowIndex >= visibleItems.length) { + return { colIndex: prev.colIndex, rowIndex: visibleItems.length - 1 }; } return prev; }); - }, [columns, fallbackNavPos, preferredRowIndex]); + }, [columns, fallbackNavPos, preferredRowIndex, filteredItemsByCol]); + + const clearFiltersRightOf = useCallback((colIndex: number) => { + setColumnFilters(prev => { + const next: Record = {}; + let changed = false; + Object.entries(prev).forEach(([k, v]) => { + const idx = Number(k); + if (idx <= colIndex) next[idx] = v; + else changed = true; + }); + return changed ? next : prev; + }); + setFilterFocusCol(prev => (prev !== null && prev > colIndex ? null : prev)); + }, []); const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { + clearFiltersRightOf(colIndex); const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory'; setColumns(prev => [ ...prev.slice(0, colIndex + 1).map((c, i) => @@ -305,7 +354,7 @@ export default function FolderBrowser() { return next; }); }); - }, []); + }, [clearFiltersRightOf]); const handleFileClick = useCallback( (colIndex: number, item: SubsonicDirectoryEntry) => { @@ -317,18 +366,22 @@ export default function FolderBrowser() { item.id, ]; setPlayingPathIds(path); - const col = columns[colIndex]; - const queue = col.items.filter(it => !it.isDir).map(entryToTrack); + const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? []; + const queue = visibleItems.filter(it => !it.isDir).map(entryToTrack); playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]); }, - [columns, playTrack], + [columns, filteredItemsByCol, playTrack], ); const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => { - setColumns(prev => - prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c)), - ); - }, []); + setColumns(prev => { + const prevSelectedId = prev[colIndex]?.selectedId ?? null; + if (prevSelectedId !== itemId) { + clearFiltersRightOf(colIndex); + } + return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c)); + }); + }, [clearFiltersRightOf]); const clearSelectedInColumn = useCallback((colIndex: number) => { setColumns(prev => @@ -336,6 +389,7 @@ export default function FolderBrowser() { ); }, []); + const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { if (item.isDir) { handleDirClick(colIndex, item); @@ -376,14 +430,33 @@ export default function FolderBrowser() { 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 (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return; setKeyboardNavActive(true); const current = keyboardPos ?? fallbackNavPos(columns); if (!current) return; const col = columns[current.colIndex]; - const item = col?.items[current.rowIndex]; + const visibleItems = filteredItemsByCol[current.colIndex] ?? []; + const item = visibleItems[current.rowIndex]; if (!col || !item) return; e.preventDefault(); @@ -403,17 +476,28 @@ export default function FolderBrowser() { if (key === 'ArrowUp') { if (current.rowIndex > 0) { const nextRowIndex = current.rowIndex - 1; - const nextItem = col.items[nextRowIndex]; + 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 < col.items.length - 1) { + if (current.rowIndex < visibleItems.length - 1) { const nextRowIndex = current.rowIndex + 1; - const nextItem = col.items[nextRowIndex]; + const nextItem = visibleItems[nextRowIndex]; setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); else setSelectedInColumn(current.colIndex, nextItem.id); @@ -424,7 +508,8 @@ export default function FolderBrowser() { if (current.colIndex > 0) { clearSelectedInColumn(current.colIndex); const nextColIndex = current.colIndex - 1; - const rowIndex = preferredRowIndex(columns[nextColIndex]); + clearFiltersRightOf(nextColIndex); + const rowIndex = preferredRowIndex(nextColIndex); if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex }); } return; @@ -432,9 +517,10 @@ export default function FolderBrowser() { if (key === 'ArrowRight') { const nextColIndex = current.colIndex + 1; if (nextColIndex < columns.length) { - const rowIndex = preferredRowIndex(columns[nextColIndex]); + const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? []; + const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1); if (rowIndex >= 0) { - const nextItem = columns[nextColIndex].items[rowIndex]; + const nextItem = nextVisibleItems[rowIndex]; setSelectedInColumn(nextColIndex, nextItem.id); setKeyboardPos({ colIndex: nextColIndex, rowIndex }); return; @@ -444,9 +530,16 @@ export default function FolderBrowser() { 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]); + }, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]); const onRowContextMenu = useCallback( (e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => { @@ -602,6 +695,55 @@ export default function FolderBrowser() { className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`} data-folder-col-index={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') { + 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 ? (
@@ -610,12 +752,11 @@ export default function FolderBrowser() {
{t('folderBrowser.error')}
- ) : col.items.length === 0 ? ( + ) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
{t('folderBrowser.empty')}
) : ( - col.items.map(item => { + (filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => { const isSelected = col.selectedId === item.id; - const rowIndex = col.items.findIndex(it => it.id === item.id); const isContextRow = contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex; const isKeyboardRow = diff --git a/src/styles/components.css b/src/styles/components.css index c4d20fcb..790b82af 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7256,6 +7256,35 @@ html.no-compositing .fs-lyrics-rail { text-align: center; } +.folder-col-filter { + position: sticky; + top: 0; + z-index: 2; + padding: 0.35rem 0.45rem; + background: color-mix(in srgb, var(--bg-base) 92%, transparent); + border-bottom: 1px solid var(--border-subtle); +} + +.folder-col-filter-input { + width: 100%; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + background: var(--bg-card); + color: var(--text-primary); + font-size: 0.78rem; +} + +.folder-col-filter-input:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 70%, var(--border-subtle)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.folder-col.folder-col--compact .folder-col-filter { + display: none; +} + .folder-col-error { color: var(--danger, #f38ba8); }