mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35: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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user