mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(folder-browser): G.87 — extract column component + 3 hooks (cluster, multi-commit) (#654)
* refactor(folder-browser): G.87.1 — extract helpers + types Move ColumnKind / NavPos / Column types + entryToAlbumIfPresent / entryToTrack mappers + isFolderBrowserArrowKey / folderBrowserHasKeyModifiers key-event helpers into src/utils/folderBrowserHelpers.ts. Pure code move. * refactor(folder-browser): G.87.2 — extract FolderBrowserColumn component * refactor(folder-browser): G.87.3 — extract useFolderBrowserNowPlayingPath hook * refactor(folder-browser): G.87.4 — extract useFolderBrowserScrolling hook * refactor(folder-browser): G.87.5 — extract useFolderBrowserKeyboardNav hook
This commit is contained in:
committed by
GitHub
parent
c8e130ecea
commit
d4d3b0e53f
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`folder-col${isCompact ? ' folder-col--compact' : ''}`}
|
||||||
|
data-folder-col-index={colIndex}
|
||||||
|
>
|
||||||
|
{filterVisible && (
|
||||||
|
<div className="folder-col-filter">
|
||||||
|
<input
|
||||||
|
ref={registerFilterInput}
|
||||||
|
data-folder-filter-input="true"
|
||||||
|
className="folder-col-filter-input"
|
||||||
|
value={filterValue}
|
||||||
|
placeholder={t('playlists.searchPlaceholder')}
|
||||||
|
onFocus={onFilterFocus}
|
||||||
|
onBlur={onFilterBlur}
|
||||||
|
onKeyDown={e => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{col.loading ? (
|
||||||
|
<div className="folder-col-status">
|
||||||
|
<div className="spinner" style={{ width: 20, height: 20 }} />
|
||||||
|
</div>
|
||||||
|
) : col.error ? (
|
||||||
|
<div className="folder-col-status folder-col-error">
|
||||||
|
{t('folderBrowser.error')}
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
title={item.title}
|
||||||
|
data-col-index={colIndex}
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-item-id={item.id}
|
||||||
|
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
|
||||||
|
onClick={() => onRowClick(item, rowIndex)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onContextMenu={e => onRowContextMenu(e, rowIndex, col, item)}
|
||||||
|
>
|
||||||
|
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
|
||||||
|
{item.isDir ? (
|
||||||
|
isSelected ? (
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
) : (
|
||||||
|
<Folder size={14} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Music
|
||||||
|
size={14}
|
||||||
|
strokeWidth={isNowPlayingTrack ? 2.5 : 2}
|
||||||
|
className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="folder-col-name">{item.title}</span>
|
||||||
|
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number, string>;
|
||||||
|
filterFocusCol: number | null;
|
||||||
|
keyboardPos: NavPos | null;
|
||||||
|
isContextMenuOpen: boolean;
|
||||||
|
filterInputRefs: React.RefObject<Record<number, HTMLInputElement | null>>;
|
||||||
|
wrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
setKeyboardNavActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setKeyboardPos: React.Dispatch<React.SetStateAction<NavPos | null>>;
|
||||||
|
setContextAnchorPos: React.Dispatch<React.SetStateAction<NavPos | null>>;
|
||||||
|
setFilterFocusCol: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
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<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 (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<HTMLElement>(
|
||||||
|
`.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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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<React.SetStateAction<Column[]>>;
|
||||||
|
setKeyboardPos: React.Dispatch<React.SetStateAction<NavPos | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
playingPathIds: string[];
|
||||||
|
setPlayingPathIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
isSelectedPathForCurrentTrack: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolderBrowserNowPlayingPath({
|
||||||
|
columns, currentTrack, isPlaying, setColumns, setKeyboardPos,
|
||||||
|
}: Args): Result {
|
||||||
|
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
||||||
|
const autoResolvedTrackRef = useRef<string | null>(null);
|
||||||
|
const prevTrackIdRef = useRef<string | null>(null);
|
||||||
|
const lastHotkeyRevealTsRef = useRef<number | null>(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<Column[] | null> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
wrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
columnsViewportWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolderBrowserScrolling({
|
||||||
|
columns, keyboardPos, keyboardNavActive, setKeyboardNavActive,
|
||||||
|
}: Args): Result {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(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<HTMLElement>(
|
||||||
|
`.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<HTMLElement>(
|
||||||
|
`.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<HTMLElement>(
|
||||||
|
`.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 };
|
||||||
|
}
|
||||||
+78
-526
@@ -1,94 +1,16 @@
|
|||||||
import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary';
|
import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicDirectoryEntry, SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicDirectoryEntry, SubsonicArtist } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
|
||||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
import {
|
||||||
import { useLocation } from 'react-router-dom';
|
entryToAlbumIfPresent, entryToTrack,
|
||||||
|
type Column, type ColumnKind, type NavPos,
|
||||||
type ColumnKind = 'roots' | 'indexes' | 'directory';
|
} from '../utils/folderBrowserHelpers';
|
||||||
type NavPos = { colIndex: number; rowIndex: number };
|
import FolderBrowserColumn from '../components/folderBrowser/FolderBrowserColumn';
|
||||||
let persistedPlayingPathIds: string[] = [];
|
import { useFolderBrowserNowPlayingPath } from '../hooks/useFolderBrowserNowPlayingPath';
|
||||||
|
import { useFolderBrowserScrolling } from '../hooks/useFolderBrowserScrolling';
|
||||||
type Column = {
|
import { useFolderBrowserKeyboardNav } from '../hooks/useFolderBrowserKeyboardNav';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FolderBrowser() {
|
export default function FolderBrowser() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -96,17 +18,10 @@ export default function FolderBrowser() {
|
|||||||
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
||||||
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
|
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
|
||||||
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
|
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
|
||||||
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
const pendingNavColRef = useRef<number | null>(null);
|
const pendingNavColRef = useRef<number | null>(null);
|
||||||
const autoResolvedTrackRef = useRef<string | null>(null);
|
|
||||||
const prevTrackIdRef = useRef<string | null>(null);
|
|
||||||
const lastHotkeyRevealTsRef = useRef<number | null>(null);
|
|
||||||
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
|
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
|
||||||
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(null);
|
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(null);
|
||||||
const [columnsViewportWidth, setColumnsViewportWidth] = useState(0);
|
|
||||||
const location = useLocation();
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
@@ -114,6 +29,13 @@ export default function FolderBrowser() {
|
|||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
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(() => {
|
useEffect(() => {
|
||||||
const placeholder: Column = {
|
const placeholder: Column = {
|
||||||
id: 'root',
|
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<HTMLElement>(
|
|
||||||
`.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<HTMLElement>(
|
|
||||||
`.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<HTMLElement>(
|
|
||||||
`.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(() => {
|
useEffect(() => {
|
||||||
setColumnFilters(prev => {
|
setColumnFilters(prev => {
|
||||||
const next: Record<number, string> = {};
|
const next: Record<number, string> = {};
|
||||||
@@ -225,41 +79,6 @@ export default function FolderBrowser() {
|
|||||||
if (!isContextMenuOpen) setContextAnchorPos(null);
|
if (!isContextMenuOpen) setContextAnchorPos(null);
|
||||||
}, [isContextMenuOpen]);
|
}, [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(() => {
|
const filteredItemsByCol = useMemo(() => {
|
||||||
return columns.map((col, colIndex) => {
|
return columns.map((col, colIndex) => {
|
||||||
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
|
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
|
||||||
@@ -452,119 +271,14 @@ export default function FolderBrowser() {
|
|||||||
[openContextMenu],
|
[openContextMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onColumnsKeyDown = useFolderBrowserKeyboardNav({
|
||||||
if (isContextMenuOpen) return;
|
columns, filteredItemsByCol, columnFilters, filterFocusCol, keyboardPos,
|
||||||
const target = e.target as HTMLElement;
|
isContextMenuOpen, filterInputRefs, wrapperRef,
|
||||||
const inFilterInput =
|
setKeyboardNavActive, setKeyboardPos, setContextAnchorPos, setFilterFocusCol,
|
||||||
target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true';
|
preferredRowIndex, fallbackNavPos,
|
||||||
if (inFilterInput) return;
|
handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn,
|
||||||
const key = e.key;
|
openContextMenuForEntry, clearFiltersRightOf,
|
||||||
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<HTMLElement>(
|
|
||||||
`.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 onRowContextMenu = useCallback(
|
const onRowContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
|
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
|
||||||
@@ -576,61 +290,6 @@ export default function FolderBrowser() {
|
|||||||
[openContextMenuForEntry],
|
[openContextMenuForEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveColumnsForTrack = useCallback(async (
|
|
||||||
track: Track,
|
|
||||||
roots: SubsonicDirectoryEntry[],
|
|
||||||
): Promise<Column[] | null> => {
|
|
||||||
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(() => {
|
const activeColIndex = useMemo(() => {
|
||||||
if (keyboardPos) return keyboardPos.colIndex;
|
if (keyboardPos) return keyboardPos.colIndex;
|
||||||
const fromSelection = [...columns]
|
const fromSelection = [...columns]
|
||||||
@@ -657,54 +316,6 @@ export default function FolderBrowser() {
|
|||||||
return Math.abs(colIndex - visibleAnchorColIndex) > 1;
|
return Math.abs(colIndex - visibleAnchorColIndex) > 1;
|
||||||
}, [compactColumnsEnabled, visibleAnchorColIndex]);
|
}, [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 (
|
return (
|
||||||
<div className="folder-browser">
|
<div className="folder-browser">
|
||||||
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
|
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
|
||||||
@@ -715,120 +326,61 @@ export default function FolderBrowser() {
|
|||||||
onKeyDown={onColumnsKeyDown}
|
onKeyDown={onColumnsKeyDown}
|
||||||
>
|
>
|
||||||
{columns.map((col, colIndex) => (
|
{columns.map((col, colIndex) => (
|
||||||
<div
|
<FolderBrowserColumn
|
||||||
key={`${col.id}-${colIndex}`}
|
key={`${col.id}-${colIndex}`}
|
||||||
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
|
col={col}
|
||||||
data-folder-col-index={colIndex}
|
colIndex={colIndex}
|
||||||
>
|
isCompact={isColumnCompact(col, colIndex)}
|
||||||
{(filterFocusCol === colIndex || !!columnFilters[colIndex]) && (
|
filterValue={columnFilters[colIndex] ?? ''}
|
||||||
<div className="folder-col-filter">
|
filterVisible={filterFocusCol === colIndex || !!columnFilters[colIndex]}
|
||||||
<input
|
filteredItems={filteredItemsByCol[colIndex] ?? []}
|
||||||
ref={el => { filterInputRefs.current[colIndex] = el; }}
|
keyboardRowIndex={keyboardPos?.colIndex === colIndex ? keyboardPos.rowIndex : null}
|
||||||
data-folder-filter-input="true"
|
contextRowIndex={contextAnchorPos?.colIndex === colIndex ? contextAnchorPos.rowIndex : null}
|
||||||
className="folder-col-filter-input"
|
currentTrack={currentTrack}
|
||||||
value={columnFilters[colIndex] ?? ''}
|
isPlaying={isPlaying}
|
||||||
placeholder={t('playlists.searchPlaceholder')}
|
isSelectedPathForCurrentTrack={!!isSelectedPathForCurrentTrack}
|
||||||
onFocus={() => setFilterFocusCol(colIndex)}
|
playingPathIds={playingPathIds}
|
||||||
onBlur={() => {
|
registerFilterInput={el => { filterInputRefs.current[colIndex] = el; }}
|
||||||
if (!(columnFilters[colIndex] ?? '').trim()) {
|
onFilterFocus={() => setFilterFocusCol(colIndex)}
|
||||||
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
|
onFilterBlur={() => {
|
||||||
}
|
if (!(columnFilters[colIndex] ?? '').trim()) {
|
||||||
}}
|
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
|
||||||
onKeyDown={e => {
|
}
|
||||||
if (e.key === 'Escape') {
|
}}
|
||||||
e.preventDefault();
|
onFilterEscape={() => {
|
||||||
e.stopPropagation();
|
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
|
||||||
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
|
setFilterFocusCol(null);
|
||||||
setFilterFocusCol(null);
|
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||||
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
}}
|
||||||
return;
|
onFilterArrowDown={() => {
|
||||||
}
|
const rowIndex = preferredRowIndex(colIndex);
|
||||||
if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) {
|
if (rowIndex >= 0) {
|
||||||
e.preventDefault();
|
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
|
||||||
e.stopPropagation();
|
if (nextItem) {
|
||||||
const rowIndex = preferredRowIndex(colIndex);
|
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
|
||||||
if (rowIndex >= 0) {
|
else setSelectedInColumn(colIndex, nextItem.id);
|
||||||
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
|
}
|
||||||
if (nextItem) {
|
setKeyboardPos({ colIndex, rowIndex });
|
||||||
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
|
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||||
else setSelectedInColumn(colIndex, nextItem.id);
|
}
|
||||||
}
|
}}
|
||||||
setKeyboardPos({ colIndex, rowIndex });
|
onFilterChange={value => {
|
||||||
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
|
||||||
}
|
setKeyboardPos(prev => {
|
||||||
}
|
if (!prev || prev.colIndex !== colIndex) return prev;
|
||||||
}}
|
return { colIndex, rowIndex: 0 };
|
||||||
onChange={e => {
|
});
|
||||||
const value = e.target.value;
|
}}
|
||||||
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
|
onRowClick={(item, rowIndex) => {
|
||||||
setKeyboardPos(prev => {
|
setKeyboardPos({ colIndex, rowIndex });
|
||||||
if (!prev || prev.colIndex !== colIndex) return prev;
|
if (item.isDir) handleDirClick(colIndex, item);
|
||||||
return { colIndex, rowIndex: 0 };
|
else handleFileClick(colIndex, item);
|
||||||
});
|
}}
|
||||||
}}
|
onRowContextMenu={(e, rowIndex, c, item) => {
|
||||||
/>
|
setKeyboardPos({ colIndex, rowIndex });
|
||||||
</div>
|
onRowContextMenu(e, colIndex, rowIndex, c, item);
|
||||||
)}
|
}}
|
||||||
{col.loading ? (
|
/>
|
||||||
<div className="folder-col-status">
|
|
||||||
<div className="spinner" style={{ width: 20, height: 20 }} />
|
|
||||||
</div>
|
|
||||||
) : col.error ? (
|
|
||||||
<div className="folder-col-status folder-col-error">
|
|
||||||
{t('folderBrowser.error')}
|
|
||||||
</div>
|
|
||||||
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
|
|
||||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
|
||||||
) : (
|
|
||||||
(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 (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
title={item.title}
|
|
||||||
data-col-index={colIndex}
|
|
||||||
data-row-index={rowIndex}
|
|
||||||
data-item-id={item.id}
|
|
||||||
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setKeyboardPos({ colIndex, rowIndex });
|
|
||||||
if (item.isDir) handleDirClick(colIndex, item);
|
|
||||||
else handleFileClick(colIndex, item);
|
|
||||||
}}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onContextMenu={e => {
|
|
||||||
setKeyboardPos({ colIndex, rowIndex });
|
|
||||||
onRowContextMenu(e, colIndex, rowIndex, col, item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
|
|
||||||
{item.isDir ? (
|
|
||||||
isSelected ? (
|
|
||||||
<FolderOpen size={14} />
|
|
||||||
) : (
|
|
||||||
<Folder size={14} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Music size={14} strokeWidth={isNowPlayingTrack ? 2.5 : 2} className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="folder-col-name">{item.title}</span>
|
|
||||||
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user