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;
+}