mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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 Playlists from './pages/Playlists';
|
||||||
import PlaylistDetail from './pages/PlaylistDetail';
|
import PlaylistDetail from './pages/PlaylistDetail';
|
||||||
import InternetRadio from './pages/InternetRadio';
|
import InternetRadio from './pages/InternetRadio';
|
||||||
|
import FolderBrowser from './pages/FolderBrowser';
|
||||||
import NowPlayingPage from './pages/NowPlaying';
|
import NowPlayingPage from './pages/NowPlaying';
|
||||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||||
import ContextMenu from './components/ContextMenu';
|
import ContextMenu from './components/ContextMenu';
|
||||||
@@ -352,6 +353,7 @@ function AppShell() {
|
|||||||
<Route path="/playlists" element={<Playlists />} />
|
<Route path="/playlists" element={<Playlists />} />
|
||||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||||
<Route path="/radio" element={<InternetRadio />} />
|
<Route path="/radio" element={<InternetRadio />} />
|
||||||
|
<Route path="/folders" element={<FolderBrowser />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -181,6 +181,68 @@ export interface SubsonicArtistInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── API Methods ──────────────────────────────────────────────
|
// ─── 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[]> {
|
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||||
'getMusicFolders.view',
|
'getMusicFolders.view',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||||
ChevronDown, Check, Music2, TrendingUp,
|
ChevronDown, Check, Music2, TrendingUp, FolderOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PsysonicLogo from './PsysonicLogo';
|
import PsysonicLogo from './PsysonicLogo';
|
||||||
import PSmallLogo from './PSmallLogo';
|
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' },
|
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', 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' },
|
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -21,6 +21,7 @@ export const deTranslation = {
|
|||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Meistgehört',
|
mostPlayed: 'Meistgehört',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
|
folderBrowser: 'Ordner-Browser',
|
||||||
libraryScope: 'Bibliotheksumfang',
|
libraryScope: 'Bibliotheksumfang',
|
||||||
allLibraries: 'Alle Bibliotheken',
|
allLibraries: 'Alle Bibliotheken',
|
||||||
},
|
},
|
||||||
@@ -880,5 +881,9 @@ export const deTranslation = {
|
|||||||
favorite: 'Zu Favoriten hinzufügen',
|
favorite: 'Zu Favoriten hinzufügen',
|
||||||
unfavorite: 'Aus Favoriten entfernen',
|
unfavorite: 'Aus Favoriten entfernen',
|
||||||
noFavorites: 'Keine Lieblingssender.',
|
noFavorites: 'Keine Lieblingssender.',
|
||||||
}
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Leerer Ordner',
|
||||||
|
error: 'Laden fehlgeschlagen',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,7 @@ export const enTranslation = {
|
|||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Most Played',
|
mostPlayed: 'Most Played',
|
||||||
radio: 'Internet Radio',
|
radio: 'Internet Radio',
|
||||||
|
folderBrowser: 'Folder Browser',
|
||||||
libraryScope: 'Library scope',
|
libraryScope: 'Library scope',
|
||||||
allLibraries: 'All libraries',
|
allLibraries: 'All libraries',
|
||||||
},
|
},
|
||||||
@@ -881,5 +882,9 @@ export const enTranslation = {
|
|||||||
favorite: 'Add to favorites',
|
favorite: 'Add to favorites',
|
||||||
unfavorite: 'Remove from favorites',
|
unfavorite: 'Remove from favorites',
|
||||||
noFavorites: 'No favorite stations.',
|
noFavorites: 'No favorite stations.',
|
||||||
}
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Empty folder',
|
||||||
|
error: 'Failed to load',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -21,6 +21,7 @@ export const frTranslation = {
|
|||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Les plus joués',
|
mostPlayed: 'Les plus joués',
|
||||||
radio: 'Radio Internet',
|
radio: 'Radio Internet',
|
||||||
|
folderBrowser: 'Explorateur de dossiers',
|
||||||
libraryScope: 'Portée de la bibliothèque',
|
libraryScope: 'Portée de la bibliothèque',
|
||||||
allLibraries: 'Toutes les bibliothèques',
|
allLibraries: 'Toutes les bibliothèques',
|
||||||
},
|
},
|
||||||
@@ -875,5 +876,9 @@ export const frTranslation = {
|
|||||||
favorite: 'Ajouter aux favoris',
|
favorite: 'Ajouter aux favoris',
|
||||||
unfavorite: 'Retirer des favoris',
|
unfavorite: 'Retirer des favoris',
|
||||||
noFavorites: 'Aucune station favorite.',
|
noFavorites: 'Aucune station favorite.',
|
||||||
}
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Dossier vide',
|
||||||
|
error: 'Échec du chargement',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -21,6 +21,7 @@ export const nbTranslation = {
|
|||||||
playlists: 'Spillelister',
|
playlists: 'Spillelister',
|
||||||
mostPlayed: 'Mest spilt',
|
mostPlayed: 'Mest spilt',
|
||||||
radio: 'Internettradio',
|
radio: 'Internettradio',
|
||||||
|
folderBrowser: 'Mappeleser',
|
||||||
libraryScope: 'Biblioteksomfang',
|
libraryScope: 'Biblioteksomfang',
|
||||||
allLibraries: 'Alle biblioteker',
|
allLibraries: 'Alle biblioteker',
|
||||||
},
|
},
|
||||||
@@ -874,5 +875,9 @@ export const nbTranslation = {
|
|||||||
favorite: 'Legg til i favoritter',
|
favorite: 'Legg til i favoritter',
|
||||||
unfavorite: 'Fjern fra favoritter',
|
unfavorite: 'Fjern fra favoritter',
|
||||||
noFavorites: 'Ingen favorittstasjoner.',
|
noFavorites: 'Ingen favorittstasjoner.',
|
||||||
}
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Tom mappe',
|
||||||
|
error: 'Kunne ikke laste',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -21,6 +21,7 @@ export const nlTranslation = {
|
|||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Meest gespeeld',
|
mostPlayed: 'Meest gespeeld',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
|
folderBrowser: 'Mappenverkenner',
|
||||||
libraryScope: 'Bibliotheekbereik',
|
libraryScope: 'Bibliotheekbereik',
|
||||||
allLibraries: 'Alle bibliotheken',
|
allLibraries: 'Alle bibliotheken',
|
||||||
},
|
},
|
||||||
@@ -875,5 +876,9 @@ export const nlTranslation = {
|
|||||||
favorite: 'Toevoegen aan favorieten',
|
favorite: 'Toevoegen aan favorieten',
|
||||||
unfavorite: 'Verwijderen uit favorieten',
|
unfavorite: 'Verwijderen uit favorieten',
|
||||||
noFavorites: 'Geen favoriete stations.',
|
noFavorites: 'Geen favoriete stations.',
|
||||||
}
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Lege map',
|
||||||
|
error: 'Laden mislukt',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const ruTranslation = {
|
|||||||
playlists: 'Плейлисты',
|
playlists: 'Плейлисты',
|
||||||
mostPlayed: 'Часто слушаемое',
|
mostPlayed: 'Часто слушаемое',
|
||||||
radio: 'Онлайн-радио',
|
radio: 'Онлайн-радио',
|
||||||
|
folderBrowser: 'Браузер папок',
|
||||||
libraryScope: 'Область медиатеки',
|
libraryScope: 'Область медиатеки',
|
||||||
allLibraries: 'Все библиотеки',
|
allLibraries: 'Все библиотеки',
|
||||||
},
|
},
|
||||||
@@ -933,4 +934,8 @@ export const ruTranslation = {
|
|||||||
unfavorite: 'Убрать из избранного',
|
unfavorite: 'Убрать из избранного',
|
||||||
noFavorites: 'Избранных станций нет.',
|
noFavorites: 'Избранных станций нет.',
|
||||||
},
|
},
|
||||||
|
folderBrowser: {
|
||||||
|
empty: 'Папка пуста',
|
||||||
|
error: 'Ошибка загрузки',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -21,6 +21,7 @@ export const zhTranslation = {
|
|||||||
playlists: '播放列表',
|
playlists: '播放列表',
|
||||||
mostPlayed: '最常播放',
|
mostPlayed: '最常播放',
|
||||||
radio: '网络电台',
|
radio: '网络电台',
|
||||||
|
folderBrowser: '文件夹浏览器',
|
||||||
libraryScope: '资料库范围',
|
libraryScope: '资料库范围',
|
||||||
allLibraries: '所有资料库',
|
allLibraries: '所有资料库',
|
||||||
},
|
},
|
||||||
@@ -871,5 +872,9 @@ export const zhTranslation = {
|
|||||||
favorite: '添加到收藏',
|
favorite: '添加到收藏',
|
||||||
unfavorite: '从收藏移除',
|
unfavorite: '从收藏移除',
|
||||||
noFavorites: '没有收藏的电台。',
|
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: 'playlists', visible: true },
|
||||||
{ id: 'mostPlayed', visible: true },
|
{ id: 'mostPlayed', visible: true },
|
||||||
{ id: 'radio', visible: true },
|
{ id: 'radio', visible: true },
|
||||||
|
{ id: 'folderBrowser', visible: false },
|
||||||
{ id: 'statistics', visible: true },
|
{ id: 'statistics', visible: true },
|
||||||
{ id: 'help', visible: true },
|
{ id: 'help', visible: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6767,3 +6767,111 @@
|
|||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent);
|
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