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