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.
This commit is contained in:
Maxim Isaev
2026-04-12 14:08:36 +03:00
parent c2f7d6d495
commit a12d6a4015
2 changed files with 203 additions and 33 deletions
+174 -33
View File
@@ -69,9 +69,12 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track {
export default function FolderBrowser() {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column[]>([]);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
const wrapperRef = useRef<HTMLDivElement>(null);
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
const pendingNavColRef = useRef<number | null>(null);
const autoResolvedTrackRef = useRef<string | null>(null);
const prevTrackIdRef = useRef<string | null>(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<number, string> = {};
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<number, string> = {};
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<HTMLDivElement>) => {
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]) && (
<div className="folder-col-filter">
<input
ref={el => { 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 };
});
}}
/>
</div>
)}
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
@@ -610,12 +752,11 @@ export default function FolderBrowser() {
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : col.items.length === 0 ? (
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
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 =
+29
View File
@@ -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);
}