mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Initial release with i18n and theming
This commit is contained in:
+242
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||
import QueuePanel from './components/QueuePanel';
|
||||
import Home from './pages/Home';
|
||||
import Albums from './pages/Albums';
|
||||
import Artists from './pages/Artists';
|
||||
import ArtistDetail from './pages/ArtistDetail';
|
||||
import NewReleases from './pages/NewReleases';
|
||||
import Favorites from './pages/Favorites';
|
||||
import RandomMix from './pages/RandomMix';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import LabelAlbums from './pages/LabelAlbums';
|
||||
import Statistics from './pages/Statistics';
|
||||
import Playlists from './pages/Playlists';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn } = useAuthStore();
|
||||
if (!isLoggedIn) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
}, [initializeFromServerQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
try {
|
||||
const appWindow = getCurrentWindow();
|
||||
if (currentTrack) {
|
||||
const state = isPlaying ? '▶' : '⏸';
|
||||
const title = `${state} ${currentTrack.artist} - ${currentTrack.title} | Psysonic`;
|
||||
document.title = title;
|
||||
await appWindow.setTitle(title);
|
||||
} else {
|
||||
document.title = 'Psysonic';
|
||||
await appWindow.setTitle('Psysonic');
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
});
|
||||
const [queueWidth, setQueueWidth] = useState(300);
|
||||
const [isDraggingSidebar, setIsDraggingSidebar] = useState(false);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('psysonic_sidebar_collapsed', isSidebarCollapsed.toString());
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDraggingSidebar) {
|
||||
// Limit sidebar width between 180px and 400px
|
||||
const newWidth = Math.max(180, Math.min(e.clientX, 400));
|
||||
setSidebarWidth(newWidth);
|
||||
} else if (isDraggingQueue) {
|
||||
// Limit queue width between 250px and 500px. Queue is on the right.
|
||||
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
|
||||
setQueueWidth(newWidth);
|
||||
}
|
||||
}, [isDraggingSidebar, isDraggingQueue]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingSidebar(false);
|
||||
setIsDraggingQueue(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingSidebar || isDraggingQueue) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.classList.add('is-dragging');
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.classList.remove('is-dragging');
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
}, [isDraggingSidebar, isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : `${sidebarWidth}px`,
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
<div
|
||||
className="resizer resizer-sidebar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingSidebar(true);
|
||||
}}
|
||||
style={{ display: isSidebarCollapsed ? 'none' : 'block' }}
|
||||
/>
|
||||
<main className="main-content">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<NowPlayingDropdown />
|
||||
</header>
|
||||
<div className="content-body" style={{ padding: 0 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingQueue(true);
|
||||
}}
|
||||
style={{ display: isQueueVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
<QueuePanel />
|
||||
<PlayerBar />
|
||||
{isFullscreenOpen && (
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tray / media key event handler
|
||||
function TauriEventBridge() {
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const { minimizeToTray } = useAuthStore();
|
||||
|
||||
// Spacebar → play/pause (ignore when focus is in an input)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [togglePlay]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
|
||||
listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('media:next', () => next()).then(u => unlisten.push(u));
|
||||
listen('media:prev', () => previous()).then(u => unlisten.push(u));
|
||||
listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u));
|
||||
listen('tray:next', () => next()).then(u => unlisten.push(u));
|
||||
|
||||
// Handle close → minimize to tray if enabled (Tauri 2 approach)
|
||||
const win = getCurrentWindow();
|
||||
win.onCloseRequested(async (event) => {
|
||||
if (minimizeToTray) {
|
||||
event.preventDefault();
|
||||
await win.hide();
|
||||
}
|
||||
}).then(u => unlisten.push(u));
|
||||
|
||||
return () => unlisten.forEach(u => u());
|
||||
}, [togglePlay, next, previous, minimizeToTray]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
// ─── Token Auth ───────────────────────────────────────────────
|
||||
function getAuthParams(username: string, password: string) {
|
||||
const salt = Math.random().toString(36).substring(2, 10);
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
const { getBaseUrl, username, password } = useAuthStore.getState();
|
||||
const baseUrl = getBaseUrl();
|
||||
const params = getAuthParams(username, password);
|
||||
|
||||
return {
|
||||
baseUrl: `${baseUrl}/rest`,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
async function api<T>(endpoint: string, extra: Record<string, unknown> = {}): Promise<T> {
|
||||
const { baseUrl, params } = getClient();
|
||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||
params: { ...params, ...extra },
|
||||
paramsSerializer: { indexes: null }
|
||||
});
|
||||
const data = resp.data['subsonic-response'];
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
export interface SubsonicAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt?: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
duration: number;
|
||||
track?: number;
|
||||
discNumber?: number;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
userRating?: number;
|
||||
// Audio technical info
|
||||
bitRate?: number; // kbps
|
||||
suffix?: string; // mp3, flac, opus…
|
||||
contentType?: string; // audio/mpeg, audio/flac…
|
||||
size?: number; // bytes
|
||||
samplingRate?: number; // Hz
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
created: string;
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
username: string;
|
||||
minutesAgo: number;
|
||||
playerId: number;
|
||||
playerName: string;
|
||||
}
|
||||
|
||||
export interface SubsonicArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
value: string; // The genre name is returned as "value" by subsonic
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
}
|
||||
|
||||
export interface SubsonicArtistInfo {
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
await api('ping.view');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size });
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getAlbumList(
|
||||
type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest',
|
||||
size = 30,
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
|
||||
return data.randomSongs?.song ?? [];
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view');
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id });
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
|
||||
return data.topSongs?.song ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
|
||||
return data.similarSongs2?.song ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
|
||||
return data.genres?.genre ?? [];
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
}
|
||||
|
||||
export interface StarredResults {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
}
|
||||
|
||||
export async function getStarred(): Promise<StarredResults> {
|
||||
const data = await api<{
|
||||
starred2: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view');
|
||||
|
||||
const r = data.starred2 ?? {};
|
||||
return {
|
||||
artists: r.artist ?? [],
|
||||
albums: r.album ?? [],
|
||||
songs: r.song ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
await api('star.view', params);
|
||||
}
|
||||
|
||||
export async function unstar(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
await api('unstar.view', params);
|
||||
}
|
||||
|
||||
export async function search(query: string, options?: { albumCount?: number; artistCount?: number; songCount?: number }): Promise<SearchResults> {
|
||||
if (!query.trim()) return { artists: [], albums: [], songs: [] };
|
||||
const data = await api<{
|
||||
searchResult3: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}>('search3.view', {
|
||||
query,
|
||||
artistCount: options?.artistCount ?? 5,
|
||||
albumCount: options?.albumCount ?? 5,
|
||||
songCount: options?.songCount ?? 10,
|
||||
});
|
||||
const r = data.searchResult3 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
|
||||
export async function scrobbleSong(id: string, time: number): Promise<void> {
|
||||
try {
|
||||
await api('scrobble.view', { id, time, submission: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportNowPlaying(id: string): Promise<void> {
|
||||
try {
|
||||
await api('scrobble.view', { id, submission: false });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream URL ───────────────────────────────────────────────
|
||||
export function buildStreamUrl(id: string): string {
|
||||
const { getBaseUrl, username, password } = useAuthStore.getState();
|
||||
const baseUrl = getBaseUrl();
|
||||
const { u, t, s, v, c, f } = (() => {
|
||||
const salt = Math.random().toString(36).substring(2, 10);
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
|
||||
})();
|
||||
|
||||
const p = new URLSearchParams({ id, u, t, s, v, c, f });
|
||||
return `${baseUrl}/rest/stream.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const { getBaseUrl, username, password } = useAuthStore.getState();
|
||||
const baseUrl = getBaseUrl();
|
||||
const salt = Math.random().toString(36).substring(2, 10);
|
||||
const token = md5(password + salt);
|
||||
const p = new URLSearchParams({ id, size: String(size), u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' });
|
||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function buildDownloadUrl(id: string): string {
|
||||
const { getBaseUrl, username, password } = useAuthStore.getState();
|
||||
const baseUrl = getBaseUrl();
|
||||
const salt = Math.random().toString(36).substring(2, 10);
|
||||
const token = md5(password + salt);
|
||||
const p = new URLSearchParams({ id, u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' });
|
||||
return `${baseUrl}/rest/download.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
// ─── Playlists ────────────────────────────────────────────────
|
||||
export async function getPlaylists(): Promise<SubsonicPlaylist[]> {
|
||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view');
|
||||
return data.playlists?.playlist ?? [];
|
||||
}
|
||||
|
||||
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
|
||||
// Songs inside a playlist are typically in the 'entry' array
|
||||
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
|
||||
const { entry, ...playlist } = data.playlist;
|
||||
return { playlist, songs: entry ?? [] };
|
||||
}
|
||||
|
||||
export async function createPlaylist(name: string, songIds?: string[]): Promise<void> {
|
||||
const params: Record<string, unknown> = { name };
|
||||
if (songIds && songIds.length > 0) {
|
||||
params.songId = songIds;
|
||||
}
|
||||
await api('createPlaylist.view', params);
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
await api('deletePlaylist.view', { id });
|
||||
}
|
||||
|
||||
// ─── Play Queue Sync ──────────────────────────────────────────
|
||||
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
|
||||
try {
|
||||
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
|
||||
const pq = data.playQueue;
|
||||
return {
|
||||
current: pq?.current,
|
||||
position: pq?.position,
|
||||
songs: pq?.entry ?? []
|
||||
};
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise<void> {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (songIds.length > 0) {
|
||||
params.id = songIds;
|
||||
}
|
||||
if (current !== undefined) params.current = current;
|
||||
if (position !== undefined) params.position = position;
|
||||
|
||||
await api('savePlayQueue.view', params);
|
||||
}
|
||||
|
||||
// ─── Now Playing ──────────────────────────────────────────────
|
||||
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
|
||||
try {
|
||||
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying[] } | '' }>('getNowPlaying.view');
|
||||
// Navidrome might return an empty string or empty object if nobody is playing
|
||||
if (!data.nowPlaying || typeof data.nowPlaying === 'string') return [];
|
||||
return data.nowPlaying.entry ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
}
|
||||
|
||||
export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="album-card card"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
|
||||
aria-label={`Details zu ${album.name}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
|
||||
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
albums: SubsonicAlbum[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
|
||||
// Auto-load trigger
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLoadMore = async () => {
|
||||
if (!onLoadMore || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
await onLoadMore();
|
||||
setLoadingMore(false);
|
||||
loadingRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [albums]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (albums.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<ArrowRight size={24} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-name" data-tooltip={artist.name}>
|
||||
{artist.name}
|
||||
</div>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<div className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { SubsonicArtist } from '../api/subsonic';
|
||||
import ArtistCardLocal from './ArtistCardLocal';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
artists: SubsonicArtist[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
|
||||
// Auto-load trigger
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLoadMore = async () => {
|
||||
if (!onLoadMore || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
await onLoadMore();
|
||||
setLoadingMore(false);
|
||||
loadingRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [artists]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="artist-row-section">
|
||||
<div className="artist-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="artist-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<ArrowRight size={24} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Adjusted coordinates to keep menu on screen
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen) {
|
||||
setCoords({ x: contextMenu.x, y: contextMenu.y });
|
||||
}
|
||||
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen && menuRef.current) {
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const winW = window.innerWidth;
|
||||
const winH = window.innerHeight;
|
||||
|
||||
let finalX = contextMenu.x;
|
||||
let finalY = contextMenu.y;
|
||||
|
||||
if (finalX + rect.width > winW) finalX = winW - rect.width - 10;
|
||||
if (finalY + rect.height > winH) finalY = winH - rect.height - 10;
|
||||
|
||||
setCoords({ x: finalX, y: finalY });
|
||||
}
|
||||
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
};
|
||||
|
||||
if (contextMenu.isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [contextMenu.isOpen, closeContextMenu]);
|
||||
|
||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||
|
||||
const { type, item, queueIndex } = contextMenu;
|
||||
|
||||
const handleAction = async (action: () => void | Promise<void>) => {
|
||||
closeContextMenu();
|
||||
await action();
|
||||
};
|
||||
|
||||
const startRadio = async (artistId: string, artistName: string) => {
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artistId);
|
||||
if (similar.length > 0) {
|
||||
const top = await getTopSongs(artistName);
|
||||
const radioTracks = [...top, ...similar].map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
playTrack(radioTracks[0], radioTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAlbum = async (albumName: string, albumId: string) => {
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${albumName}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${albumName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download fehlgeschlagen:', e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu animate-fade-in"
|
||||
style={{ left: coords.x, top: coords.y }}
|
||||
>
|
||||
{(type === 'song' || type === 'album-song') && (() => {
|
||||
const song = item as Track;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||
<Play size={14} /> Direkt abspielen
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
if (!currentTrack) {
|
||||
playTrack(song, [song]);
|
||||
return;
|
||||
}
|
||||
const currentIdx = usePlayerStore.getState().queueIndex;
|
||||
const newQueue = [...queue];
|
||||
newQueue.splice(currentIdx + 1, 0, song);
|
||||
usePlayerStore.setState({ queue: newQueue });
|
||||
})}>
|
||||
<ChevronRight size={14} /> Als Nächstes abspielen
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> Zur Warteschlange hinzufügen
|
||||
</div>
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const tracks = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> Ganzes Album einreihen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> Song-Radio starten
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
|
||||
<Star size={14} /> Favorisieren
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'album' && (() => {
|
||||
const album = item as SubsonicAlbum;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
// we don't have tracks here immediately, so we'd navigate or fetch. For now, navigate.
|
||||
navigate(`/album/${album.id}`);
|
||||
})}>
|
||||
<Play size={14} /> Album öffnen
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> Zum Künstler
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
|
||||
<Star size={14} /> Album favorisieren
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> Herunterladen (ZIP)
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'artist' && (() => {
|
||||
const artist = item as SubsonicArtist;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||
<Radio size={14} /> Künstler-Radio starten
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
|
||||
<Star size={14} /> Künstler favorisieren
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'queue-item' && (() => {
|
||||
const song = item as Track;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
|
||||
<Play size={14} /> Direkt abspielen
|
||||
</div>
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
if (queueIndex !== undefined) removeTrack(queueIndex);
|
||||
})}>
|
||||
Diesen Song entfernen
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
|
||||
<Radio size={14} /> Song-Radio starten
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl } from '../api/subsonic';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
|
||||
const FsBg = memo(function FsBg({ url }: { url: string }) {
|
||||
// Each layer: {url, id, visible}
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const id = counterRef.current++;
|
||||
// Add the new layer (opacity 0)
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
// One frame later: make new layer visible (0→1) and old ones invisible (1→0)
|
||||
const t1 = setTimeout(() => {
|
||||
setLayers(prev =>
|
||||
prev.map(l => ({ ...l, visible: l.id === id }))
|
||||
);
|
||||
}, 20);
|
||||
// After transition: clean up old layers
|
||||
const t2 = setTimeout(() => {
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 800);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="fs-bg"
|
||||
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Isolated progress sub-component (re-renders every tick, nothing else does) ───
|
||||
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)), [seek]);
|
||||
|
||||
return (
|
||||
<div className="fs-bottom">
|
||||
<div className="fs-progress-wrap">
|
||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||
<div className="fs-progress-bar">
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
||||
aria-label="Songfortschritt"
|
||||
/>
|
||||
</div>
|
||||
<span className="fs-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
return (
|
||||
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}>
|
||||
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label="Vollbild-Player">
|
||||
{/* Crossfading blurred background */}
|
||||
<FsBg url={coverUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Close button */}
|
||||
<button className="fs-close" onClick={onClose} aria-label="Vollbild schließen" data-tooltip="Schließen (Esc)">
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Main layout: cover left, upcoming right */}
|
||||
<div className="fs-layout">
|
||||
|
||||
{/* Left column: cover only */}
|
||||
<div className="fs-left">
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: upcoming tracks */}
|
||||
{upcoming.length > 0 && (
|
||||
<div className="fs-right">
|
||||
<h2 className="fs-upcoming-title">Nächste Titel</h2>
|
||||
<div className="fs-upcoming-list">
|
||||
{upcoming.map((track, i) => (
|
||||
<button
|
||||
key={`${track.id}-${queueIndex + 1 + i}`}
|
||||
className="fs-upcoming-item"
|
||||
onClick={() => playTrack(track, queue)}
|
||||
>
|
||||
{track.coverArt ? (
|
||||
<img src={buildCoverArtUrl(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
|
||||
) : (
|
||||
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
|
||||
)}
|
||||
<div className="fs-upcoming-info">
|
||||
<span className="fs-upcoming-name">{track.title}</span>
|
||||
<span className="fs-upcoming-artist">{track.artist}</span>
|
||||
</div>
|
||||
<span className="fs-upcoming-dur">{formatTime(track.duration)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: meta + progress + controls — centered across full width */}
|
||||
<div className="fs-footer">
|
||||
<div className="fs-footer-main">
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — isolated sub-component */}
|
||||
<FsProgress duration={duration} />
|
||||
</div>
|
||||
|
||||
{/* Transport controls */}
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
|
||||
<Square size={20} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label="Vorheriger Titel">
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label="Nächster Titel">
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
data-tooltip={`Wiederholen: ${repeatMode === 'off' ? 'Aus' : repeatMode === 'all' ? 'Alle' : 'Einen'}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export default function Hero() {
|
||||
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRandomAlbums(1).then(albums => {
|
||||
if (!cancelled && albums[0]) setAlbum(albums[0]);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hero"
|
||||
role="banner"
|
||||
aria-label="Album des Augenblicks"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{coverUrl && (
|
||||
<div
|
||||
className="hero-bg"
|
||||
style={{ backgroundImage: `url(${coverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="hero-overlay" aria-hidden="true" />
|
||||
<div className="hero-content animate-fade-in">
|
||||
{coverUrl && (
|
||||
<img className="hero-cover" src={coverUrl} alt={`${album.name} Cover`} />
|
||||
)}
|
||||
<div className="hero-text">
|
||||
<span className="hero-eyebrow">Album des Augenblicks</span>
|
||||
<h2 className="hero-title">{album.name}</h2>
|
||||
<p className="hero-artist">{album.artist}</p>
|
||||
<div className="hero-meta">
|
||||
{album.year && <span className="badge">{album.year}</span>}
|
||||
{album.genre && <span className="badge">{album.genre}</span>}
|
||||
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="hero-play-btn"
|
||||
id="hero-play-btn"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
|
||||
aria-label={`Album ${album.name} abspielen`}
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
Album abspielen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const tracks = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (err) { }
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
Einreihen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (q: string) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(q), ms);
|
||||
};
|
||||
}
|
||||
|
||||
export default function LiveSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const doSearch = useCallback(
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setOpen(false); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await search(q);
|
||||
setResults(r);
|
||||
setOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => { doSearch(query); }, [query, doSearch]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
|
||||
|
||||
return (
|
||||
<div className="live-search" ref={ref} role="search">
|
||||
<div className="live-search-input-wrap">
|
||||
{loading ? (
|
||||
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}>
|
||||
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
|
||||
</span>
|
||||
) : (
|
||||
<Search size={16} className="live-search-icon" />
|
||||
)}
|
||||
<input
|
||||
id="live-search-input"
|
||||
className="input live-search-field"
|
||||
type="search"
|
||||
placeholder="Suchen nach Künstler, Album oder Song…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{query && (
|
||||
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label="Suche leeren">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="live-search-dropdown" id="search-results" role="listbox">
|
||||
{!hasResults && !loading && (
|
||||
<div className="search-empty">Keine Ergebnisse für „{query}"</div>
|
||||
)}
|
||||
|
||||
{results?.artists.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Users size={12} /> Künstler</div>
|
||||
{results.artists.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.albums.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Disc3 size={12} /> Alben</div>
|
||||
{results.albums.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="search-result-item"
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
>
|
||||
{a.coverArt ? (
|
||||
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.songs.length ? (
|
||||
<div className="search-section">
|
||||
<div className="search-section-label"><Music size={12} /> Songs</div>
|
||||
{results.songs.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
className="search-result-item"
|
||||
onClick={() => {
|
||||
playTrack({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
|
||||
});
|
||||
setOpen(false); setQuery('');
|
||||
}}
|
||||
role="option"
|
||||
>
|
||||
<div className="search-result-icon"><Music size={14} /></div>
|
||||
<div>
|
||||
<div className="search-result-name">{s.title}</div>
|
||||
<div className="search-result-sub">{s.artist} · {s.album}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
|
||||
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
|
||||
|
||||
export default function NowPlayingDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNowPlaying = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getNowPlaying();
|
||||
setNowPlaying(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Now Playing', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch when the dropdown is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-tooltip="Wer hört was?"
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||
>
|
||||
<Radio size={18} className={nowPlaying.length > 0 ? 'animate-pulse' : ''} style={{ color: nowPlaying.length > 0 ? 'var(--accent)' : 'inherit' }} />
|
||||
<span>Live</span>
|
||||
{nowPlaying.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
{nowPlaying.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="glass animate-fade-in"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 10px)',
|
||||
right: 0,
|
||||
width: '340px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '12px',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '1rem',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Wer hört was?</h3>
|
||||
<button
|
||||
onClick={fetchNowPlaying}
|
||||
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
|
||||
style={{ width: '28px', height: '28px', padding: 0 }}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && nowPlaying.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
|
||||
Lädt...
|
||||
</div>
|
||||
) : nowPlaying.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
|
||||
Gerade hört niemand Musik.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{nowPlaying.map((stream, idx) => (
|
||||
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<PlayCircle size={24} style={{ margin: '12px', color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
|
||||
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
<User size={10} />
|
||||
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
|
||||
{stream.minutesAgo > 0 && <span>• vor {stream.minutesAgo}m</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seek(parseFloat(e.target.value));
|
||||
}, [seek]);
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const progressStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${progress * 100}%, var(--ctp-surface2) ${progress * 100}%)`,
|
||||
};
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
|
||||
onClick={() => currentTrack && toggleFullscreen()}
|
||||
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
{currentTrack?.coverArt ? (
|
||||
<img
|
||||
className="player-album-art"
|
||||
src={buildCoverArtUrl(currentTrack.coverArt, 128)}
|
||||
alt={`${currentTrack.album} Cover`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Music size={22} />
|
||||
</div>
|
||||
)}
|
||||
{currentTrack && (
|
||||
<div className="player-art-expand-hint" aria-hidden="true">
|
||||
<Maximize2 size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<div className="player-track-name" data-tooltip={currentTrack?.title ?? ''}>
|
||||
{currentTrack?.title ?? t('player.noTitle')}
|
||||
</div>
|
||||
<div className="player-track-artist" data-tooltip={currentTrack?.artist ?? ''}>
|
||||
{currentTrack?.artist ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls + Progress */}
|
||||
<div className="player-controls">
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={16} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : 'inherit' }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-progress">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-progress-bar">
|
||||
<input
|
||||
type="range"
|
||||
id="player-seek"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={progressStyle}
|
||||
aria-label={t('player.progress')}
|
||||
/>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume + Connection */}
|
||||
<div className="player-right">
|
||||
<div className="volume-control" aria-label={t('player.volume')}>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleQueue}
|
||||
aria-label={t('player.toggleQueue')}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderStars(rating?: number) {
|
||||
if (!rating) return null;
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<Star
|
||||
key={i}
|
||||
size={12}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>{stars}</div>;
|
||||
}
|
||||
|
||||
function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="live-search-field"
|
||||
placeholder={t('queue.playlistName')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
|
||||
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists().then(data => {
|
||||
setPlaylists(data);
|
||||
setLoading(false);
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('queue.deleteConfirm', { name }))) {
|
||||
await deletePlaylist(id);
|
||||
fetchPlaylists();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
|
||||
) : playlists.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{playlists.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
if (queue.length === 0) return;
|
||||
setSaveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearQueue();
|
||||
};
|
||||
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'queue_reorder',
|
||||
index
|
||||
}));
|
||||
};
|
||||
|
||||
const onDragOverItem = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIdx(index);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
|
||||
e.preventDefault();
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
|
||||
try {
|
||||
const dataStr = e.dataTransfer.getData('application/json');
|
||||
if (!dataStr) return;
|
||||
const data = JSON.parse(dataStr);
|
||||
|
||||
if (data.type === 'queue_reorder') {
|
||||
const fromIdx = data.index;
|
||||
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
|
||||
if (fromIdx !== undefined && fromIdx !== targetIdx) {
|
||||
reorderQueue(fromIdx, targetIdx);
|
||||
}
|
||||
} else if (data.type === 'song') {
|
||||
const track = data.track;
|
||||
if (dropIndex !== undefined) {
|
||||
// If dropped on a specific item, we might want to insert it there.
|
||||
// For now we just enqueue it at the end to keep it simple, or insert it.
|
||||
// Since we don't have an insert method, we use enqueue (appends to end).
|
||||
enqueue([track]);
|
||||
} else {
|
||||
enqueue([track]);
|
||||
}
|
||||
} else if (data.type === 'album') {
|
||||
const albumData = await getAlbum(data.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Drop error', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="queue-panel"
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => onDropQueue(e)}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button onClick={handleLoad} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.loadPlaylist')} data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button onClick={handleClear} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.clear')} data-tooltip={t('queue.clear')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '14px', background: 'var(--border)', margin: '0 4px' }} />
|
||||
<button onClick={toggleQueue} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.close')} data-tooltip={t('queue.hide')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 400)} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
|
||||
<div className="queue-current-sub truncate" data-tooltip={currentTrack.album}>{currentTrack.album}</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
`${currentTrack.bitRate} kbps · ${currentTrack.suffix.toUpperCase()}`
|
||||
) : (
|
||||
currentTrack.suffix?.toUpperCase() ?? ''
|
||||
)}
|
||||
</div>
|
||||
{renderStars(currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<div className="queue-list">
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">
|
||||
{t('queue.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isDragging = draggedIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
|
||||
// Highlight above or below depending on index direction
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isDragging) {
|
||||
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
} else if (isDragOver && draggedIdx !== null) {
|
||||
if (draggedIdx > idx) {
|
||||
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
|
||||
} else {
|
||||
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
className={`queue-item ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, idx)}
|
||||
onDragOver={(e) => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
onDropQueue(e, idx);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
||||
<span className="truncate">{track.title}</span>
|
||||
</div>
|
||||
<div className="queue-item-artist truncate">{track.artist}</div>
|
||||
</div>
|
||||
<div className="queue-item-duration">
|
||||
{formatTime(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveModalOpen && (
|
||||
<SavePlaylistModal
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
await createPlaylist(name, queue.map(t => t.id));
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to save playlist', e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks: Track[] = data.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(tracks[0], tracks);
|
||||
}
|
||||
setLoadModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to load playlist', e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
);
|
||||
|
||||
const navItems = [
|
||||
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
|
||||
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
|
||||
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
|
||||
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
|
||||
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
|
||||
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
toggleCollapse?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={toggleCollapse}
|
||||
title={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
|
||||
</button>
|
||||
{!isCollapsed && <PsysonicLogo />}
|
||||
{!isCollapsed && <span className="brand-name">Psysonic</span>}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Hauptnavigation">
|
||||
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t(item.labelKey) : undefined}
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
style={isCollapsed ? { marginTop: 'auto' } : undefined}
|
||||
title={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
title={isCollapsed ? t('sidebar.settings') : undefined}
|
||||
>
|
||||
<Settings size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
|
||||
</NavLink>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
// English Translations
|
||||
const enTranslation = {
|
||||
sidebar: {
|
||||
library: 'Library',
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'New Releases',
|
||||
allAlbums: 'All Albums',
|
||||
artists: 'Artists',
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Random Mix',
|
||||
favorites: 'Favorites',
|
||||
system: 'System',
|
||||
statistics: 'Statistics',
|
||||
settings: 'Settings',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
language: 'Language',
|
||||
languageEn: 'English',
|
||||
languageDe: 'German',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
connection: 'Connection',
|
||||
lanIp: 'LAN IP',
|
||||
externalUrl: 'External URL',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
activeConn: 'Active Connection',
|
||||
activeServer: 'Currently used server:',
|
||||
connLocal: 'Local (LAN)',
|
||||
connExternal: 'External (Internet)',
|
||||
lfmTitle: 'Last.fm Scrobbling',
|
||||
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the <strong>Navidrome Webplayer</strong> in your browser, go to your profile, and connect your Last.fm account.',
|
||||
lfmDesc2: 'Once that is done, Psysonic automatically forwards your currently playing songs to Navidrome, and they will appear on Last.fm.',
|
||||
scrobbleEnabled: 'Scrobbling enabled',
|
||||
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
||||
behavior: 'App Behavior',
|
||||
trayTitle: 'Minimize to Tray',
|
||||
trayDesc: 'Minimize app to the system tray on close (X)',
|
||||
cacheTitle: 'Max. Cache Size',
|
||||
cacheDesc: 'For preloaded tracks',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
logout: 'Logout'
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
savePlaylist: 'Save Playlist',
|
||||
playlistName: 'Playlist Name',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
loadPlaylist: 'Load Playlist',
|
||||
loading: 'Loading...',
|
||||
noPlaylists: 'No playlists found.',
|
||||
load: 'Load',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||
clear: 'Clear',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
emptyQueue: 'The queue is empty.'
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
openFullscreen: 'Open Fullscreen Player',
|
||||
noTitle: 'No Title',
|
||||
stop: 'Stop',
|
||||
prev: 'Previous Track',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
next: 'Next Track',
|
||||
repeat: 'Repeat',
|
||||
repeatOff: 'Off',
|
||||
repeatAll: 'All',
|
||||
repeatOne: 'One',
|
||||
progress: 'Song Progress',
|
||||
volume: 'Volume',
|
||||
toggleQueue: 'Toggle Queue'
|
||||
}
|
||||
};
|
||||
|
||||
// German Translations
|
||||
const deTranslation = {
|
||||
sidebar: {
|
||||
library: 'Bibliothek',
|
||||
mainstage: 'Mainstage',
|
||||
newReleases: 'Neueste',
|
||||
allAlbums: 'Alle Alben',
|
||||
artists: 'Künstler',
|
||||
playlists: 'Playlists',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
system: 'System',
|
||||
statistics: 'Statistiken',
|
||||
settings: 'Einstellungen',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden'
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
language: 'Sprache',
|
||||
languageEn: 'Englisch',
|
||||
languageDe: 'Deutsch',
|
||||
theme: 'Design',
|
||||
appearance: 'Darstellung',
|
||||
connection: 'Verbindung',
|
||||
lanIp: 'LAN-IP',
|
||||
externalUrl: 'Externe URL',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
activeConn: 'Aktive Verbindung',
|
||||
activeServer: 'Aktuell verwendeter Server:',
|
||||
connLocal: 'Lokal (LAN)',
|
||||
connExternal: 'Extern (Internet)',
|
||||
lfmTitle: 'Last.fm Scrobbling',
|
||||
lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den <strong>Navidrome Webplayer</strong> im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
|
||||
lfmDesc2: 'Sobald das erledigt ist, leitet Psysonic deine aktuell gespielten Songs automatisch an Navidrome weiter, und diese erscheinen auf Last.fm.',
|
||||
scrobbleEnabled: 'Scrobbling aktiviert',
|
||||
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
||||
behavior: 'App-Verhalten',
|
||||
trayTitle: 'In Tray minimieren',
|
||||
trayDesc: 'App beim Schließen (X) in den System-Tray minimieren',
|
||||
cacheTitle: 'Max. Cache-Größe',
|
||||
cacheDesc: 'Für vorgeladene Tracks',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
logout: 'Abmelden'
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
savePlaylist: 'Playlist speichern',
|
||||
playlistName: 'Name der Playlist',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
loadPlaylist: 'Playlist laden',
|
||||
loading: 'Lade...',
|
||||
noPlaylists: 'Keine Playlists gefunden.',
|
||||
load: 'Laden',
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Playlist "{{name}}" löschen?',
|
||||
clear: 'Leeren',
|
||||
hide: 'Verbergen',
|
||||
close: 'Schließen',
|
||||
nextTracks: 'Nächste Titel',
|
||||
emptyQueue: 'Die Warteschlange ist leer.'
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
openFullscreen: 'Vollbild-Player öffnen',
|
||||
noTitle: 'Kein Titel',
|
||||
stop: 'Stop',
|
||||
prev: 'Vorheriger Titel',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
next: 'Nächster Titel',
|
||||
repeat: 'Wiederholen',
|
||||
repeatOff: 'Aus',
|
||||
repeatAll: 'Alle',
|
||||
repeatOne: 'Einen',
|
||||
progress: 'Songfortschritt',
|
||||
volume: 'Lautstärke',
|
||||
toggleQueue: 'Warteschlange umschalten'
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve language from local storage or default to 'en'
|
||||
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: enTranslation },
|
||||
de: { translation: deTranslation }
|
||||
},
|
||||
lng: savedLanguage,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false // react already safes from xss
|
||||
}
|
||||
});
|
||||
|
||||
// Setup listener to persist language changes
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
localStorage.setItem('psysonic_language', lng);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './styles/theme.css';
|
||||
import './styles/layout.css';
|
||||
import './styles/components.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,426 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus } from 'lucide-react';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
if (song.samplingRate) parts.push(`${(song.samplingRate / 1000).toFixed(1)} kHz`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const [hover, setHover] = useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label="Bewertung">
|
||||
{[1,2,3,4,5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n} Stern${n !== 1 ? 'e' : ''}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BioModalProps { bio: string; onClose: () => void; }
|
||||
function BioModal({ bio, onClose }: BioModalProps) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="Künstler-Biografie">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Schließen"><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>Künstler-Biografie</h3>
|
||||
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: bio }} data-selectable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthStore();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
|
||||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [bio, setBio] = useState<string | null>(null);
|
||||
const [bioOpen, setBioOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setRelatedAlbums([]);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
setIsStarred(!!data.album.starred);
|
||||
|
||||
const initialStarred = new Set<string>();
|
||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
|
||||
setLoading(false);
|
||||
// Fetch related albums by the same artist
|
||||
try {
|
||||
const artistData = await getArtist(data.album.artistId);
|
||||
// Filter out the current album from the related list
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
}
|
||||
}).catch(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
const tracks = album.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
|
||||
track: song.track, year: song.year, bitRate: song.bitRate,
|
||||
suffix: song.suffix, userRating: song.userRating
|
||||
};
|
||||
playTrack(track, [track]);
|
||||
};
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
await setRating(songId, rating);
|
||||
};
|
||||
|
||||
const handleBio = async () => {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
const info = await getArtistInfo(album.album.artistId);
|
||||
setBio(info.biography ?? 'Keine Biografie verfügbar.');
|
||||
setBioOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (albumName: string, albumId: string) => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (auth.downloadFolder) {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(auth.downloadFolder, `${albumName}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
console.log(`Saved to ${path}`);
|
||||
} else {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `${albumName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download fehlgeschlagen:', e);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!album) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred); // Optimistic UI update
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(album.album.id);
|
||||
} else {
|
||||
await star(album.album.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred); // Revert on failure
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // prevent play on double click trigger
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
|
||||
// Optimistic UI
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(song.id, 'song');
|
||||
} else {
|
||||
await star(song.id, 'song');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
// Revert
|
||||
const revert = new Set(starredSongs);
|
||||
setStarredSongs(revert);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">Album nicht gefunden.</div>;
|
||||
|
||||
const { album: info, songs } = album;
|
||||
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
return (
|
||||
<div className="album-detail animate-fade-in">
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{coverUrl && (
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{ backgroundImage: `url(${coverUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-overlay" aria-hidden="true" />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> Zurück
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<img className="album-detail-cover" src={coverUrl} alt={`${info.name} Cover`} />
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge" style={{ marginBottom: '0.5rem' }}>Album</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Zu ${info.artist} wechseln`}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span style={{ margin: '0 4px' }}>·</span>
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={`Weitere Alben von ${info.recordLabel} anzeigen`}
|
||||
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
||||
>
|
||||
{info.recordLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
|
||||
<Play size={16} fill="currentColor" /> Alle abspielen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
|
||||
>
|
||||
<ListPlus size={16} /> Einreihen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
id="album-star-btn"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
|
||||
{isStarred ? 'Favorit' : 'Als Favorit'}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
|
||||
<ExternalLink size={16} /> Künstler-Bio
|
||||
</button>
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
|
||||
<Download size={16} /> {downloading ? 'Lade…' : 'Download (ZIP)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header">
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>Titel</div>
|
||||
<div>Format</div>
|
||||
<div style={{ textAlign: 'center' }}>Favorit</div>
|
||||
<div>Bewertung</div>
|
||||
<div style={{ textAlign: 'right' }}>Dauer</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Group songs by disc number
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
return discNums.map(discNum => (
|
||||
<div key={discNum}>
|
||||
{isMultiDisc && (
|
||||
<div className="disc-header">
|
||||
<span className="disc-icon">💿</span>
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song, i) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
onDoubleClick={() => handlePlaySong(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
{song.artist !== info.artist && (
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{(song.suffix || song.bitRate) && (
|
||||
<span className="track-codec" style={{ marginTop: 0 }}>
|
||||
{codecLabel(song)}
|
||||
{song.size ? <span className="track-size"> · {formatSize(song.size)}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: '1rem' }}>Mehr von {info.artist}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
|
||||
|
||||
export default function Albums() {
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [sort, setSort] = useState<SortType>('alphabeticalByName');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList(sortType, PAGE_SIZE, offset);
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setPage(0); load(sort, 0); }, [sort, load]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(sort, next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, sort, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: 'A–Z (Album)' },
|
||||
{ value: 'alphabeticalByArtist', label: 'A–Z (Künstler)' },
|
||||
{ value: 'newest', label: 'Neueste zuerst' },
|
||||
{ value: 'random', label: 'Zufällig' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title">Alle Alben</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Inline Last.fm SVG icon
|
||||
function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const clearQueue = usePlayerStore(state => state.clearQueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => [])
|
||||
]);
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const openLink = (url: string) => open(url);
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred); // Optimistic UI update
|
||||
try {
|
||||
if (currentlyStarred) {
|
||||
await unstar(artist.id, 'artist');
|
||||
} else {
|
||||
await star(artist.id, 'artist');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred); // Revert on failure
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (topSongs.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(topSongs[0], topSongs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
if (topSongs.length > 0) {
|
||||
const shuffled = [...topSongs].sort(() => Math.random() - 0.5);
|
||||
clearQueue();
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRadio = async () => {
|
||||
if (!artist) return;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
const similar = await getSimilarSongs2(artist.id, 50);
|
||||
if (similar.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(similar[0], similar);
|
||||
} else {
|
||||
alert("Keine ähnlichen Titel für diesen Künstler gefunden.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Radio start failed", e);
|
||||
} finally {
|
||||
setRadioLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artist) {
|
||||
return (
|
||||
<div className="content-body">
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
||||
Künstler nicht gefunden.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>Zurück</span>
|
||||
</button>
|
||||
|
||||
{/* Header: avatar + name + meta + links */}
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
||||
{artist.name}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{/* External links */}
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
|
||||
<LastfmIcon size={14} />
|
||||
Last.fm
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
|
||||
<ExternalLink size={14} />
|
||||
Wikipedia
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star toggle */}
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{isStarred ? 'Favorit' : 'Als Favorit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{topSongs.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll}>
|
||||
<Play size={16} /> Alle abspielen
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={handleShuffle}>
|
||||
<Shuffle size={16} /> Zufallswiedergabe
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={handleStartRadio} disabled={radioLoading}>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{radioLoading ? 'Lädt...' : 'Radio'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Biography — only shown when available */}
|
||||
{info?.biography && (
|
||||
<div className="artist-bio-section">
|
||||
<div
|
||||
className="artist-bio-text"
|
||||
dangerouslySetInnerHTML={{ __html: info.biography }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Songs */}
|
||||
{topSongs.length > 0 && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
Beliebteste Titel
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>Titel</div>
|
||||
<div>Album</div>
|
||||
<div style={{ textAlign: 'right' }}>Dauer</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
onDoubleClick={() => playTrack(song, topSongs)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{song.coverArt && (
|
||||
<img
|
||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
||||
alt={song.album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="track-title">{song.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
||||
{song.album}
|
||||
</div>
|
||||
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
Alben von {artist.name}
|
||||
</h2>
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Keine Alben gefunden.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { Users, LayoutGrid, List } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const ALPHABET = ['Alle', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
|
||||
export default function Artists() {
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState('Alle');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleCount(prev => prev + 50);
|
||||
}, []);
|
||||
|
||||
// Reset infinite scroll when filters change
|
||||
useEffect(() => {
|
||||
setVisibleCount(50);
|
||||
}, [filter, letterFilter, viewMode]);
|
||||
|
||||
// Filter pipeline
|
||||
let filtered = artists;
|
||||
|
||||
if (letterFilter !== 'Alle') {
|
||||
filtered = filtered.filter(a => {
|
||||
const first = a.name[0]?.toUpperCase() ?? '#';
|
||||
const isAlpha = /^[A-Z]$/.test(first);
|
||||
if (letterFilter === '#') return !isAlpha;
|
||||
return first === letterFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
filtered = filtered.filter(a => a.name.toLowerCase().includes(filter.toLowerCase()));
|
||||
}
|
||||
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
// Group by first letter (for list view)
|
||||
const groups: Record<string, SubsonicArtist[]> = {};
|
||||
visible.forEach(a => {
|
||||
const letter = a.name[0]?.toUpperCase() ?? '#';
|
||||
const key = /^[A-Z]$/.test(letter) ? letter : '#';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(a);
|
||||
});
|
||||
const letters = Object.keys(groups).sort();
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>Künstler</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder="Suchen…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="artist-filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip="Grid ansicht"
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip="Listenansicht"
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '2rem' }}>
|
||||
{ALPHABET.map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
style={{
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: letterFilter === l ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: letterFilter === l ? 'var(--ctp-crust)' : 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 200)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={32} />
|
||||
)}
|
||||
<Users size={32} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{artist.albumCount} Alben</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && viewMode === 'list' && (
|
||||
<>
|
||||
{letters.map(letter => (
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<div className="artist-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
{coverId ? (
|
||||
<img
|
||||
src={buildCoverArtUrl(coverId, 100)}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={18} />
|
||||
)}
|
||||
<Users size={18} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{artist.albumCount} Alben</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost" onClick={loadMore}>
|
||||
Mehr laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
Keine Künstler gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
export default function Favorites() {
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack } = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
.then(res => {
|
||||
setAlbums(res.albums);
|
||||
setArtists(res.artists);
|
||||
setSongs(res.songs);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
<div style={{ marginBottom: '-1.5rem' }}>
|
||||
<h1 className="page-title">Favoriten</h1>
|
||||
</div>
|
||||
|
||||
{!hasAnyFavorites ? (
|
||||
<div className="empty-state">Du hast noch keine Favoriten gespeichert.</div>
|
||||
) : (
|
||||
<>
|
||||
{artists.length > 0 && (
|
||||
<ArtistRow title="Künstler" artists={artists} />
|
||||
)}
|
||||
|
||||
{albums.length > 0 && (
|
||||
<AlbumRow title="Alben" albums={albums} />
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>Songs</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
{/* Wir können für die Favoriten-Seite ruhig alle Songs anzeigen, statt nur 10 wie auf der Startseite */}
|
||||
{songs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 60px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info">
|
||||
<span className="track-title" title={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Hero from '../components/Hero';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
|
||||
export default function Home() {
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 12).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
]).then(([s, n, r, f]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setRandom(r);
|
||||
setMostPlayed(f);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent',
|
||||
currentList: SubsonicAlbum[],
|
||||
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
// Ensure we don't append duplicates if the API returns them
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) {
|
||||
setter(prev => [...prev, ...newItems]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Hero />
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{starred.length > 0 && (
|
||||
<AlbumRow
|
||||
title="Persönliche Favoriten"
|
||||
albums={starred}
|
||||
onLoadMore={() => loadMore('starred', starred, setStarred)}
|
||||
moreText="Mehr laden"
|
||||
/>
|
||||
)}
|
||||
<AlbumRow
|
||||
title="Zuletzt hinzugefügt"
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText="Mehr laden"
|
||||
/>
|
||||
<AlbumRow
|
||||
title="Meistgehört"
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText="Mehr laden"
|
||||
/>
|
||||
<AlbumRow
|
||||
title="Entdecken"
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText="Mehr entdecken"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { search, SubsonicAlbum } from '../api/subsonic';
|
||||
|
||||
export default function LabelAlbums() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
|
||||
// Search for the label name and ask for a large number of albums
|
||||
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
|
||||
.then(res => {
|
||||
// Filter out albums that don't match the record label exactly if possible,
|
||||
// to avoid unrelated search hits. We do case-insensitive comparison.
|
||||
const matches = res.albums.filter(a =>
|
||||
a.recordLabel?.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
|
||||
// (or it's not indexed exactly as typed), just show all album matches
|
||||
// as a decent best-effort if our strict filter yields nothing.
|
||||
setAlbums(matches.length > 0 ? matches : res.albums);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ margin: '1rem 0', gap: '6px' }}>
|
||||
<ChevronLeft size={16} /> Zurück
|
||||
</button>
|
||||
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>
|
||||
Label: <span style={{ color: 'var(--accent)' }}>{name}</span>
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : albums.length === 0 ? (
|
||||
<div className="empty-state">Keine Alben für dieses Label gefunden.</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => (
|
||||
<AlbumCard key={a.id} album={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ping } from '../api/subsonic';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="18" fill="url(#grad-login)" />
|
||||
<text x="8" y="47" fontFamily="Inter, sans-serif" fontWeight="800" fontSize="42" fill="white">P</text>
|
||||
<line x1="40" y1="18" x2="58" y2="18" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
|
||||
<line x1="37" y1="26" x2="58" y2="26" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.75"/>
|
||||
<line x1="40" y1="34" x2="58" y2="34" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
|
||||
<line x1="37" y1="42" x2="58" y2="42" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.6"/>
|
||||
<line x1="42" y1="50" x2="58" y2="50" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.4"/>
|
||||
<defs>
|
||||
<linearGradient id="grad-login" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#cba6f7"/>
|
||||
<stop offset="1" stopColor="#89b4fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { setCredentials, setLoggedIn, setConnecting, setConnectionError, connectionError } = useAuthStore();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
serverName: '',
|
||||
lanIp: '',
|
||||
externalUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleConnect = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.lanIp && !form.externalUrl) {
|
||||
setTestMessage('Bitte LAN-IP oder externe URL eingeben.');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('testing');
|
||||
setTestMessage('Verbinde…');
|
||||
setConnecting(true);
|
||||
setCredentials(form);
|
||||
setConnectionError(null);
|
||||
|
||||
// Small delay to let store update
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const ok = await ping();
|
||||
setConnecting(false);
|
||||
|
||||
if (ok) {
|
||||
setLoggedIn(true);
|
||||
setStatus('ok');
|
||||
setTestMessage('Verbunden!');
|
||||
setTimeout(() => navigate('/'), 600);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setConnectionError('Verbindung fehlgeschlagen – bitte Daten prüfen.');
|
||||
setTestMessage('Verbindung fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-bg" aria-hidden="true" />
|
||||
<div className="login-card animate-fade-in">
|
||||
<div className="login-logo">
|
||||
<PsysonicLogo />
|
||||
</div>
|
||||
<h1 className="login-title">Psysonic</h1>
|
||||
<p className="login-subtitle">Dein Navidrome Desktop Player</p>
|
||||
|
||||
<form className="login-form" onSubmit={handleConnect} noValidate>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-server-name">Server-Name (optional)</label>
|
||||
<input
|
||||
id="login-server-name"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Mein Navidrome"
|
||||
value={form.serverName}
|
||||
onChange={update('serverName')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-lan-ip">LAN-IP / URL</label>
|
||||
<input
|
||||
id="login-lan-ip"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="192.168.1.100:4533"
|
||||
value={form.lanIp}
|
||||
onChange={update('lanIp')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-external-url">Externe URL (FQDN)</label>
|
||||
<input
|
||||
id="login-external-url"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="music.example.com"
|
||||
value={form.externalUrl}
|
||||
onChange={update('externalUrl')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-username">Benutzername</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
value={form.username}
|
||||
onChange={update('username')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">Passwort</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="login-password"
|
||||
className="input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
autoComplete="current-password"
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
aria-label={showPass ? 'Passwort verstecken' : 'Passwort anzeigen'}
|
||||
>
|
||||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testMessage && (
|
||||
<div className={`login-status login-status--${status}`} role="alert">
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />}
|
||||
{status === 'ok' && <Wifi size={16} />}
|
||||
{status === 'error' && <WifiOff size={16} />}
|
||||
<span>{testMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', fontSize: '15px' }}
|
||||
id="login-connect-btn"
|
||||
disabled={status === 'testing'}
|
||||
>
|
||||
{status === 'testing' ? 'Verbinde…' : 'Verbinden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
|
||||
export default function NewReleases() {
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async (offset: number, append = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList('newest', PAGE_SIZE, offset);
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setPage(0); load(0); }, [load]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ListMusic, Play, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function Playlists() {
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
const fetchPlaylists = () => {
|
||||
setLoading(true);
|
||||
getPlaylists()
|
||||
.then(data => {
|
||||
setPlaylists(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load playlists', err);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists();
|
||||
}, []);
|
||||
|
||||
const handlePlay = async (id: string) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks = data.songs.map((s: any) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks.length > 0) {
|
||||
clearQueue();
|
||||
playTrack(tracks[0], tracks);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play playlist', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Playlist "${name}" wirklich löschen?`)) {
|
||||
try {
|
||||
await deletePlaylist(id);
|
||||
fetchPlaylists();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete playlist', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Playlists</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Lade Playlists...</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
Keine Playlists gefunden.<br/>
|
||||
Nutze die Warteschlange, um Playlists zu erstellen.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
||||
{playlists.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--surface0)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
border: '1px solid var(--surface1)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
className="hover-card"
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '0 0 0.25rem 0', color: 'var(--text)' }} className="truncate">
|
||||
{p.name}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
|
||||
{p.songCount} {p.songCount === 1 ? 'Track' : 'Tracks'} • {Math.floor(p.duration / 60)} Min.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePlay(p.id)}
|
||||
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> Abspielen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
data-tooltip="Löschen"
|
||||
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { Play, HandMetal, RefreshCw } from 'lucide-react';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RandomMix() {
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
getRandomSongs(50)
|
||||
.then(fetched => {
|
||||
setSongs(fetched);
|
||||
const st = new Set<string>();
|
||||
fetched.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
setStarredSongs(st);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSongs();
|
||||
}, []);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (songs.length > 0) {
|
||||
playTrack(songs[0], songs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnqueueAll = () => {
|
||||
if (songs.length > 0) {
|
||||
enqueue(songs);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
const revert = new Set(starredSongs);
|
||||
setStarredSongs(revert);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h1 className="page-title">Zufallsmix</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip="Neue Songs laden">
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> Neu mixen
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
|
||||
<Play size={18} fill="currentColor" /> Alle abspielen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>Titel</span>
|
||||
<span>Album</span>
|
||||
<span style={{ textAlign: 'center' }}>Favorit</span>
|
||||
<span style={{ textAlign: 'right' }}>Dauer</span>
|
||||
</div>
|
||||
|
||||
{songs.map((song) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
role="row"
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'song',
|
||||
track
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
|
||||
data-tooltip="Abspielen"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<HandMetal size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Server, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { ping } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [lanIp, setLanIp] = useState(auth.lanIp);
|
||||
const [externalUrl, setExternalUrl] = useState(auth.externalUrl);
|
||||
const [connStatus, setConnStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
|
||||
const [lfmApiKey, setLfmApiKey] = useState(auth.lastfmApiKey);
|
||||
const [lfmSecret, setLfmSecret] = useState(auth.lastfmApiSecret);
|
||||
const testConnection = async () => {
|
||||
setConnStatus('testing');
|
||||
auth.setCredentials({ serverName: auth.serverName, lanIp, externalUrl, username: auth.username, password: auth.password });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const ok = await ping();
|
||||
setConnStatus(ok ? 'ok' : 'error');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const pickDownloadFolder = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
auth.setDownloadFolder(selected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('settings.title')}</h1>
|
||||
|
||||
{/* Language */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
<h2>{t('settings.language')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">{t('settings.languageEn')}</option>
|
||||
<option value="de">{t('settings.languageDe')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Theme */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha (Dark)</option>
|
||||
<option value="latte">Catppuccin Latte (Light)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Wifi size={18} />
|
||||
<h2>{t('settings.connection')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-lan">{t('settings.lanIp')}</label>
|
||||
<input id="settings-lan" className="input" value={lanIp} onChange={e => setLanIp(e.target.value)} placeholder="192.168.1.100:4533" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-ext">{t('settings.externalUrl')}</label>
|
||||
<input id="settings-ext" className="input" value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="music.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-primary" onClick={testConnection} id="settings-test-conn-btn" disabled={connStatus === 'testing'}>
|
||||
{connStatus === 'testing' ? t('settings.testingBtn') : t('settings.testBtn')}
|
||||
</button>
|
||||
{connStatus === 'ok' && <span style={{ color: 'var(--positive)', display: 'flex', alignItems: 'center', gap: '4px' }}><CheckCircle2 size={16} /> {t('settings.connected')}</span>}
|
||||
{connStatus === 'error' && <span style={{ color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}><WifiOff size={16} /> {t('settings.failed')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
{/* Active connection toggle */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.activeConn')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.activeServer')} <strong>{auth.activeConnection === 'local' ? t('settings.connLocal') : t('settings.connExternal')}</strong></div>
|
||||
</div>
|
||||
<div className="conn-toggle" role="group" aria-label="Verbindung umschalten">
|
||||
<button
|
||||
className={`conn-toggle-btn ${auth.activeConnection === 'local' ? 'active' : ''}`}
|
||||
onClick={() => auth.toggleConnection()}
|
||||
id="conn-local-btn"
|
||||
aria-pressed={auth.activeConnection === 'local'}
|
||||
>
|
||||
<Server size={13} /> {t('settings.connLocal')}
|
||||
</button>
|
||||
<button
|
||||
className={`conn-toggle-btn ${auth.activeConnection === 'external' ? 'active' : ''}`}
|
||||
onClick={() => auth.toggleConnection()}
|
||||
id="conn-extern-btn"
|
||||
aria-pressed={auth.activeConnection === 'external'}
|
||||
>
|
||||
<Globe size={13} /> {t('settings.connExternal')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }} dangerouslySetInnerHTML={{ __html: t('settings.lfmDesc1') }} />
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label="Scrobbling aktivieren">
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* App Behavior */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label="In Tray minimieren">
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
|
||||
{auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn" style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Logout */}
|
||||
<section className="settings-section">
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
|
||||
<LogOut size={16} /> {t('settings.logout')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { BarChart3, TrendingUp, Star, Music } from 'lucide-react';
|
||||
|
||||
export default function Statistics() {
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getGenres().catch(() => [])
|
||||
]).then(([f, h, g]) => {
|
||||
setFrequent(f);
|
||||
setHighest(h);
|
||||
// Sort genres by album count or song count
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20)); // Top 20 genres
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'frequent' | 'highest',
|
||||
currentList: SubsonicAlbum[],
|
||||
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) {
|
||||
setter(prev => [...prev, ...newItems]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
}
|
||||
};
|
||||
|
||||
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Statistiken</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
|
||||
<AlbumRow
|
||||
title="Meistgespielte Alben"
|
||||
albums={frequent}
|
||||
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
|
||||
moreText="Mehr laden"
|
||||
/>
|
||||
|
||||
<AlbumRow
|
||||
title="Höchstbewertete Alben"
|
||||
albums={highest}
|
||||
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
||||
moreText="Mehr laden"
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<Music size={20} />
|
||||
<h2>Genre-Verteilung (Top 20)</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
|
||||
{genres.map(genre => {
|
||||
const percentage = (genre.songCount / maxGenreCount) * 100;
|
||||
return (
|
||||
<div key={genre.value} style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', color: 'var(--text)' }}>
|
||||
<span>{genre.value}</span>
|
||||
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 1s ease-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
export type ConnectionMode = 'local' | 'external';
|
||||
|
||||
interface AuthState {
|
||||
// Server config
|
||||
serverName: string;
|
||||
lanIp: string;
|
||||
externalUrl: string;
|
||||
username: string;
|
||||
password: string; // stored encrypted via plugin-store
|
||||
activeConnection: ConnectionMode;
|
||||
|
||||
// Last.fm
|
||||
lastfmApiKey: string;
|
||||
lastfmApiSecret: string;
|
||||
lastfmSessionKey: string;
|
||||
lastfmUsername: string;
|
||||
|
||||
// Settings
|
||||
minimizeToTray: boolean;
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
downloadFolder: string;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
|
||||
// Actions
|
||||
setCredentials: (data: {
|
||||
serverName: string;
|
||||
lanIp: string;
|
||||
externalUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => void;
|
||||
setActiveConnection: (mode: ConnectionMode) => void;
|
||||
toggleConnection: () => void;
|
||||
setLoggedIn: (v: boolean) => void;
|
||||
setConnecting: (v: boolean) => void;
|
||||
setConnectionError: (e: string | null) => void;
|
||||
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Derived
|
||||
getBaseUrl: () => string;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
serverName: '',
|
||||
lanIp: '',
|
||||
externalUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
activeConnection: 'local',
|
||||
lastfmApiKey: '',
|
||||
lastfmApiSecret: '',
|
||||
lastfmSessionKey: '',
|
||||
lastfmUsername: '',
|
||||
minimizeToTray: false,
|
||||
scrobblingEnabled: true,
|
||||
maxCacheMb: 500,
|
||||
downloadFolder: '',
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
|
||||
setCredentials: (data) => set({ ...data, connectionError: null }),
|
||||
|
||||
setActiveConnection: (mode) => set({ activeConnection: mode }),
|
||||
|
||||
toggleConnection: () =>
|
||||
set(s => ({ activeConnection: s.activeConnection === 'local' ? 'external' : 'local' })),
|
||||
|
||||
setLoggedIn: (v) => set({ isLoggedIn: v }),
|
||||
setConnecting: (v) => set({ isConnecting: v }),
|
||||
setConnectionError: (e) => set({ connectionError: e }),
|
||||
|
||||
setLastfm: (apiKey, apiSecret, sessionKey, username) =>
|
||||
set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
|
||||
logout: () => set({
|
||||
isLoggedIn: false,
|
||||
username: '',
|
||||
password: '',
|
||||
lastfmSessionKey: '',
|
||||
lastfmUsername: '',
|
||||
}),
|
||||
|
||||
getBaseUrl: () => {
|
||||
const s = get();
|
||||
if (s.activeConnection === 'local') {
|
||||
return s.lanIp.startsWith('http') ? s.lanIp : `http://${s.lanIp}`;
|
||||
}
|
||||
return s.externalUrl.startsWith('http') ? s.externalUrl : `https://${s.externalUrl}`;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'psysonic-auth',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,357 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { Howl } from 'howler';
|
||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
duration: number;
|
||||
coverArt?: string;
|
||||
track?: number;
|
||||
year?: number;
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
progress: number; // 0–1
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
howl: Howl | null;
|
||||
prefetched: Map<string, Howl>;
|
||||
scrobbled: boolean;
|
||||
|
||||
// Actions
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
togglePlay: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
clearQueue: () => void;
|
||||
prefetchUpcoming: (fromIndex: number, queue: Track[]) => void;
|
||||
|
||||
isQueueVisible: boolean;
|
||||
toggleQueue: () => void;
|
||||
|
||||
isFullscreenOpen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
|
||||
repeatMode: 'off' | 'all' | 'one';
|
||||
toggleRepeat: () => void;
|
||||
|
||||
reorderQueue: (startIndex: number, endIndex: number) => void;
|
||||
removeTrack: (index: number) => void;
|
||||
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
// Context Menu Global State
|
||||
contextMenu: {
|
||||
isOpen: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
|
||||
queueIndex?: number; // Only for 'queue-item'
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
}
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function clearProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to debounce or fire queue syncs
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||
if (syncTimeout) clearTimeout(syncTimeout);
|
||||
syncTimeout = setTimeout(() => {
|
||||
// Collect up to 1000 track IDs just in case it's huge
|
||||
const ids = queue.slice(0, 1000).map(t => t.id);
|
||||
// Convert currentTime (seconds) to expected format (milliseconds)
|
||||
const pos = Math.floor(currentTime * 1000);
|
||||
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
||||
console.error('Failed to sync play queue to server', err);
|
||||
});
|
||||
}, 1500); // 1.5s debounce
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
howl: null,
|
||||
prefetched: new Map(),
|
||||
scrobbled: false,
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
repeatMode: 'off',
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
|
||||
openContextMenu: (x, y, item, type, queueIndex) => set({
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex }
|
||||
}),
|
||||
closeContextMenu: () => set(state => ({
|
||||
contextMenu: { ...state.contextMenu, isOpen: false }
|
||||
})),
|
||||
|
||||
toggleQueue: () => set((state) => ({ isQueueVisible: !state.isQueueVisible })),
|
||||
toggleFullscreen: () => set((state) => ({ isFullscreenOpen: !state.isFullscreenOpen })),
|
||||
|
||||
toggleRepeat: () => set((state) => {
|
||||
const modes = ['off', 'all', 'one'] as const;
|
||||
const nextIdx = (modes.indexOf(state.repeatMode) + 1) % modes.length;
|
||||
return { repeatMode: modes[nextIdx] };
|
||||
}),
|
||||
|
||||
stop: () => {
|
||||
get().howl?.stop();
|
||||
get().howl?.seek(0);
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
||||
},
|
||||
|
||||
playTrack: (track, queue) => {
|
||||
const state = get();
|
||||
// Stop current
|
||||
state.howl?.unload();
|
||||
clearProgress();
|
||||
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
const url = buildStreamUrl(track.id);
|
||||
const howl = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
volume: state.volume,
|
||||
onplay: () => {
|
||||
set({ isPlaying: true });
|
||||
// Subsonic / Navidrome Now Playing
|
||||
reportNowPlaying(track.id);
|
||||
|
||||
set({ scrobbled: false });
|
||||
progressInterval = setInterval(() => {
|
||||
const h = get().howl;
|
||||
if (!h) return;
|
||||
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
|
||||
const dur = h.duration() || 1;
|
||||
const prog = cur / dur;
|
||||
set({ currentTime: cur, progress: prog });
|
||||
|
||||
// Scrobble at 50%
|
||||
if (prog >= 0.5 && !get().scrobbled) {
|
||||
set({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) {
|
||||
scrobbleSong(track.id, Date.now());
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Prefetch next 3
|
||||
get().prefetchUpcoming(idx + 1, newQueue);
|
||||
},
|
||||
onend: () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
||||
const { repeatMode, currentTrack, queue } = get();
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
get().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
get().next();
|
||||
}
|
||||
},
|
||||
onstop: () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
});
|
||||
|
||||
howl.play();
|
||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
get().howl?.pause();
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
howl.play();
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
|
||||
togglePlay: () => {
|
||||
const { isPlaying } = get();
|
||||
isPlaying ? get().pause() : get().resume();
|
||||
},
|
||||
|
||||
next: () => {
|
||||
const { queue, queueIndex, repeatMode } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
}
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { howl, queue, queueIndex, currentTime } = get();
|
||||
if (currentTime > 3) {
|
||||
howl?.seek(0);
|
||||
set({ progress: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const prevIdx = queueIndex - 1;
|
||||
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
|
||||
},
|
||||
|
||||
seek: (progress) => {
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
const time = progress * (howl.duration() || currentTrack.duration);
|
||||
howl.seek(time);
|
||||
set({ progress, currentTime: time });
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
get().howl?.volume(clamped);
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
setProgress: (t, duration) => {
|
||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||
},
|
||||
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
|
||||
clearQueue: () => {
|
||||
get().howl?.unload();
|
||||
clearProgress();
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, currentTime: 0, howl: null });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
// Internal: prefetch next N tracks
|
||||
prefetchUpcoming: (fromIndex: number, queue: Track[]) => {
|
||||
const { prefetched } = get();
|
||||
const toFetch = queue.slice(fromIndex, fromIndex + 3);
|
||||
toFetch.forEach(track => {
|
||||
if (!prefetched.has(track.id)) {
|
||||
const url = buildStreamUrl(track.id);
|
||||
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
|
||||
prefetched.set(track.id, h);
|
||||
}
|
||||
});
|
||||
set({ prefetched: new Map(prefetched) });
|
||||
},
|
||||
// Playlist management
|
||||
reorderQueue: (startIndex: number, endIndex: number) => {
|
||||
const { queue, queueIndex, currentTrack } = get();
|
||||
const result = Array.from(queue);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
|
||||
// Update queueIndex if the currently playing track moved
|
||||
let newIndex = queueIndex;
|
||||
if (currentTrack) {
|
||||
newIndex = result.findIndex(t => t.id === currentTrack.id);
|
||||
}
|
||||
set({ queue: result, queueIndex: Math.max(0, newIndex) });
|
||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
removeTrack: (index: number) => {
|
||||
const { queue, queueIndex } = get();
|
||||
const newQueue = [...queue];
|
||||
newQueue.splice(index, 1);
|
||||
// If we removed the currently playing track, stop playback?
|
||||
// Usually wait until it finishes or user skips. We'll just update state.
|
||||
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
|
||||
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
|
||||
},
|
||||
|
||||
initializeFromServerQueue: async () => {
|
||||
try {
|
||||
const q = await getPlayQueue();
|
||||
if (q.songs.length > 0) {
|
||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
|
||||
if (q.current) {
|
||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||
if (idx >= 0) {
|
||||
currentTrack = mappedTracks[idx];
|
||||
queueIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
queue: mappedTracks,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
// Convert position from ms to s
|
||||
currentTime: q.position ? q.position / 1000 : 0
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize queue from server', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
}), {
|
||||
name: 'psysonic-player',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
volume: state.volume,
|
||||
repeatMode: state.repeatMode,
|
||||
} as Partial<PlayerState>),
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'latte';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'mocha',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
}
|
||||
)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
/* ─── Psysonic App Shell ─── */
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--queue-width);
|
||||
grid-template-rows: 1fr var(--player-height);
|
||||
grid-template-areas:
|
||||
"sidebar main queue"
|
||||
"player player player";
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
/* ─── Resizer Handles ─── */
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: var(--player-height);
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 50;
|
||||
background: transparent;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.resizer:hover, .resizer:active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.resizer-sidebar { left: calc(var(--sidebar-width) - 3px); }
|
||||
.resizer-queue { right: calc(var(--queue-width) - 3px); }
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-5);
|
||||
height: 64px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-brand .logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-4) var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: var(--accent);
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
}
|
||||
|
||||
.nav-link svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link.active svg,
|
||||
.nav-link:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Collapsed Sidebar Styles */
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-link {
|
||||
justify-content: center;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Main Content ─── */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
height: 64px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-app);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content-header .spacer { flex: 1; }
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* ─── Player Bar ─── */
|
||||
.player-bar {
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 280px;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
background: var(--bg-player);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
height: var(--player-height);
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.player-track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-album-art {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.player-album-art-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.player-album-art-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.player-album-art-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
pointer-events: none;
|
||||
}
|
||||
.player-album-art-wrap.clickable { cursor: pointer; }
|
||||
.player-album-art-wrap:hover::after { opacity: 1; }
|
||||
|
||||
/* Expand icon hint that appears on cover hover */
|
||||
.player-art-expand-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.player-album-art-wrap.clickable:hover .player-art-expand-hint { opacity: 1; }
|
||||
|
||||
.player-track-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-track-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.player-track-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.player-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-app);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.player-btn-primary:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.player-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-progress-bar {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-progress-bar input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.volume-control svg { flex-shrink: 0; color: var(--text-muted); }
|
||||
|
||||
.volume-control input[type="range"] { flex: 1; }
|
||||
|
||||
/* Connection Toggle */
|
||||
.conn-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.conn-toggle-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.conn-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─── Queue Panel ─── */
|
||||
.queue-panel {
|
||||
grid-area: queue;
|
||||
background: var(--bg-sidebar);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
height: 64px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--bg-app);
|
||||
border-bottom: 1px solid var(--bg-surface);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-current-track {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-current-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.queue-current-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.queue-current-cover .fallback {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.queue-current-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.queue-current-info h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-current-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.queue-current-tech {
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
padding: var(--space-4) var(--space-5) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
padding: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.queue-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.queue-item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.queue-item-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.queue-item-duration {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.queue-item.active .queue-item-artist,
|
||||
.queue-item.active .queue-item-duration {
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Psysonic – Design System
|
||||
Catppuccin Mocha Color Palette + Inter Typography
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* ─── Catppuccin Mocha Variables ─── */
|
||||
:root, [data-theme='mocha'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23bac2de%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
|
||||
/* Semantic tokens */
|
||||
--bg-app: var(--ctp-base);
|
||||
--bg-sidebar: var(--ctp-mantle);
|
||||
--bg-card: var(--ctp-surface0);
|
||||
--bg-hover: var(--ctp-surface1);
|
||||
--bg-player: var(--ctp-crust);
|
||||
--bg-glass: rgba(30, 30, 46, 0.75);
|
||||
--accent: var(--ctp-mauve);
|
||||
--accent-dim: rgba(203, 166, 247, 0.15);
|
||||
--accent-glow: rgba(203, 166, 247, 0.3);
|
||||
--text-primary: var(--ctp-text);
|
||||
--text-secondary:var(--ctp-subtext1);
|
||||
--text-muted: var(--ctp-overlay1);
|
||||
--border: var(--ctp-surface1);
|
||||
--border-subtle: var(--ctp-surface0);
|
||||
--positive: var(--ctp-green);
|
||||
--warning: var(--ctp-yellow);
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Catppuccin Latte Variables (Light Theme) ─── */
|
||||
[data-theme='latte'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%235c5f77%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
--ctp-rosewater: #dc8a78;
|
||||
--ctp-flamingo: #dd7878;
|
||||
--ctp-pink: #ea76cb;
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-red: #d20f39;
|
||||
--ctp-maroon: #e64553;
|
||||
--ctp-peach: #fe640b;
|
||||
--ctp-yellow: #df8e1d;
|
||||
--ctp-green: #40a02b;
|
||||
--ctp-teal: #179299;
|
||||
--ctp-sky: #04a5e5;
|
||||
--ctp-sapphire: #209fb5;
|
||||
--ctp-blue: #1e66f5;
|
||||
--ctp-lavender: #7287fd;
|
||||
--ctp-text: #4c4f69;
|
||||
--ctp-subtext1: #5c5f77;
|
||||
--ctp-subtext0: #6c6f85;
|
||||
--ctp-overlay2: #7c7f93;
|
||||
--ctp-overlay1: #8c8fa1;
|
||||
--ctp-overlay0: #9ca0b0;
|
||||
--ctp-surface2: #acb0be;
|
||||
--ctp-surface1: #bcc0cc;
|
||||
--ctp-surface0: #ccd0da;
|
||||
--ctp-base: #eff1f5;
|
||||
--ctp-mantle: #e6e9ef;
|
||||
--ctp-crust: #dce0e8;
|
||||
|
||||
/* Semantic tokens */
|
||||
--bg-app: var(--ctp-base);
|
||||
--bg-sidebar: var(--ctp-mantle);
|
||||
--bg-card: var(--ctp-surface0);
|
||||
--bg-hover: var(--ctp-surface1);
|
||||
--bg-player: var(--ctp-crust);
|
||||
--bg-glass: rgba(239, 241, 245, 0.75);
|
||||
--accent: var(--ctp-mauve);
|
||||
--accent-dim: rgba(136, 57, 239, 0.15);
|
||||
--accent-glow: rgba(136, 57, 239, 0.3);
|
||||
--text-primary: var(--ctp-text);
|
||||
--text-secondary:var(--ctp-subtext1);
|
||||
--text-muted: var(--ctp-overlay1);
|
||||
--border: var(--ctp-surface1);
|
||||
--border-subtle: var(--ctp-surface0);
|
||||
--positive: var(--ctp-green);
|
||||
--warning: var(--ctp-yellow);
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Global Base Settings ─── */
|
||||
:root {
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', 'Inter', sans-serif;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.5);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 220px;
|
||||
--player-height: 88px;
|
||||
}
|
||||
|
||||
/* ─── Reset ─── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-app);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection in specific areas */
|
||||
input, textarea, [data-selectable] {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { cursor: pointer; border: none; background: none; font: inherit; color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
ul, ol { list-style: none; }
|
||||
|
||||
/* ─── Scrollbars ─── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--ctp-surface2); border-radius: var(--radius-full); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ctp-overlay0); }
|
||||
|
||||
/* ─── Focus ─── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ─── Global Dragging State ─── */
|
||||
body.is-dragging,
|
||||
body.is-dragging * {
|
||||
user-select: none !important;
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* ─── Utility Classes ─── */
|
||||
.glass {
|
||||
background: var(--bg-glass, rgba(30, 30, 46, 0.75));
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
}
|
||||
|
||||
/* ─── Button Variants ─── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--ctp-lavender); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.btn-surface {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-surface:hover { background: var(--bg-hover); }
|
||||
|
||||
/* ─── Input ─── */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
.input::placeholder { color: var(--text-muted); }
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
select.input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: var(--select-arrow);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 16px;
|
||||
padding-right: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select.input.input:focus {
|
||||
background-image: var(--select-arrow); /* Keep arrow on focus */
|
||||
}
|
||||
|
||||
/* ─── Card ─── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ─── Album Grid ─── */
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* ─── Scrollable area ─── */
|
||||
.scroll-area {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Divider ─── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* ─── Badge ─── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Star Rating ─── */
|
||||
.star-rating {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
.star-rating .star.filled { color: var(--ctp-yellow); }
|
||||
.star-rating .star:hover { color: var(--ctp-yellow); cursor: pointer; }
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-12px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
.animate-fade-in { animation: fadeIn var(--transition-slow) both; }
|
||||
.animate-slide-in { animation: slideIn var(--transition-slow) both; }
|
||||
.animate-pulse { animation: pulse 1.5s ease infinite; }
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ─── Progress / Range ─── */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ctp-surface2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Loading Spinner ─── */
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* ─── Equalizer bars animation ─── */
|
||||
.eq-bar {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.eq-bar:nth-child(1) { animation: wave 0.8s ease-in-out 0.0s infinite; }
|
||||
.eq-bar:nth-child(2) { animation: wave 0.8s ease-in-out 0.15s infinite; }
|
||||
.eq-bar:nth-child(3) { animation: wave 0.8s ease-in-out 0.3s infinite; }
|
||||
.eq-bar:nth-child(4) { animation: wave 0.8s ease-in-out 0.45s infinite; }
|
||||
Reference in New Issue
Block a user