mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(folder-browser): Miller columns directory navigation
New page /folders with macOS Finder-style column layout: - getMusicDirectory / getMusicIndexes / getMusicFolders Subsonic API methods - Root level uses getIndexes.view (music folder IDs are not getMusicDirectory IDs) - Columns fill available width with flex: 1, min-width: 200px - Auto-scroll to rightmost column on expand - Cancelled-flag prevents stale state on fast navigation - Clicking a track plays it in the context of the current column's tracks - FolderOpen/Folder/Music icons, accent-colored selection, ellipsis truncation - Hidden in sidebar by default (user can enable in Settings) - i18n: EN DE FR NL NB ZH RU Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -352,6 +353,7 @@ function AppShell() {
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -181,6 +181,68 @@ export interface SubsonicArtistInfo {
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
export interface SubsonicDirectoryEntry {
|
||||
id: string;
|
||||
parent?: string;
|
||||
title: string;
|
||||
isDir: boolean;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
track?: number;
|
||||
year?: number;
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
size?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicDirectory {
|
||||
id: string;
|
||||
parent?: string;
|
||||
name: string;
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
|
||||
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
||||
'getMusicDirectory.view',
|
||||
{ id },
|
||||
);
|
||||
const dir = data.directory;
|
||||
const raw = dir.child;
|
||||
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
||||
}
|
||||
|
||||
/** Returns the top-level artist/directory entries for a music folder root.
|
||||
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs —
|
||||
* use getIndexes.view with musicFolderId instead. */
|
||||
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
||||
type IndexArtist = { id: string; name: string; coverArt?: string };
|
||||
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
|
||||
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
|
||||
'getIndexes.view',
|
||||
{ musicFolderId },
|
||||
);
|
||||
const raw = data.indexes?.index;
|
||||
if (!raw) return [];
|
||||
const indices = Array.isArray(raw) ? raw : [raw];
|
||||
const entries: SubsonicDirectoryEntry[] = [];
|
||||
for (const idx of indices) {
|
||||
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
|
||||
for (const a of artists) {
|
||||
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||
'getMusicFolders.view',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -28,6 +28,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ export const deTranslation = {
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
},
|
||||
@@ -880,5 +881,9 @@ export const deTranslation = {
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Leerer Ordner',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -22,6 +22,7 @@ export const enTranslation = {
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
},
|
||||
@@ -881,5 +882,9 @@ export const enTranslation = {
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Empty folder',
|
||||
error: 'Failed to load',
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ export const frTranslation = {
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
folderBrowser: 'Explorateur de dossiers',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
},
|
||||
@@ -875,5 +876,9 @@ export const frTranslation = {
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Dossier vide',
|
||||
error: 'Échec du chargement',
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ export const nbTranslation = {
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
folderBrowser: 'Mappeleser',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
},
|
||||
@@ -874,5 +875,9 @@ export const nbTranslation = {
|
||||
favorite: 'Legg til i favoritter',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
noFavorites: 'Ingen favorittstasjoner.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Tom mappe',
|
||||
error: 'Kunne ikke laste',
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ export const nlTranslation = {
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
},
|
||||
@@ -875,5 +876,9 @@ export const nlTranslation = {
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Lege map',
|
||||
error: 'Laden mislukt',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ruTranslation = {
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
},
|
||||
@@ -933,4 +934,8 @@ export const ruTranslation = {
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Папка пуста',
|
||||
error: 'Ошибка загрузки',
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ export const zhTranslation = {
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
folderBrowser: '文件夹浏览器',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
},
|
||||
@@ -871,5 +872,9 @@ export const zhTranslation = {
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: '空文件夹',
|
||||
error: '加载失败',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Column = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: SubsonicDirectoryEntry[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FolderBrowser() {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column[]>([]);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
// ── root: load music folders on mount ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false };
|
||||
setColumns([placeholder]);
|
||||
getMusicFolders()
|
||||
.then(folders => {
|
||||
const items: SubsonicDirectoryEntry[] = folders.map(f => ({
|
||||
id: f.id,
|
||||
title: f.name,
|
||||
isDir: true,
|
||||
}));
|
||||
setColumns([{ ...placeholder, items, loading: false }]);
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── auto-scroll to newly added column ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
|
||||
}, [columns.length]);
|
||||
|
||||
// ── click a directory ──────────────────────────────────────────────────────
|
||||
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
// Mark selected + truncate columns after this one + add loading column
|
||||
setColumns(prev => [
|
||||
...prev.slice(0, colIndex + 1).map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
),
|
||||
{ id: item.id, name: item.title, items: [], selectedId: null, loading: true, error: false },
|
||||
]);
|
||||
|
||||
// Column 0 holds music folder roots — their IDs are only valid for
|
||||
// getIndexes.view (musicFolderId), not getMusicDirectory.view
|
||||
const fetchItems = colIndex === 0
|
||||
? getMusicIndexes(item.id)
|
||||
: getMusicDirectory(item.id).then(d => d.child);
|
||||
|
||||
fetchItems
|
||||
.then(items => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], items, loading: false };
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], loading: false, error: true };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── click a file (track) ───────────────────────────────────────────────────
|
||||
const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
setColumns(prev => prev.map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
));
|
||||
// Build queue from all tracks in this column
|
||||
const col = columns[colIndex];
|
||||
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
|
||||
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
||||
}, [columns, playTrack]);
|
||||
|
||||
// ── render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="folder-browser">
|
||||
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
|
||||
<div className="folder-browser-columns" ref={wrapperRef}>
|
||||
{columns.map((col, colIndex) => (
|
||||
<div key={`${col.id}-${colIndex}`} className="folder-col">
|
||||
{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>
|
||||
) : col.items.length === 0 ? (
|
||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||
) : (
|
||||
col.items.map(item => {
|
||||
const isSelected = col.selectedId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`folder-col-row${isSelected ? ' selected' : ''}`}
|
||||
onClick={() =>
|
||||
item.isDir
|
||||
? handleDirClick(colIndex, item)
|
||||
: handleFileClick(colIndex, item)
|
||||
}
|
||||
>
|
||||
<span className="folder-col-icon">
|
||||
{item.isDir
|
||||
? isSelected
|
||||
? <FolderOpen size={14} />
|
||||
: <Folder size={14} />
|
||||
: <Music size={14} />}
|
||||
</span>
|
||||
<span className="folder-col-name">{item.title}</span>
|
||||
{item.isDir && (
|
||||
<ChevronRight size={12} className="folder-col-chevron" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'folderBrowser', visible: false },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
|
||||
@@ -6767,3 +6767,111 @@
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Folder Browser (Miller Columns) ──────────────────────────────────────── */
|
||||
|
||||
.folder-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.folder-browser-title {
|
||||
padding: 1.5rem 1.5rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-browser-columns {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* height fallback for browsers that don't support flex: 1 correctly here */
|
||||
height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.folder-browser-columns::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.folder-col {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.folder-col-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-col-error {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
.folder-col-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.38rem 0.6rem 0.38rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.folder-col-row:hover {
|
||||
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
|
||||
}
|
||||
|
||||
.folder-col-row.selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.folder-col-row.selected .folder-col-chevron {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.folder-col-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-col-row.selected .folder-col-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.folder-col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-col-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user