Initial release with i18n and theming

This commit is contained in:
Psychotoxical
2026-03-09 19:02:57 +01:00
commit 65459e53f1
110 changed files with 29536 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install npm dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: app-v__VERSION__
releaseName: 'Psysonic v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: false
prerelease: false
args: ${{ matrix.settings.args }}
+28
View File
@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Node
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Tauri
src-tauri/target/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/psysonic.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+2724
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "psysonic",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
"react-router-dom": "^6.26.2",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/howler": "^2.2.12",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+5576
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "psysonic"
version = "0.1.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
repository = ""
default-run = "psysonic"
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "psysonic_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "psysonic"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://schema.tauri.app/config/2/capability.json",
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
"shell:allow-open",
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
"store:allow-load",
"store:allow-set",
"store:allow-get",
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"fs:default",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"core:window:allow-set-title"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","core:window:allow-set-title"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+108
View File
@@ -0,0 +1,108 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
// Build tray menu
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let separator = tauri::menu::PredefinedMenuItem::separator(app)?;
let show = MenuItemBuilder::with_id("show", "Show Psysonic").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&next)
.item(&separator)
.item(&show)
.item(&quit)
.build()?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => {
let _ = app.emit("tray:play-pause", ());
}
"next" => {
let _ = app.emit("tray:next", ());
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|_tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
// Left click shows app (handled in JS side via tray event)
}
})
.build(app)?;
// Register media key global shortcuts
#[cfg(not(target_os = "linux"))]
{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
for shortcut_str in &shortcuts {
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
let shortcut_clone = shortcut_str.to_string();
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match shortcut_clone.as_str() {
"MediaPlayPause" => "media:play-pause",
"MediaNextTrack" => "media:next",
"MediaPreviousTrack" => "media:prev",
_ => return,
};
let _ = app.emit(event_name, ());
}
});
}
}
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
// Emit event so JS can decide: hide to tray or allow close.
// JS handles prevent_close via onCloseRequested() in App.tsx.
let _ = window.emit("window:close-requested", ());
}
})
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
psysonic_lib::run();
}
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "0.1.0",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Psysonic",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true
}
],
"security": {
"csp": null
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": false,
"menuOnLeftClick": false,
"title": "Psysonic",
"tooltip": "Psysonic"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+242
View File
@@ -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>
);
}
+364
View File
@@ -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

+66
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+217
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+358
View File
@@ -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>
);
}
+83
View File
@@ -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
View File
@@ -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;
+13
View File
@@ -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>
);
+426
View File
@@ -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>
);
}
+95
View File
@@ -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: 'AZ (Album)' },
{ value: 'alphabeticalByArtist', label: 'AZ (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>
);
}
+299
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+186
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More