Compare commits

...

11 Commits

Author SHA1 Message Date
Psychotoxical 5528123193 feat: Update Notification system and UI refinements (v1.0.7) 2026-03-13 19:31:44 +01:00
Psychotoxical 85823ff4c4 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:32 +01:00
Psychotoxical e36a81f847 Update README.md 2026-03-12 22:30:41 +01:00
Psychotoxical f8c45efd2b feat: IndexedDB image caching, initial-avatars, and discovery improvements (v1.0.5) 2026-03-12 22:28:25 +01:00
Psychotoxical 7e0cffc892 feat: album download support and GPU compatibility fixes (v1.0.4) 2026-03-12 20:04:28 +01:00
Psychotoxical 04773e83f7 chore: prepare release v1.0.3 2026-03-12 18:36:36 +01:00
Psychotoxical 45a220989f chore: bump version to 1.0.3 2026-03-11 23:21:11 +01:00
Psychotoxical 8a5cca799b ci: add libunwind-dev to release workflow 2026-03-11 23:16:11 +01:00
Psychotoxical ee49258c4a chore: release v1.0.2 - fix GStreamer bundling for Linux 2026-03-11 23:12:00 +01:00
Psychotoxical df9334f844 docs: update README with project attribution 2026-03-11 22:06:51 +01:00
Psychotoxical 6456b3e561 chore: prepare release v1.0.1 and ignore CLAUDE.md 2026-03-11 21:56:26 +01:00
47 changed files with 3137 additions and 1166 deletions
+94 -17
View File
@@ -6,7 +6,42 @@ on:
workflow_dispatch:
jobs:
release:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: create release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
with:
script: |
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body: 'See the assets to download this version and install.',
draft: false,
prerelease: false
})
return data.id
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
@@ -17,20 +52,49 @@ jobs:
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: 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:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-22.04'
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-libav
- name: install linuxdeploy gstreamer plugin
run: |
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
- name: setup node
uses: actions/setup-node@v4
@@ -39,19 +103,32 @@ jobs:
- 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
- name: build
run: npm run tauri:build
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: patch AppImage AppRun
run: |
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
rm -rf squashfs-root appimagetool
- name: upload Linux artifacts
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 }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
+3
View File
@@ -26,3 +26,6 @@ dist-ssr
# Tauri
src-tauri/target/
# Documentation
CLAUDE.md
+66
View File
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.7] - 2026-03-13
### Added
- **Update Notifications**: Integrated a native update check system in the sidebar that notifies you when a new version is available on GitHub.
- **Improved Settings**: Refined layout and styling for a cleaner settings experience.
### Fixed
- **UI/UX Refinements**: Polished sidebar animations and layout for better visual consistency.
- **i18n**: Added missing translations for update notifications and system status.
## [1.0.6] - 2026-03-13
### Added
- **Extended Themes**: Selection expanded to 8 themes, including the complete Nord series (Nord, Snowstorm, Frost, Aurora).
- **Light Theme Support**: Enhanced readability for Hero and Fullscreen Player components when using light themes (Latte, Snowstorm).
### Fixed
- **Linux/Wayland Compatibility**: Fixed immediate crash on Wayland environments by forcing X11 backend for the AppImage.
- **Playback Stability**: Introduced seek debouncing to prevent audio stalls on Linux/GStreamer.
- **Windows Integration**: Improved drag-and-drop compatibility for systems using WebView2.
## [1.0.5] - 2026-03-12
### Added
- **Image Caching**: Integrated IndexedDB-based image caching for cover art and artist images, providing significantly faster loading times for frequently accessed items.
- **Improved Artist Discovery**: Faster scrolling in the Artists list using color-coded initial-based avatars for quick visual identification.
- **Random Albums**: New discovery page for exploring your library with random album selections.
- **Help & Documentation**: Added a dedicated help page for better user onboarding.
### Changed
- **Optimized UI**: Instant "Now Playing" status updates via local state filtering for a more responsive experience.
- **Enhanced Data Flow**: General performance improvements in server communication and state management.
## [1.0.4] - 2026-03-12
### Added
- **Album Downloads**: Support for downloading entire albums with real-time progress tracking.
### Fixed
- **Linux GPU Compatibility**: Patched AppImage to disable DMABUF renderer, fixing EGL/GPU crashes on older hardware.
- **CI/CD Reliability**: Optimized release workflow with split jobs for better stability across platforms.
## [1.0.3] - 2026-03-12
### Fixed
- **CI/CD Build**: Resolved build conflicts on Ubuntu 22.04 by removing redundant dev packages (`libunwind-dev`, gstreamer dev).
- **Linux AppImage**: Configured GStreamer bundling and verified runtime environment settings.
## [1.0.2] - 2026-03-11
### Fixed
- **Linux AppImage**: Integrated GStreamer bundling fix in CI/CD workflow.
- **CI/CD Reliability**: Set `APPIMAGE_EXTRACT_AND_RUN=1` to prevent FUSE-related issues.
## [1.0.1] - 2026-03-11
### Fixed
- **Optimized Codebase**: Integrated core fixes and performance improvements.
- **Improved Multi-Server Support**: Fixed edge cases in server switching and credential management.
- **Enhanced Security**: Switched to `crypto.getRandomValues()` for more robust auth salt generation.
- **Connection Reliability**: Added pre-verification for server connections to prevent state synchronization issues.
- **Linux Compatibility**: Applied workarounds for WebKitGTK compositing issues on Linux.
### Changed
- Repository maintenance and preparation for the 1.0.1 release.
## [1.0.0] - 2026-03-09
### Added
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.0",
"version": "1.0.7",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "0.1.0"
version = "1.0.6"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.0"
version = "1.0.7"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://schema.tauri.app/config/2/capability.json",
"$schema": "../gen/schemas/capability-schema.json",
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
@@ -22,7 +22,6 @@
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-hide",
+1 -1
View File
@@ -1 +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","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}}
{"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","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}}
+7 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.0",
"version": "1.0.7",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -44,6 +44,11 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"linux": {
"appimage": {
"bundleMediaFramework": true
}
}
}
}
+6 -2
View File
@@ -21,6 +21,8 @@ import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import { useAuthStore } from './store/authStore';
@@ -28,8 +30,8 @@ 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 />;
const { isLoggedIn, servers, activeServerId } = useAuthStore();
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
return <>{children}</>;
}
@@ -142,6 +144,7 @@ function AppShell() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/random-albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
@@ -152,6 +155,7 @@ function AppShell() {
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
</Routes>
</div>
</main>
+78 -50
View File
@@ -2,31 +2,38 @@ import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Token Auth ───────────────────────────────────────────────
function getAuthParams(username: string, password: string) {
const salt = Math.random().toString(36).substring(2, 10);
const salt = secureRandomSalt();
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 { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const params = getAuthParams(username, password);
return {
baseUrl: `${baseUrl}/rest`,
params,
};
if (!baseUrl) throw new Error('No server configured');
const params = getAuthParams(server?.username ?? '', server?.password ?? '');
return { baseUrl: `${baseUrl}/rest`, params };
}
async function api<T>(endpoint: string, extra: Record<string, unknown> = {}): Promise<T> {
async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, timeout = 15000): Promise<T> {
const { baseUrl, params } = getClient();
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra },
paramsSerializer: { indexes: null }
paramsSerializer: { indexes: null },
timeout,
});
const data = resp.data['subsonic-response'];
const data = resp.data?.['subsonic-response'];
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
return data as T;
}
@@ -59,11 +66,11 @@ export interface SubsonicSong {
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
bitRate?: number;
suffix?: string;
contentType?: string;
size?: number;
samplingRate?: number;
channelCount?: number;
starred?: string;
}
@@ -95,7 +102,7 @@ export interface SubsonicArtist {
}
export interface SubsonicGenre {
value: string; // The genre name is returned as "value" by subsonic
value: string;
songCount: number;
albumCount: number;
}
@@ -119,6 +126,24 @@ export async function ping(): Promise<boolean> {
}
}
/** Test a connection with explicit credentials — does NOT depend on store state. */
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
try {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
return data?.status === 'ok';
} 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 ?? [];
@@ -205,13 +230,8 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view');
const r = data.starred2 ?? {};
return {
artists: r.artist ?? [],
albums: r.album ?? [],
songs: r.song ?? [],
};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> {
@@ -270,33 +290,50 @@ export async function reportNowPlaying(id: string): Promise<void> {
// ─── Stream URL ───────────────────────────────────────────────
export function buildStreamUrl(id: string): string {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
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 });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
/** Stable cache key for cover art — does not include ephemeral auth params. */
export function coverArtCacheKey(id: string, size = 256): string {
const server = useAuthStore.getState().getActiveServer();
return `${server?.id ?? '_'}:cover:${id}:${size}`;
}
export function buildCoverArtUrl(id: string, size = 256): string {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
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' });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id, size: String(size),
u: server?.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 { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
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' });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/download.view?${p.toString()}`;
}
@@ -307,7 +344,6 @@ export async function getPlaylists(): Promise<SubsonicPlaylist[]> {
}
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 ?? [] };
@@ -330,11 +366,7 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num
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 ?? []
};
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
} catch {
return { songs: [] };
}
@@ -342,12 +374,9 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num
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 (songIds.length > 0) params.id = songIds;
if (current !== undefined) params.current = current;
if (position !== undefined) params.position = position;
await api('savePlayQueue.view', params);
}
@@ -355,7 +384,6 @@ export async function savePlayQueue(songIds: string[], current?: string, positio
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 {
+4 -3
View File
@@ -1,8 +1,9 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl } from '../api/subsonic';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from './CachedImage';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -28,7 +29,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
@@ -37,7 +38,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
>
<div className="album-card-cover">
{coverUrl ? (
<img src={coverUrl} alt={`${album.name} Cover`} loading="lazy" />
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} 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">
+4 -2
View File
@@ -3,6 +3,7 @@ import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -86,7 +88,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<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>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+4 -2
View File
@@ -3,6 +3,7 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -86,7 +88,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<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>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+21
View File
@@ -0,0 +1,21 @@
import React, { useEffect, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
cacheKey: string;
}
export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
const [resolved, setResolved] = useState('');
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
}, [fetchUrl, cacheKey]);
return resolved || fetchUrl;
}
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
return <img src={resolvedSrc} {...props} />;
}
+125 -137
View File
@@ -7,13 +7,23 @@ 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';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
export default function ContextMenu() {
const { t } = useTranslation();
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 });
@@ -28,37 +38,14 @@ export default function ContextMenu() {
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;
@@ -91,139 +78,140 @@ export default function ContextMenu() {
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`);
const path = await join(auth.downloadFolder, `${sanitizeFilename(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`;
a.download = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
} catch (e) {
console.error('Download fehlgeschlagen:', e);
console.error('Download failed:', 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
<>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y, zIndex: 999 }}
>
{(type === 'song' || type === 'album-song') && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> {t('contextMenu.playNow')}
</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>
</>
);
})()}
<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} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</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} /> {t('contextMenu.enqueueAlbum')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</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 === 'album' && (() => {
const album = item as SubsonicAlbum;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</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 === 'artist' && (() => {
const artist = item as SubsonicArtist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
</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>
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
{t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
</>
);
})()}
</div>
</>
);
}
+64 -67
View File
@@ -4,7 +4,9 @@ import {
ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -62,20 +64,18 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
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 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="progress"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
@@ -84,11 +84,12 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
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 className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
);
});
@@ -98,6 +99,7 @@ interface FullscreenPlayerProps {
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
@@ -109,9 +111,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
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);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
// Close on Escape
useEffect(() => {
@@ -121,34 +125,67 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
}, [onClose]);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label="Vollbild-Player">
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Crossfading blurred background */}
<FsBg url={coverUrl} />
<FsBg url={resolvedCoverUrl} />
<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)">
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
<ChevronDown size={28} />
</button>
{/* Main layout: cover left, upcoming right */}
{/* Two-column layout: left = cover + info + controls, right = playlist */}
<div className="fs-layout">
{/* Left column: cover only */}
{/* Left column */}
<div className="fs-left">
<div className="fs-cover-wrap">
{coverUrl ? (
<img src={coverUrl} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
<CachedImage src={coverUrl} cacheKey={coverKey} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
</div>
<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>
<FsProgress duration={duration} />
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
<Square size={14} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={20} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={20} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
</div>
{/* Right column: upcoming tracks */}
{upcoming.length > 0 && (
<div className="fs-right">
<h2 className="fs-upcoming-title">Nächste Titel</h2>
<h2 className="fs-upcoming-title">{t('queue.nextTracks')}</h2>
<div className="fs-upcoming-list">
{upcoming.map((track, i) => (
<button
@@ -157,7 +194,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
onClick={() => playTrack(track, queue)}
>
{track.coverArt ? (
<img src={buildCoverArtUrl(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
<CachedImage src={buildCoverArtUrl(track.coverArt, 80)} cacheKey={coverArtCacheKey(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
) : (
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
)}
@@ -172,46 +209,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
</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>
);
}
+109 -29
View File
@@ -1,47 +1,113 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
export default function Hero() {
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
const navigate = useNavigate();
const INTERVAL_MS = 10000;
// Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(1);
useEffect(() => {
let cancelled = false;
getRandomAlbums(1).then(albums => {
if (!cancelled && albums[0]) setAlbum(albums[0]);
}).catch(() => {});
return () => { cancelled = true; };
if (!url) return;
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<>
{layers.map(layer => (
<div
key={layer.id}
className="hero-bg"
style={{
backgroundImage: `url(${layer.url})`,
opacity: layer.visible ? 1 : 0,
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
}}
aria-hidden="true"
/>
))}
</>
);
}
export default function Hero() {
const { t } = useTranslation();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
}, []);
if (!album) return <div className="hero-placeholder" />;
// Start / restart auto-advance timer
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return;
timerRef.current = setInterval(() => {
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, []);
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
useEffect(() => {
startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [albums.length, startTimer]);
const goTo = useCallback((idx: number) => {
setActiveIdx(idx);
startTimer(albums.length);
}, [albums.length, startTimer]);
const album = albums[activeIdx] ?? null;
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Resolve cover thumbnail via cache
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
if (!album) return <div className="hero-placeholder" />;
return (
<div
className="hero"
role="banner"
aria-label="Album des Augenblicks"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
{coverUrl && (
<div
className="hero-bg"
style={{ backgroundImage: `url(${coverUrl})` }}
aria-hidden="true"
/>
)}
<HeroBg url={resolvedBgUrl} />
<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`} />
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
{coverRawUrl && (
<CachedImage
className="hero-cover"
src={coverRawUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
/>
)}
<div className="hero-text">
<span className="hero-eyebrow">Album des Augenblicks</span>
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{album.artist}</p>
<div className="hero-meta">
@@ -54,10 +120,10 @@ export default function Hero() {
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
aria-label={`Album ${album.name} abspielen`}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
Album abspielen
{t('hero.playAlbum')}
</button>
<button
className="btn btn-surface"
@@ -71,17 +137,31 @@ export default function Hero() {
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
usePlayerStore.getState().enqueue(tracks);
} catch (err) { }
} catch (_) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
Einreihen
{t('hero.enqueue')}
</button>
</div>
</div>
</div>
{/* Carousel dot indicators */}
{albums.length > 1 && (
<div className="hero-dots" onClick={e => e.stopPropagation()}>
{albums.map((_, i) => (
<button
key={i}
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
onClick={() => goTo(i)}
aria-label={`Album ${i + 1}`}
/>
))}
</div>
)}
</div>
);
}
+10 -8
View File
@@ -3,6 +3,7 @@ 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';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -13,6 +14,7 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
}
export default function LiveSearch() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false);
@@ -63,7 +65,7 @@ export default function LiveSearch() {
id="live-search-input"
className="input live-search-field"
type="search"
placeholder="Suchen nach Künstler, Album oder Song…"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
@@ -73,7 +75,7 @@ export default function LiveSearch() {
autoComplete="off"
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label="Suche leeren">
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label={t('search.clearLabel')}>
×
</button>
)}
@@ -82,12 +84,12 @@ export default function LiveSearch() {
{open && (
<div className="live-search-dropdown" id="search-results" role="listbox">
{!hasResults && !loading && (
<div className="search-empty">Keine Ergebnisse für {query}"</div>
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> Künstler</div>
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => (
<button
key={a.id}
@@ -104,7 +106,7 @@ export default function LiveSearch() {
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> Alben</div>
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => (
<button
key={a.id}
@@ -128,14 +130,14 @@ export default function LiveSearch() {
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> Songs</div>
<div className="search-section-label"><Music size={12} /> {t('search.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,
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
});
+44 -27
View File
@@ -1,10 +1,16 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const isPlaying = usePlayerStore(s => s.isPlaying);
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -20,11 +26,16 @@ export default function NowPlayingDropdown() {
}
};
// Fetch when the dropdown is opened
// Poll in background so the badge stays current without opening the dropdown
useEffect(() => {
if (isOpen) {
fetchNowPlaying();
}
fetchNowPlaying();
const id = setInterval(fetchNowPlaying, 10000);
return () => clearInterval(id);
}, []);
// Refresh immediately when dropdown is opened
useEffect(() => {
if (isOpen) fetchNowPlaying();
}, [isOpen]);
// Click outside to close
@@ -38,33 +49,39 @@ export default function NowPlayingDropdown() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// For the current user, trust the local player state — the server keeps stale
// "now playing" entries for minutes after playback stops.
const visible = nowPlaying.filter(entry =>
entry.username === ownUsername ? isPlaying : true
);
return (
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
<button
className="btn btn-surface"
<button
className="btn btn-surface"
onClick={() => setIsOpen(!isOpen)}
data-tooltip="Wer hört was?"
data-tooltip={t('nowPlaying.tooltip')}
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' }} />
<Radio size={18} className={visible.length > 0 ? 'animate-pulse' : ''} style={{ color: visible.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'
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
}}>
{nowPlaying.length}
{visible.length}
</span>
)}
</button>
{isOpen && (
<div
<div
className="glass animate-fade-in"
style={{
position: 'absolute',
@@ -83,9 +100,9 @@ export default function NowPlayingDropdown() {
}}
>
<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}
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={fetchNowPlaying}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
style={{ width: '28px', height: '28px', padding: 0 }}
>
@@ -93,17 +110,17 @@ export default function NowPlayingDropdown() {
</button>
</div>
{loading && nowPlaying.length === 0 ? (
{loading && visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Lädt...
{t('nowPlaying.loading')}
</div>
) : nowPlaying.length === 0 ? (
) : visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Gerade hört niemand Musik.
{t('nowPlaying.nobody')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{nowPlaying.map((stream, idx) => (
{visible.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 ? (
@@ -118,7 +135,7 @@ export default function NowPlayingDropdown() {
<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>}
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
</div>
</div>
</div>
+11 -8
View File
@@ -1,9 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } 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 { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import { useTranslation } from 'react-i18next';
function formatTime(seconds: number): string {
@@ -18,6 +19,8 @@ export default function PlayerBar() {
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
@@ -45,11 +48,11 @@ export default function PlayerBar() {
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
<img
<CachedImage
className="player-album-art"
src={buildCoverArtUrl(currentTrack.coverArt, 128)}
src={coverSrc}
cacheKey={coverKey}
alt={`${currentTrack.album} Cover`}
loading="lazy"
/>
) : (
<div className="player-album-art-placeholder">
@@ -80,7 +83,7 @@ export default function PlayerBar() {
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={18} />
<SkipBack size={20} />
</button>
<button
@@ -89,11 +92,11 @@ export default function PlayerBar() {
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" />}
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={18} />
<SkipForward size={20} />
</button>
<button
+35 -16
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } 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';
@@ -124,6 +124,7 @@ export default function QueuePanel() {
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -142,41 +143,57 @@ export default function QueuePanel() {
};
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'queue_reorder',
index
}));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
setDragOverIdx(index);
};
const onDragEnd = () => {
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
};
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
e.preventDefault();
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2
if (draggedIdx !== null) {
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (draggedIdx !== targetIdx) {
reorderQueue(draggedIdx, targetIdx);
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
return;
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
try {
const dataStr = e.dataTransfer.getData('application/json');
const dataStr = e.dataTransfer.getData('text/plain');
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') {
if (data.type === 'song') {
const track = data.track;
if (dropIndex !== undefined) {
// If dropped on a specific item, we might want to insert it there.
@@ -201,9 +218,10 @@ export default function QueuePanel() {
};
return (
<aside
className="queue-panel"
onDragOver={e => e.preventDefault()}
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={e => onDropQueue(e)}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
@@ -293,6 +311,7 @@ export default function QueuePanel() {
}}
draggable
onDragStart={(e) => onDragStart(e, idx)}
onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => {
+78 -8
View File
@@ -1,9 +1,9 @@
import React from 'react';
import React, { useEffect, useState } 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
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
} from 'lucide-react';
const PsysonicLogo = () => (
@@ -13,21 +13,82 @@ const PsysonicLogo = () => (
const navItems = [
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-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
}: {
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
const [lMaj, lMin, lPat] = parse(latest);
const [cMaj, cMin, cPat] = parse(current);
if (lMaj !== cMaj) return lMaj > cMaj;
if (lMin !== cMin) return lMin > cMin;
return lPat > cPat;
}
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
const { t } = useTranslation();
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
<ArrowUpCircle size={20} />
</div>
);
}
return (
<div className="update-toast">
<div className="update-toast-header">
<ArrowUpCircle size={14} />
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<a
className="update-toast-link"
href="https://github.com/Psychotoxical/psysonic/releases"
target="_blank"
rel="noopener noreferrer"
>
{t('sidebar.updateLink')}
</a>
</div>
);
}
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const current = await getVersion();
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, current)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable or not running in Tauri — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -60,15 +121,24 @@ export default function Sidebar({
))}
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed ? { marginTop: 'auto' } : undefined}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
>
<BarChart3 size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
</NavLink>
<NavLink
to="/help"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.help') : undefined}
>
<HelpCircle size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.help')}</span>}
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
+561 -24
View File
@@ -8,6 +8,7 @@ const enTranslation = {
mainstage: 'Mainstage',
newReleases: 'New Releases',
allAlbums: 'All Albums',
randomAlbums: 'Random Albums',
artists: 'Artists',
playlists: 'Playlists',
randomMix: 'Random Mix',
@@ -15,8 +16,197 @@ const enTranslation = {
system: 'System',
statistics: 'Statistics',
settings: 'Settings',
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
},
home: {
starred: 'Personal Favorites',
recent: 'Recently Added',
mostPlayed: 'Most Played',
discover: 'Discover',
loadMore: 'Load More',
discoverMore: 'Discover More'
},
hero: {
eyebrow: 'Featured Album',
playAlbum: 'Play Album',
enqueue: 'Enqueue',
enqueueTooltip: 'Add entire album to queue',
},
search: {
placeholder: 'Search for artist, album or song…',
noResults: 'No results for "{{query}}"',
artists: 'Artists',
albums: 'Albums',
songs: 'Songs',
clearLabel: 'Clear search',
},
nowPlaying: {
tooltip: 'Who is listening?',
title: 'Who is listening?',
loading: 'Loading…',
nobody: 'Nobody is currently listening.',
minutesAgo: '{{n}}m ago',
},
contextMenu: {
playNow: 'Play Now',
playNext: 'Play Next',
addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album',
startRadio: 'Start Radio',
favorite: 'Favorite',
favoriteArtist: 'Favorite Artist',
favoriteAlbum: 'Favorite Album',
removeFromQueue: 'Remove from Queue',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
},
albumDetail: {
back: 'Back',
playAll: 'Play All',
enqueue: 'Enqueue',
enqueueTooltip: 'Add entire album to queue',
artistBio: 'Artist Bio',
download: 'Download (ZIP)',
downloading: 'Loading…',
downloadHint: 'FLAC/WAV albums are zipped server-side first — large albums may take a moment before the download starts.',
downloadHintShort: 'Server zips first — may take a moment depending on file size',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
favorite: 'Favorite',
noBio: 'No biography available.',
moreByArtist: 'More by {{artist}}',
tracksCount: '{{n}} Tracks',
goToArtist: 'Go to {{artist}}',
moreLabelAlbums: 'More albums on {{label}}',
trackTitle: 'Title',
trackArtist: 'Artist',
trackFormat: 'Format',
trackFavorite: 'Favorite',
trackRating: 'Rating',
trackDuration: 'Duration',
trackTotal: 'Total',
notFound: 'Album not found.',
bioModal: 'Artist Biography',
bioClose: 'Close',
ratingLabel: 'Rating',
},
artistDetail: {
back: 'Back',
albums: 'Albums',
album: 'Album',
playAll: 'Play All',
shuffle: 'Shuffle',
radio: 'Radio',
loading: 'Loading…',
noRadio: 'No similar tracks found for this artist.',
notFound: 'Artist not found.',
albumsBy: 'Albums by {{name}}',
topTracks: 'Top Tracks',
noAlbums: 'No albums found.',
trackTitle: 'Title',
trackAlbum: 'Album',
trackDuration: 'Duration',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
favorite: 'Favorite',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
},
favorites: {
title: 'Favorites',
empty: "You haven't saved any favorites yet.",
artists: 'Artists',
albums: 'Albums',
songs: 'Songs',
},
randomAlbums: {
title: 'Random Albums',
refresh: 'Refresh',
},
randomMix: {
title: 'Random Mix',
remix: 'Remix',
remixTooltip: 'Load new random songs',
playAll: 'Play All',
trackTitle: 'Title',
trackArtist: 'Artist',
trackAlbum: 'Album',
trackFavorite: 'Favorite',
trackDuration: 'Duration',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
play: 'Play',
},
playlists: {
title: 'Playlists',
loading: 'Loading playlists…',
empty: 'No playlists found.\nUse the queue to create playlists.',
play: 'Play',
deleteTooltip: 'Delete',
confirmDelete: 'Really delete playlist "{{name}}"?',
minutes: 'min.',
track_one: '{{count}} Track',
track_other: '{{count}} Tracks',
},
albums: {
title: 'All Albums',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artist)',
sortNewest: 'Newest first',
sortRandom: 'Random',
},
artists: {
title: 'Artists',
search: 'Search…',
all: 'All',
gridView: 'Grid view',
listView: 'List view',
loadMore: 'Load more',
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
serverName: 'Server Name (optional)',
serverNamePlaceholder: 'My Navidrome',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
username: 'Username',
usernamePlaceholder: 'admin',
password: 'Password',
showPassword: 'Show password',
hidePassword: 'Hide password',
connect: 'Connect',
connecting: 'Connecting…',
connected: 'Connected!',
error: 'Connection failed please check your details.',
urlRequired: 'Please enter a server URL.',
savedServers: 'Saved Servers',
addNew: 'Or add a new server',
},
common: {
albums: 'Albums',
album: 'Album',
loading: 'Loading…',
loadingMore: 'Loading…',
loadingPlaylists: 'Loading Playlists…',
noAlbums: 'No albums found.',
downloading: 'Downloading…',
downloadZip: 'Download (ZIP)',
back: 'Back',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
use: 'Use',
add: 'Add',
active: 'Active',
},
settings: {
title: 'Settings',
@@ -25,19 +215,29 @@ const enTranslation = {
languageDe: 'German',
theme: 'Theme',
appearance: 'Appearance',
connection: 'Connection',
lanIp: 'LAN IP',
externalUrl: 'External URL',
servers: 'Servers',
serverName: 'Server Name',
serverUrl: 'Server URL',
serverUsername: 'Username',
serverPassword: 'Password',
addServer: 'Add Server',
addServerTitle: 'Add New Server',
useServer: 'Use',
deleteServer: 'Delete',
noServers: 'No servers saved.',
serverActive: 'Active',
confirmDeleteServer: 'Delete server "{{name}}"?',
serverConnecting: 'Connecting…',
serverConnected: 'Connected!',
serverFailed: 'Connection failed.',
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.',
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: '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',
@@ -50,7 +250,66 @@ const enTranslation = {
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
pickFolderTitle: 'Select Download Folder',
logout: 'Logout'
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
aboutLicense: 'License',
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
aboutRepo: 'Source Code on GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js'
},
help: {
title: 'Help',
s1: 'Getting Started',
q1: 'Which servers are compatible?',
a1: 'Psysonic works with any Subsonic-compatible server: Navidrome, Gonic, Subsonic, Airsonic, and others. Navidrome is the recommended choice.',
q2: 'How do I connect to my server?',
a2: 'Open Settings and click "Add Server". Enter the server URL (e.g. 192.168.1.100:4533), your username, and password. Psysonic tests the connection before saving — nothing is stored if the connection fails.',
q3: 'Can I use multiple servers?',
a3: 'Yes. You can add as many servers as you like in Settings and switch between them at any time. Only one server is active at a time.',
s2: 'Playback',
q4: 'How do I play music?',
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
q5: 'What keyboard shortcuts are available?',
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
q6: 'What is the queue?',
a6: 'The queue shows all upcoming tracks. Open it with the list icon in the player bar. You can reorder tracks by dragging and save the current queue as a playlist.',
q7: 'How do I open the fullscreen player?',
a7: 'Click the album art thumbnail in the player bar at the bottom, or the expand icon next to it. Press Escape to close it again.',
q8: 'How does repeat work?',
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
s3: 'Library',
q9: 'How do I download an album?',
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server zips the album first — this may take a moment for large albums or lossless files (FLAC / WAV). A progress bar shows the download status.',
q10: 'How do I star / favorite tracks and albums?',
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
q11: 'What is the hero carousel on the home page?',
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
s4: 'Settings',
q12: 'How do I change the theme?',
a12: 'Settings → Theme. Choose between Catppuccin Mocha (dark) and Catppuccin Latte (light).',
q13: 'How do I change the language?',
a13: 'Settings → Language. English and German are currently supported.',
q14: 'What does "Minimize to Tray" do?',
a14: 'When enabled, clicking the × button hides Psysonic to the system tray instead of closing it. Music keeps playing. Right-click the tray icon for controls or to quit.',
q15: 'How do I set a download folder?',
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
s5: 'Scrobbling',
q16: 'How does scrobbling work?',
a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.',
q17: 'When is a scrobble sent?',
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
s6: 'Troubleshooting',
q18: 'Cover art and artist images load slowly.',
a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.',
q19: 'The connection test fails.',
a19: 'Check the URL including the port (e.g. http://192.168.1.100:4533). Make sure no firewall blocks the connection. Try http:// instead of https:// on a local network. Also verify that your username and password are correct.',
q20: 'No audio on Linux (AppImage).',
a20: 'The AppImage bundles GStreamer. If audio still doesn\'t work, try installing system GStreamer packages: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
q21: 'The app crashes or shows a black screen on old Linux hardware.',
a21: 'This is usually caused by GPU / EGL driver issues in WebKitGTK. The official AppImage already patches the launcher to disable GPU compositing. If you build from source, launch with: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
},
queue: {
title: 'Queue',
@@ -59,7 +318,7 @@ const enTranslation = {
cancel: 'Cancel',
save: 'Save',
loadPlaylist: 'Load Playlist',
loading: 'Loading...',
loading: 'Loading',
noPlaylists: 'No playlists found.',
load: 'Load',
delete: 'Delete',
@@ -70,9 +329,19 @@ const enTranslation = {
nextTracks: 'Next Tracks',
emptyQueue: 'The queue is empty.'
},
statistics: {
title: 'Statistics',
mostPlayed: 'Most Played Albums',
highestRated: 'Highest Rated Albums',
genreDistribution: 'Genre Distribution (Top 20)',
loadMore: 'Load more',
},
player: {
regionLabel: 'Music Player',
openFullscreen: 'Open Fullscreen Player',
fullscreen: 'Fullscreen Player',
closeFullscreen: 'Close Fullscreen',
closeTooltip: 'Close (Esc)',
noTitle: 'No Title',
stop: 'Stop',
prev: 'Previous Track',
@@ -96,6 +365,7 @@ const deTranslation = {
mainstage: 'Mainstage',
newReleases: 'Neueste',
allAlbums: 'Alle Alben',
randomAlbums: 'Zufallsalben',
artists: 'Künstler',
playlists: 'Playlists',
randomMix: 'Zufallsmix',
@@ -103,8 +373,197 @@ const deTranslation = {
system: 'System',
statistics: 'Statistiken',
settings: 'Einstellungen',
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
},
home: {
starred: 'Persönliche Favoriten',
recent: 'Zuletzt hinzugefügt',
mostPlayed: 'Meistgehört',
discover: 'Entdecken',
loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken'
},
hero: {
eyebrow: 'Album des Augenblicks',
playAlbum: 'Album abspielen',
enqueue: 'Einreihen',
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
},
search: {
placeholder: 'Suchen nach Künstler, Album oder Song…',
noResults: 'Keine Ergebnisse für „{{query}}"',
artists: 'Künstler',
albums: 'Alben',
songs: 'Songs',
clearLabel: 'Suche leeren',
},
nowPlaying: {
tooltip: 'Wer hört was?',
title: 'Wer hört was?',
loading: 'Lädt…',
nobody: 'Gerade hört niemand Musik.',
minutesAgo: 'vor {{n}}m',
},
contextMenu: {
playNow: 'Direkt abspielen',
playNext: 'Als Nächstes abspielen',
addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen',
startRadio: 'Radio starten',
favorite: 'Favorisieren',
favoriteArtist: 'Künstler favorisieren',
favoriteAlbum: 'Album favorisieren',
removeFromQueue: 'Diesen Song entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
},
albumDetail: {
back: 'Zurück',
playAll: 'Alle abspielen',
enqueue: 'Einreihen',
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
artistBio: 'Künstler-Bio',
download: 'Download (ZIP)',
downloading: 'Lade…',
downloadHint: 'FLAC/WAV-Alben werden serverseitig zuerst gezippt — bei großen Alben kann es einen Moment dauern, bevor der Download startet.',
downloadHintShort: 'Server zippt zuerst — je nach Dateigröße kann es etwas dauern bevor der Download startet',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
favorite: 'Als Favorit',
noBio: 'Keine Biografie verfügbar.',
moreByArtist: 'Mehr von {{artist}}',
tracksCount: '{{n}} Tracks',
goToArtist: 'Zu {{artist}} wechseln',
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
trackTitle: 'Titel',
trackArtist: 'Interpret',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Bewertung',
trackDuration: 'Dauer',
trackTotal: 'Gesamt',
notFound: 'Album nicht gefunden.',
bioModal: 'Künstler-Biografie',
bioClose: 'Schließen',
ratingLabel: 'Bewertung',
},
artistDetail: {
back: 'Zurück',
albums: 'Alben',
album: 'Album',
playAll: 'Alle abspielen',
shuffle: 'Zufallswiedergabe',
radio: 'Radio',
loading: 'Lädt…',
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
notFound: 'Künstler nicht gefunden.',
albumsBy: 'Alben von {{name}}',
topTracks: 'Beliebteste Titel',
noAlbums: 'Keine Alben gefunden.',
trackTitle: 'Titel',
trackAlbum: 'Album',
trackDuration: 'Dauer',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
favorite: 'Als Favorit',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
},
favorites: {
title: 'Favoriten',
empty: 'Du hast noch keine Favoriten gespeichert.',
artists: 'Künstler',
albums: 'Alben',
songs: 'Songs',
},
randomAlbums: {
title: 'Zufallsalben',
refresh: 'Neu laden',
},
randomMix: {
title: 'Zufallsmix',
remix: 'Neu mixen',
remixTooltip: 'Neue Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
trackArtist: 'Künstler',
trackAlbum: 'Album',
trackFavorite: 'Favorit',
trackDuration: 'Dauer',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
play: 'Abspielen',
},
playlists: {
title: 'Playlists',
loading: 'Lade Playlists…',
empty: 'Keine Playlists gefunden.\nNutze die Warteschlange, um Playlists zu erstellen.',
play: 'Abspielen',
deleteTooltip: 'Löschen',
confirmDelete: 'Playlist "{{name}}" wirklich löschen?',
minutes: 'Min.',
track_one: '{{count}} Track',
track_other: '{{count}} Tracks',
},
albums: {
title: 'Alle Alben',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Künstler)',
sortNewest: 'Neueste zuerst',
sortRandom: 'Zufällig',
},
artists: {
title: 'Künstler',
search: 'Suchen…',
all: 'Alle',
gridView: 'Gitteransicht',
listView: 'Listenansicht',
loadMore: 'Mehr laden',
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
serverName: 'Server-Name (optional)',
serverNamePlaceholder: 'Mein Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
username: 'Benutzername',
usernamePlaceholder: 'admin',
password: 'Passwort',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verstecken',
connect: 'Verbinden',
connecting: 'Verbinde…',
connected: 'Verbunden!',
error: 'Verbindung fehlgeschlagen bitte Daten prüfen.',
urlRequired: 'Bitte Server-URL eingeben.',
savedServers: 'Gespeicherte Server',
addNew: 'Oder neuen Server hinzufügen',
},
common: {
albums: 'Alben',
album: 'Album',
loading: 'Lade…',
loadingMore: 'Lade…',
loadingPlaylists: 'Lade Playlists…',
noAlbums: 'Keine Alben gefunden.',
downloading: 'Lade…',
downloadZip: 'Download (ZIP)',
back: 'Zurück',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
use: 'Verwenden',
add: 'Hinzufügen',
active: 'Aktiv',
},
settings: {
title: 'Einstellungen',
@@ -113,19 +572,29 @@ const deTranslation = {
languageDe: 'Deutsch',
theme: 'Design',
appearance: 'Darstellung',
connection: 'Verbindung',
lanIp: 'LAN-IP',
externalUrl: 'Externe URL',
servers: 'Server',
serverName: 'Server-Name',
serverUrl: 'Server-URL',
serverUsername: 'Benutzername',
serverPassword: 'Passwort',
addServer: 'Server hinzufügen',
addServerTitle: 'Neuen Server hinzufügen',
useServer: 'Verwenden',
deleteServer: 'Löschen',
noServers: 'Keine Server gespeichert.',
serverActive: 'Aktiv',
confirmDeleteServer: 'Server „{{name}}" löschen?',
serverConnecting: 'Verbinde…',
serverConnected: 'Verbunden!',
serverFailed: 'Verbindung fehlgeschlagen.',
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.',
lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: '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',
@@ -138,7 +607,66 @@ const deTranslation = {
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
pickFolderTitle: 'Download-Ordner auswählen',
logout: 'Abmelden'
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutLicense: 'Lizenz',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js'
},
help: {
title: 'Hilfe',
s1: 'Erste Schritte',
q1: 'Welche Server sind kompatibel?',
a1: 'Psysonic funktioniert mit jedem Subsonic-kompatiblen Server: Navidrome, Gonic, Subsonic, Airsonic und anderen. Navidrome ist die empfohlene Wahl.',
q2: 'Wie verbinde ich mich mit meinem Server?',
a2: 'Öffne die Einstellungen und klicke auf "Server hinzufügen". Gib die Server-URL (z. B. 192.168.1.100:4533), Benutzername und Passwort ein. Psysonic testet die Verbindung vor dem Speichern — bei Fehler wird nichts gespeichert.',
q3: 'Kann ich mehrere Server verwenden?',
a3: 'Ja. Du kannst beliebig viele Server in den Einstellungen hinzufügen und jederzeit zwischen ihnen wechseln. Immer ist nur ein Server aktiv.',
s2: 'Wiedergabe',
q4: 'Wie spiele ich Musik ab?',
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
q5: 'Welche Tastenkürzel gibt es?',
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
q6: 'Was ist die Warteschlange?',
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Listen-Icon in der Playerleiste. Tracks können per Drag & Drop umsortiert und als Playlist gespeichert werden.',
q7: 'Wie öffne ich den Vollbild-Player?',
a7: 'Klick auf das Album-Cover unten in der Playerleiste oder auf das Expand-Icon daneben. Mit Escape wieder schließen.',
q8: 'Wie funktioniert die Wiederholfunktion?',
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
s3: 'Bibliothek',
q9: 'Wie lade ich ein Album herunter?',
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server zieht das Album zuerst zusammen — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann das einen Moment dauern. Ein Fortschrittsbalken zeigt den Status.',
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
q11: 'Was ist das Karussell auf der Startseite?',
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
s4: 'Einstellungen',
q12: 'Wie ändere ich das Theme?',
a12: 'Einstellungen → Theme. Wahl zwischen Catppuccin Mocha (dunkel) und Catppuccin Latte (hell).',
q13: 'Wie ändere ich die Sprache?',
a13: 'Einstellungen → Sprache. Aktuell verfügbar: Englisch und Deutsch.',
q14: 'Was bewirkt "In Tray minimieren"?',
a14: 'Wenn aktiviert, versteckt sich Psysonic beim Klick auf × im System-Tray statt sich zu schließen. Die Musik läuft weiter. Rechtsklick auf das Tray-Icon zeigt Steueroptionen und "Beenden".',
q15: 'Wie lege ich einen Download-Ordner fest?',
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
s5: 'Scrobbling',
q16: 'Wie funktioniert Scrobbling?',
a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.',
q17: 'Wann wird ein Scrobble gesendet?',
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
s6: 'Problemlösung',
q18: 'Cover und Künstlerbilder laden langsam.',
a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.',
q19: 'Der Verbindungstest schlägt fehl.',
a19: 'URL inklusive Port prüfen (z. B. http://192.168.1.100:4533). Sicherstellen, dass keine Firewall die Verbindung blockiert. Im lokalen Netzwerk http:// statt https:// versuchen. Benutzername und Passwort kontrollieren.',
q20: 'Kein Ton unter Linux (AppImage).',
a20: 'Das AppImage bündelt GStreamer. Falls trotzdem kein Ton kommt, diese System-Pakete installieren: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
q21: 'Die App stürzt ab oder zeigt einen schwarzen Bildschirm auf alter Linux-Hardware.',
a21: 'Das liegt meist an GPU/EGL-Treiberproblemen in WebKitGTK. Das offizielle AppImage deaktiviert GPU-Compositing bereits automatisch. Beim Selbst-Kompilieren: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
},
queue: {
title: 'Warteschlange',
@@ -147,7 +675,7 @@ const deTranslation = {
cancel: 'Abbrechen',
save: 'Speichern',
loadPlaylist: 'Playlist laden',
loading: 'Lade...',
loading: 'Lade',
noPlaylists: 'Keine Playlists gefunden.',
load: 'Laden',
delete: 'Löschen',
@@ -158,9 +686,19 @@ const deTranslation = {
nextTracks: 'Nächste Titel',
emptyQueue: 'Die Warteschlange ist leer.'
},
statistics: {
title: 'Statistiken',
mostPlayed: 'Meistgespielte Alben',
highestRated: 'Höchstbewertete Alben',
genreDistribution: 'Genre-Verteilung (Top 20)',
loadMore: 'Mehr laden',
},
player: {
regionLabel: 'Musikplayer',
openFullscreen: 'Vollbild-Player öffnen',
fullscreen: 'Vollbild-Player',
closeFullscreen: 'Vollbild schließen',
closeTooltip: 'Schließen (Esc)',
noTitle: 'Kein Titel',
stop: 'Stop',
prev: 'Vorheriger Titel',
@@ -190,11 +728,10 @@ i18n
lng: savedLanguage,
fallbackLng: 'en',
interpolation: {
escapeValue: false // react already safes from xss
escapeValue: false
}
});
// Setup listener to persist language changes
i18n.on('languageChanged', (lng) => {
localStorage.setItem('psysonic_language', lng);
});
+197 -135
View File
@@ -1,7 +1,7 @@
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 { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
@@ -9,6 +9,16 @@ 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';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -29,10 +39,28 @@ function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: nu
return parts.join(' · ');
}
/** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const { t } = useTranslation();
const [hover, setHover] = useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label="Bewertung">
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1,2,3,4,5].map(n => (
<button
key={n}
@@ -40,7 +68,7 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n} Stern${n !== 1 ? 'e' : ''}`}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
@@ -53,18 +81,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
interface BioModalProps { bio: string; onClose: () => void; }
function BioModal({ bio, onClose }: BioModalProps) {
const { t } = useTranslation();
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="Künstler-Biografie">
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
<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 />
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
</div>
</div>
);
}
export default function AlbumDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuthStore();
@@ -77,7 +107,7 @@ export default function AlbumDetail() {
const [bio, setBio] = useState<string | null>(null);
const [bioOpen, setBioOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
@@ -85,19 +115,15 @@ export default function AlbumDetail() {
if (!id) return;
setLoading(true);
setRelatedAlbums([]);
getAlbum(id).then(async data => {
setAlbum(data);
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
setLoading(false);
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);
@@ -127,10 +153,10 @@ export default function AlbumDetail() {
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
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]);
};
@@ -144,190 +170,220 @@ export default function AlbumDetail() {
if (!album) return;
if (bio) { setBioOpen(true); return; }
const info = await getArtistInfo(album.album.artistId);
setBio(info.biography ?? 'Keine Biografie verfügbar.');
setBio(info.biography ?? t('albumDetail.noBio'));
setBioOpen(true);
};
const handleDownload = async (albumName: string, albumId: string) => {
setDownloading(true);
setDownloadProgress(0);
try {
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
if (total && response.body) {
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
setDownloadProgress(Math.round((received / total) * 100));
}
} else {
const buffer = await response.arrayBuffer() as ArrayBuffer;
chunks.push(new Uint8Array(buffer));
setDownloadProgress(100);
}
const blob = new Blob(chunks);
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${albumName}.zip`);
const path = await join(auth.downloadFolder, `${sanitizeFilename(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`;
a.download = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
} catch (e) {
console.error('Download fehlgeschlagen:', e);
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
setDownloading(false);
// keep bar visible at 100% for 3 seconds so user sees completion
setTimeout(() => setDownloadProgress(null), 60000);
}
};
const toggleStar = async () => {
if (!album) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred); // Optimistic UI update
setIsStarred(!currentlyStarred);
try {
if (currentlyStarred) {
await unstar(album.album.id);
} else {
await star(album.album.id);
}
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
setIsStarred(currentlyStarred);
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation(); // prevent play on double click trigger
e.stopPropagation();
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');
}
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);
setStarredSongs(new Set(starredSongs));
}
};
// Hooks must be called unconditionally — derive from nullable album state
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">Album nicht gefunden.</div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
const { album: info, songs } = album;
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
return (
<div className="album-detail animate-fade-in">
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
<div className="album-detail-header">
{coverUrl && (
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${coverUrl})` }}
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
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
<ChevronLeft size={16} /> {t('albumDetail.back')}
</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"
{coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} 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' }}>{t('common.album')}</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
onClick={() => navigate(`/artist/${info.artistId}`)}
>
<ListPlus size={16} /> Einreihen
{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={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
</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>
<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" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<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>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={toggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="tracklist">
<div className="tracklist-header">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<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>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
</div>
{(() => {
// Group songs by disc number
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -348,7 +404,7 @@ export default function AlbumDetail() {
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className="track-row"
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
onDoubleClick={() => handlePlaySong(song)}
onContextMenu={(e) => {
e.preventDefault();
@@ -368,19 +424,18 @@ export default function AlbumDetail() {
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
}));
e.dataTransfer.setData('text/plain', 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>
{hasVariousArtists && (
<div className="track-artist-cell">
<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 }}>
@@ -390,10 +445,10 @@ export default function AlbumDetail() {
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
<button
className="btn btn-ghost"
onClick={(e) => toggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
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"} />
@@ -411,11 +466,18 @@ export default function AlbumDetail() {
</div>
));
})()}
{/* Total row */}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</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 style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<div className="album-grid-wrap">
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
+7 -7
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
export default function Albums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -51,16 +53,14 @@ export default function Albums() {
}, [loadMore]);
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: 'AZ (Album)' },
{ value: 'alphabeticalByArtist', label: 'AZ (Künstler)' },
{ value: 'newest', label: 'Neueste zuerst' },
{ value: 'random', label: 'Zufällig' },
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
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>
<h1 className="page-title">{t('albums.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
<button
@@ -84,7 +84,7 @@ export default function Albums() {
<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>
+59 -50
View File
@@ -1,10 +1,12 @@
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 { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -12,7 +14,23 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Inline Last.fm SVG icon
/** Strip dangerous tags/attributes from server-provided HTML */
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}
function LastfmIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -22,6 +40,7 @@ function LastfmIcon({ size = 16 }: { size?: number }) {
}
export default function ArtistDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
@@ -31,7 +50,7 @@ export default function ArtistDetail() {
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);
@@ -44,7 +63,6 @@ export default function ArtistDetail() {
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => [])
@@ -64,16 +82,13 @@ export default function ArtistDetail() {
const toggleStar = async () => {
if (!artist) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred); // Optimistic UI update
setIsStarred(!currentlyStarred);
try {
if (currentlyStarred) {
await unstar(artist.id, 'artist');
} else {
await star(artist.id, 'artist');
}
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
setIsStarred(currentlyStarred);
}
};
@@ -101,10 +116,10 @@ export default function ArtistDetail() {
clearQueue();
playTrack(similar[0], similar);
} else {
alert("Keine ähnlichen Titel für diesen Künstler gefunden.");
alert(t('artistDetail.noRadio'));
}
} catch (e) {
console.error("Radio start failed", e);
console.error('Radio start failed', e);
} finally {
setRadioLoading(false);
}
@@ -122,7 +137,7 @@ export default function ArtistDetail() {
return (
<div className="content-body">
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
Künstler nicht gefunden.
{t('artistDetail.notFound')}
</div>
</div>
);
@@ -138,18 +153,18 @@ export default function ArtistDetail() {
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} /> <span>Zurück</span>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
{/* Header: avatar + name + meta + links */}
<div className="artist-detail-header">
<div className="artist-detail-avatar">
{coverId ? (
<img
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<Users size={64} color="var(--text-muted)" />
@@ -161,11 +176,10 @@ export default function ArtistDetail() {
{artist.name}
</h1>
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{/* External links */}
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
{info?.lastFmUrl && (
@@ -180,44 +194,43 @@ export default function ArtistDetail() {
</button>
</div>
)}
{/* Star toggle */}
<button
className="artist-ext-link"
<button
className="artist-ext-link"
onClick={toggleStar}
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
{isStarred ? 'Favorit' : 'Als Favorit'}
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')}
</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
<Play size={16} /> {t('artistDetail.playAll')}
</button>
<button className="btn btn-surface" onClick={handleShuffle}>
<Shuffle size={16} /> Zufallswiedergabe
<Shuffle size={16} /> {t('artistDetail.shuffle')}
</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'}
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
</button>
</div>
</div>
</div>
{/* Biography — only shown when available */}
{/* Biography — sanitized HTML from server */}
{info?.biography && (
<div className="artist-bio-section">
<div
className="artist-bio-text"
dangerouslySetInnerHTML={{ __html: info.biography }}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
/>
</div>
)}
@@ -226,14 +239,14 @@ export default function ArtistDetail() {
{topSongs.length > 0 && (
<>
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
Beliebteste Titel
{t('artistDetail.topTracks')}
</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>{t('artistDetail.trackTitle')}</div>
<div>{t('artistDetail.trackAlbum')}</div>
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
</div>
{topSongs.map((song, idx) => (
<div
@@ -251,28 +264,24 @@ export default function ArtistDetail() {
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>
{idx + 1}
</div>
<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'; }}
<CachedImage
src={buildCoverArtUrl(song.coverArt, 64)}
cacheKey={coverArtCacheKey(song.coverArt, 64)}
alt={song.album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).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>
@@ -284,7 +293,7 @@ export default function ArtistDetail() {
{/* Albums */}
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
Alben von {artist.name}
{t('artistDetail.albumsBy', { name: artist.name })}
</h2>
{albums.length > 0 ? (
@@ -292,7 +301,7 @@ export default function ArtistDetail() {
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
) : (
<p style={{ color: 'var(--text-muted)' }}>Keine Alben gefunden.</p>
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
)}
</div>
);
+54 -54
View File
@@ -1,18 +1,44 @@
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 { getArtists, SubsonicArtist } from '../api/subsonic';
import { LayoutGrid, List } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
const ALPHABET = ['Alle', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
// Catppuccin accent colors — one is picked deterministically from the artist name
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
'var(--ctp-blue)', 'var(--ctp-lavender)',
];
function nameColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CTP_COLORS[h % CTP_COLORS.length];
}
function nameInitial(name: string): string {
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
if (letter) return letter.toUpperCase();
// Fallback: first alphanumeric (e.g. "1349")
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
return alnum?.toUpperCase() ?? '?';
}
export default function Artists() {
const { t } = useTranslation();
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [letterFilter, setLetterFilter] = useState('Alle');
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [visibleCount, setVisibleCount] = useState(50);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -32,8 +58,8 @@ export default function Artists() {
// Filter pipeline
let filtered = artists;
if (letterFilter !== 'Alle') {
if (letterFilter !== ALL_SENTINEL) {
filtered = filtered.filter(a => {
const first = a.name[0]?.toUpperCase() ?? '#';
const isAlpha = /^[A-Z]$/.test(first);
@@ -63,31 +89,31 @@ export default function Artists() {
<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>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder="Suchen…"
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
<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"
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<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"
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
@@ -111,7 +137,7 @@ export default function Artists() {
transition: 'all 0.2s'
}}
>
{l}
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
@@ -121,10 +147,10 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<div className="album-grid-wrap">
{visible.map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<div
key={artist.id}
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
@@ -132,26 +158,13 @@ export default function Artists() {
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 className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{artist.albumCount} Alben</div>
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</div>
@@ -167,7 +180,7 @@ export default function Artists() {
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<button
key={artist.id}
@@ -179,26 +192,13 @@ export default function Artists() {
}}
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 className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{artist.albumCount} Alben</div>
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
@@ -213,14 +213,14 @@ export default function Artists() {
{!loading && hasMore && (
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-ghost" onClick={loadMore}>
Mehr laden
{t('artists.loadMore')}
</button>
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
Keine Künstler gefunden.
{t('artists.notFound')}
</div>
)}
</div>
+10 -12
View File
@@ -4,8 +4,10 @@ import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function Favorites() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
@@ -37,28 +39,27 @@ export default function Favorites() {
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>
<h1 className="page-title">{t('favorites.title')}</h1>
</div>
{!hasAnyFavorites ? (
<div className="empty-state">Du hast noch keine Favoriten gespeichert.</div>
<div className="empty-state">{t('favorites.empty')}</div>
) : (
<>
{artists.length > 0 && (
<ArtistRow title="Künstler" artists={artists} />
<ArtistRow title={t('favorites.artists')} artists={artists} />
)}
{albums.length > 0 && (
<AlbumRow title="Alben" albums={albums} />
<AlbumRow title={t('favorites.albums')} 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>
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.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}
@@ -74,14 +75,11 @@ export default function Favorites() {
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
}));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
>
+108
View File
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; }
interface FaqSection { icon: React.ReactNode; title: string; items: FaqItem[]; }
function AccordionItem({ q, a, open, onToggle }: FaqItem & { open: boolean; onToggle: () => void }) {
return (
<div className={`help-item${open ? ' help-item-open' : ''}`}>
<button className="help-question" onClick={onToggle} aria-expanded={open}>
<span>{q}</span>
<ChevronDown size={16} className="help-chevron" />
</button>
{open && <div className="help-answer">{a}</div>}
</div>
);
}
export default function Help() {
const { t } = useTranslation();
const [openKey, setOpenKey] = useState<string | null>(null);
const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
const sections: FaqSection[] = [
{
icon: <Rocket size={18} />,
title: t('help.s1'),
items: [
{ q: t('help.q1'), a: t('help.a1') },
{ q: t('help.q2'), a: t('help.a2') },
{ q: t('help.q3'), a: t('help.a3') },
],
},
{
icon: <Play size={18} />,
title: t('help.s2'),
items: [
{ q: t('help.q4'), a: t('help.a4') },
{ q: t('help.q5'), a: t('help.a5') },
{ q: t('help.q6'), a: t('help.a6') },
{ q: t('help.q7'), a: t('help.a7') },
{ q: t('help.q8'), a: t('help.a8') },
],
},
{
icon: <LibraryBig size={18} />,
title: t('help.s3'),
items: [
{ q: t('help.q9'), a: t('help.a9') },
{ q: t('help.q10'), a: t('help.a10') },
{ q: t('help.q11'), a: t('help.a11') },
],
},
{
icon: <Settings2 size={18} />,
title: t('help.s4'),
items: [
{ q: t('help.q12'), a: t('help.a12') },
{ q: t('help.q13'), a: t('help.a13') },
{ q: t('help.q14'), a: t('help.a14') },
{ q: t('help.q15'), a: t('help.a15') },
],
},
{
icon: <Radio size={18} />,
title: t('help.s5'),
items: [
{ q: t('help.q16'), a: t('help.a16') },
{ q: t('help.q17'), a: t('help.a17') },
],
},
{
icon: <Wrench size={18} />,
title: t('help.s6'),
items: [
{ q: t('help.q18'), a: t('help.a18') },
{ q: t('help.q19'), a: t('help.a19') },
{ q: t('help.q20'), a: t('help.a20') },
{ q: t('help.q21'), a: t('help.a21') },
],
},
];
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<div className="settings-section-header">
{section.icon}
<h2>{section.title}</h2>
</div>
<div className="help-list">
{section.items.map((item, ii) => {
const key = `${si}-${ii}`;
return <AccordionItem key={key} q={item.q} a={item.a} open={openKey === key} onToggle={() => toggle(key)} />;
})}
</div>
</section>
))}
</div>
</div>
);
}
+11 -8
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -42,6 +43,8 @@ export default function Home() {
}
};
const { t } = useTranslation();
return (
<div className="animate-fade-in">
<Hero />
@@ -55,29 +58,29 @@ export default function Home() {
<>
{starred.length > 0 && (
<AlbumRow
title="Persönliche Favoriten"
title={t('home.starred')}
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
)}
<AlbumRow
title="Zuletzt hinzugefügt"
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
<AlbumRow
title="Meistgehört"
title={t('home.mostPlayed')}
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
<AlbumRow
title="Entdecken"
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText="Mehr entdecken"
moreText={t('home.discoverMore')}
/>
</>
)}
+9 -7
View File
@@ -3,8 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { search, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function LabelAlbums() {
const { t } = useTranslation();
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -13,17 +15,17 @@ export default function LabelAlbums() {
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,
// 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 =>
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
// 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);
})
@@ -34,7 +36,7 @@ export default function LabelAlbums() {
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
<ChevronLeft size={16} /> {t('common.back')}
</button>
<h1 className="page-title" style={{ marginBottom: '2rem' }}>
@@ -46,7 +48,7 @@ export default function LabelAlbums() {
<div className="spinner" />
</div>
) : albums.length === 0 ? (
<div className="empty-state">Keine Alben für dieses Label gefunden.</div>
<div className="empty-state">{t('common.noAlbums')}</div>
) : (
<div className="album-grid-wrap">
{albums.map(a => (
+92 -71
View File
@@ -1,38 +1,20 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { ping } from '../api/subsonic';
import { pingWithCredentials } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
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>
<img src="/logo.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
);
export default function Login() {
const navigate = useNavigate();
const { setCredentials, setLoggedIn, setConnecting, setConnectionError, connectionError } = useAuthStore();
const { t } = useTranslation();
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
const [form, setForm] = useState({
serverName: '',
lanIp: '',
externalUrl: '',
username: '',
password: '',
});
const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' });
const [showPass, setShowPass] = useState(false);
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
@@ -40,38 +22,69 @@ export default function Login() {
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.');
const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => {
if (!profile.url.trim()) {
setTestMessage(t('login.urlRequired'));
setStatus('error');
return;
}
setStatus('testing');
setTestMessage('Verbinde…');
setTestMessage(t('login.connecting'));
setConnecting(true);
setCredentials(form);
setConnectionError(null);
// Small delay to let store update
await new Promise(r => setTimeout(r, 100));
// Test connection directly with entered credentials — don't touch the store yet.
// This avoids any race condition with Zustand's async store rehydration.
let ok = false;
try {
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
} catch {
ok = false;
}
const ok = await ping();
setConnecting(false);
if (ok) {
// Connection succeeded — now persist to store
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
let serverId: string;
if (existing) {
updateServer(existing.id, {
name: profile.name.trim() || profile.url.trim(),
password: profile.password,
});
serverId = existing.id;
} else {
serverId = addServer({
name: profile.name.trim() || profile.url.trim(),
url: profile.url.trim(),
username: profile.username.trim(),
password: profile.password,
});
}
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
setTestMessage('Verbunden!');
setTestMessage(t('login.connected'));
setTimeout(() => navigate('/'), 600);
} else {
setStatus('error');
setConnectionError('Verbindung fehlgeschlagen bitte Daten prüfen.');
setTestMessage('Verbindung fehlgeschlagen.');
setConnectionError(t('login.error'));
setTestMessage(t('login.error'));
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password });
};
const handleQuickConnect = async (srv: typeof servers[0]) => {
setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password });
await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password });
};
return (
<div className="login-page">
<div className="login-bg" aria-hidden="true" />
@@ -80,64 +93,72 @@ export default function Login() {
<PsysonicLogo />
</div>
<h1 className="login-title">Psysonic</h1>
<p className="login-subtitle">Dein Navidrome Desktop Player</p>
<p className="login-subtitle">{t('login.subtitle')}</p>
<form className="login-form" onSubmit={handleConnect} noValidate>
{/* Saved servers quick-connect */}
{servers.length > 0 && (
<div className="login-saved-servers">
<div className="login-saved-label">{t('login.savedServers')}</div>
{servers.map(srv => (
<button
key={srv.id}
className="btn btn-surface login-server-btn"
onClick={() => handleQuickConnect(srv)}
disabled={status === 'testing'}
>
<Server size={14} style={{ flexShrink: 0 }} />
<div style={{ textAlign: 'left', minWidth: 0 }}>
<div style={{ fontWeight: 600 }} className="truncate">{srv.name || srv.url}</div>
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
</div>
</button>
))}
<div className="login-divider"><span>{t('login.addNew')}</span></div>
</div>
)}
<form className="login-form" onSubmit={handleFormSubmit} noValidate>
<div className="form-group">
<label htmlFor="login-server-name">Server-Name (optional)</label>
<label htmlFor="login-server-name">{t('login.serverName')}</label>
<input
id="login-server-name"
className="input"
type="text"
placeholder="Mein Navidrome"
placeholder={t('login.serverNamePlaceholder')}
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 className="form-group">
<label htmlFor="login-url">{t('login.serverUrl')}</label>
<input
id="login-url"
className="input"
type="text"
placeholder={t('login.serverUrlPlaceholder')}
value={form.url}
onChange={update('url')}
autoComplete="off"
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="login-username">Benutzername</label>
<label htmlFor="login-username">{t('login.username')}</label>
<input
id="login-username"
className="input"
type="text"
placeholder="admin"
placeholder={t('login.usernamePlaceholder')}
value={form.username}
onChange={update('username')}
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="login-password">Passwort</label>
<label htmlFor="login-password">{t('login.password')}</label>
<div style={{ position: 'relative' }}>
<input
id="login-password"
@@ -153,7 +174,7 @@ export default function Login() {
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'}
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
@@ -177,7 +198,7 @@ export default function Login() {
id="login-connect-btn"
disabled={status === 'testing'}
>
{status === 'testing' ? 'Verbinde…' : 'Verbinden'}
{status === 'testing' ? t('login.connecting') : t('login.connect')}
</button>
</form>
</div>
+17 -16
View File
@@ -2,11 +2,13 @@ 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';
import { useTranslation } from 'react-i18next';
export default function Playlists() {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue);
@@ -45,7 +47,7 @@ export default function Playlists() {
};
const handleDelete = async (id: string, name: string) => {
if (confirm(`Playlist "${name}" wirklich löschen?`)) {
if (confirm(t('playlists.confirmDelete', { name }))) {
try {
await deletePlaylist(id);
fetchPlaylists();
@@ -59,21 +61,20 @@ export default function Playlists() {
<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>
<h1 className="page-title" style={{ margin: 0 }}>{t('playlists.title')}</h1>
</div>
{loading ? (
<div className="empty-state">Lade Playlists...</div>
<div className="empty-state">{t('playlists.loading')}</div>
) : playlists.length === 0 ? (
<div className="empty-state">
Keine Playlists gefunden.<br/>
Nutze die Warteschlange, um Playlists zu erstellen.
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>
{t('playlists.empty')}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{playlists.map(p => (
<div
key={p.id}
<div
key={p.id}
style={{
background: 'var(--surface0)',
borderRadius: '12px',
@@ -91,22 +92,22 @@ export default function Playlists() {
{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.
{t('playlists.track', { count: p.songCount })} {Math.floor(p.duration / 60)} {t('playlists.minutes')}
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-primary"
<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
<Play size={16} fill="currentColor" /> {t('playlists.play')}
</button>
<button
className="btn btn-ghost"
<button
className="btn btn-ghost"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip="Löschen"
data-tooltip={t('playlists.deleteTooltip')}
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
>
<Trash2 size={18} />
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import { useTranslation } from 'react-i18next';
const INTERVAL_MS = 30000;
const ALBUM_COUNT = 30;
export default function RandomAlbums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [renderKey, setRenderKey] = useState(0);
const [progress, setProgress] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
setRenderKey(k => k + 1);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
const startCycle = useCallback(() => {
// Clear existing timers
if (timerRef.current) clearInterval(timerRef.current);
if (progressRef.current) clearInterval(progressRef.current);
// Reset progress bar
setProgress(0);
const startTime = Date.now();
progressRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
}, 100);
// Auto-refresh
timerRef.current = setInterval(() => {
load().then(() => startCycle());
}, INTERVAL_MS);
}, [load]);
useEffect(() => {
load().then(() => startCycle());
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (progressRef.current) clearInterval(progressRef.current);
};
}, [load, startCycle]);
const handleManualRefresh = () => {
load().then(() => startCycle());
};
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<button
className="btn btn-ghost"
onClick={handleManualRefresh}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</div>
{/* Countdown progress bar */}
<div className="random-albums-progress">
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
</div>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : (
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}
</div>
);
}
+28 -34
View File
@@ -2,6 +2,7 @@ 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';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -11,6 +12,7 @@ function formatDuration(seconds: number): string {
}
export default function RandomMix() {
const { t } = useTranslation();
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack);
@@ -35,15 +37,7 @@ export default function RandomMix() {
}, []);
const handlePlayAll = () => {
if (songs.length > 0) {
playTrack(songs[0], songs);
}
};
const handleEnqueueAll = () => {
if (songs.length > 0) {
enqueue(songs);
}
if (songs.length > 0) playTrack(songs[0], songs);
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
@@ -59,22 +53,21 @@ export default function RandomMix() {
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
const revert = new Set(starredSongs);
setStarredSongs(revert);
setStarredSongs(new Set(starredSongs));
}
};
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>
<h1 className="page-title">{t('randomMix.title')}</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 className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
<Play size={18} fill="currentColor" /> Alle abspielen
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
</button>
</div>
</div>
@@ -85,19 +78,20 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
<span></span>
<span>Titel</span>
<span>Album</span>
<span style={{ textAlign: 'center' }}>Favorit</span>
<span style={{ textAlign: 'right' }}>Dauer</span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{songs.map((song) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
role="row"
draggable
@@ -108,35 +102,35 @@ export default function RandomMix() {
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
}));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
data-tooltip="Abspielen"
data-tooltip={t('randomMix.play')}
>
<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-artist-cell">
<span className="track-artist" data-tooltip={song.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
<button
className="btn btn-ghost"
onClick={(e) => toggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
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"} />
+257 -73
View File
@@ -1,32 +1,131 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Server, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
} from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { ping } from '../api/subsonic';
import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
const [showPass, setShowPass] = useState(false);
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div className="settings-card" style={{ marginTop: '1rem' }}>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverName')}</label>
<input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" />
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder="192.168.1.100:4533" autoComplete="off" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label>
<input className="input" type="text" value={form.username} onChange={update('username')} placeholder="admin" autoComplete="off" />
</div>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
<div style={{ position: 'relative' }}>
<input
className="input"
type={showPass ? 'text' : 'password'}
value={form.password}
onChange={update('password')}
placeholder="••••••••"
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)}
>
{showPass ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button
className="btn btn-primary"
onClick={() => form.url.trim() && onSave({ name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password })}
>
{t('common.add')}
</button>
</div>
</div>
);
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [lanIp, setLanIp] = useState(auth.lanIp);
const [externalUrl, setExternalUrl] = useState(auth.externalUrl);
const [connStatus, setConnStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [lfmApiKey, setLfmApiKey] = useState(auth.lastfmApiKey);
const [lfmSecret, setLfmSecret] = useState(auth.lastfmApiSecret);
const testConnection = async () => {
setConnStatus('testing');
auth.setCredentials({ serverName: auth.serverName, lanIp, externalUrl, username: auth.username, password: auth.password });
await new Promise(r => setTimeout(r, 100));
const ok = await ping();
setConnStatus(ok ? 'ok' : 'error');
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
} catch {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
};
const switchToServer = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
if (ok) {
auth.setActiveServer(server.id);
auth.setLoggedIn(true);
navigate('/');
} else {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
} catch {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
};
const deleteServer = (server: ServerProfile) => {
if (confirm(t('settings.confirmDeleteServer', { name: server.name || server.url }))) {
auth.removeServer(server.id);
}
};
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
setShowAddForm(false);
const tempId = '_new';
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
try {
const ok = await pingWithCredentials(data.url, data.username, data.password);
if (ok) {
const id = auth.addServer(data);
auth.setActiveServer(id);
auth.setLoggedIn(true);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
} else {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
} catch {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
};
const handleLogout = () => {
@@ -53,9 +152,9 @@ export default function Settings() {
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={i18n.language}
<select
className="input"
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
aria-label={t('settings.language')}
>
@@ -74,72 +173,103 @@ export default function Settings() {
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={theme.theme}
<select
className="input"
value={theme.theme}
onChange={(e) => theme.setTheme(e.target.value as any)}
aria-label={t('settings.theme')}
>
<option value="mocha">Catppuccin Mocha (Dark)</option>
<option value="latte">Catppuccin Latte (Light)</option>
<option value="mocha">Catppuccin Mocha</option>
<option value="macchiato">Catppuccin Macchiato</option>
<option value="frappe">Catppuccin Frappé</option>
<option value="latte">Catppuccin Latte</option>
<option value="nord">Nord · Polar Night</option>
<option value="nord-snowstorm">Nord · Snowstorm</option>
<option value="nord-frost">Nord · Frost</option>
<option value="nord-aurora">Nord · Aurora</option>
</select>
</div>
</div>
</section>
{/* Connection */}
{/* Servers */}
<section className="settings-section">
<div className="settings-section-header">
<Wifi size={18} />
<h2>{t('settings.connection')}</h2>
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
<div className="settings-card">
<div className="form-row">
<div className="form-group">
<label htmlFor="settings-lan">{t('settings.lanIp')}</label>
<input id="settings-lan" className="input" value={lanIp} onChange={e => setLanIp(e.target.value)} placeholder="192.168.1.100:4533" />
</div>
<div className="form-group">
<label htmlFor="settings-ext">{t('settings.externalUrl')}</label>
<input id="settings-ext" className="input" value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="music.example.com" />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '0.75rem' }}>
<button className="btn btn-primary" onClick={testConnection} id="settings-test-conn-btn" disabled={connStatus === 'testing'}>
{connStatus === 'testing' ? t('settings.testingBtn') : t('settings.testBtn')}
</button>
{connStatus === 'ok' && <span style={{ color: 'var(--positive)', display: 'flex', alignItems: 'center', gap: '4px' }}><CheckCircle2 size={16} /> {t('settings.connected')}</span>}
{connStatus === 'error' && <span style={{ color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}><WifiOff size={16} /> {t('settings.failed')}</span>}
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
{/* Active connection toggle */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.activeConn')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.activeServer')} <strong>{auth.activeConnection === 'local' ? t('settings.connLocal') : t('settings.connExternal')}</strong></div>
</div>
<div className="conn-toggle" role="group" aria-label="Verbindung umschalten">
<button
className={`conn-toggle-btn ${auth.activeConnection === 'local' ? 'active' : ''}`}
onClick={() => auth.toggleConnection()}
id="conn-local-btn"
aria-pressed={auth.activeConnection === 'local'}
>
<Server size={13} /> {t('settings.connLocal')}
</button>
<button
className={`conn-toggle-btn ${auth.activeConnection === 'external' ? 'active' : ''}`}
onClick={() => auth.toggleConnection()}
id="conn-extern-btn"
aria-pressed={auth.activeConnection === 'external'}
>
<Globe size={13} /> {t('settings.connExternal')}
</button>
</div>
{auth.servers.length === 0 && !showAddForm ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{t('settings.noServers')}
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{auth.servers.map(srv => {
const isActive = srv.id === auth.activeServerId;
const status = connStatus[srv.id];
return (
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span>
{isActive && (
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
{t('settings.serverActive')}
</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
>
<Wifi size={13} />
{t('settings.testBtn')}
</button>
{!isActive && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => switchToServer(srv)}
disabled={status === 'testing'}
id={`settings-use-server-${srv.id}`}
>
{t('settings.useServer')}
</button>
)}
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '4px 8px' }}
onClick={() => deleteServer(srv)}
data-tooltip={t('settings.deleteServer')}
id={`settings-delete-server-${srv.id}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{showAddForm ? (
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
) : (
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
</section>
{/* Last.fm */}
@@ -150,7 +280,11 @@ export default function Settings() {
</div>
<div className="settings-card">
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
<p style={{ marginBottom: '0.5rem' }} dangerouslySetInnerHTML={{ __html: t('settings.lfmDesc1') }} />
<p style={{ marginBottom: '0.5rem' }}>
{t('settings.lfmDesc1')}{' '}
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
{' '}{t('settings.lfmDesc1b')}
</p>
<p>{t('settings.lfmDesc2')}</p>
</div>
@@ -159,7 +293,7 @@ export default function Settings() {
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
</div>
<label className="toggle-switch" aria-label="Scrobbling aktivieren">
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
<span className="toggle-track" />
</label>
@@ -179,7 +313,7 @@ export default function Settings() {
<div style={{ fontWeight: 500 }}>{t('settings.trayTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.trayDesc')}</div>
</div>
<label className="toggle-switch" aria-label="In Tray minimieren">
<label className="toggle-switch" aria-label={t('settings.trayTitle')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
<span className="toggle-track" />
</label>
@@ -225,6 +359,56 @@ export default function Settings() {
<LogOut size={16} /> {t('settings.logout')}
</button>
</section>
{/* About */}
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
<h2>{t('settings.aboutTitle')}</h2>
</div>
<div className="settings-card settings-about">
<div className="settings-about-header">
<img src="/logo.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.7
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
{t('settings.aboutFeatures')}
</p>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
</div>
<button
className="btn btn-ghost"
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
>
<ExternalLink size={14} />
{t('settings.aboutRepo')}
</button>
</div>
</section>
</div>
);
}
+21 -20
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import AlbumRow from '../components/AlbumRow';
import { BarChart3, TrendingUp, Star, Music } from 'lucide-react';
import { BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function Statistics() {
const { t } = useTranslation();
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
@@ -45,7 +47,7 @@ export default function Statistics() {
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
<h1 className="page-title" style={{ margin: 0 }}>Statistiken</h1>
<h1 className="page-title" style={{ margin: 0 }}>{t('statistics.title')}</h1>
</div>
{loading ? (
@@ -54,28 +56,27 @@ export default function Statistics() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<AlbumRow
title="Meistgespielte Alben"
albums={frequent}
<AlbumRow
title={t('statistics.mostPlayed')}
albums={frequent}
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
moreText="Mehr laden"
moreText={t('statistics.loadMore')}
/>
<AlbumRow
title="Höchstbewertete Alben"
albums={highest}
<AlbumRow
title={t('statistics.highestRated')}
albums={highest}
onLoadMore={() => loadMore('highest', highest, setHighest)}
moreText="Mehr laden"
moreText={t('statistics.loadMore')}
/>
{genres.length > 0 && (
<div>
<div className="section-title">
<Music size={20} />
<h2>Genre-Verteilung (Top 20)</h2>
<h2>{t('statistics.genreDistribution')}</h2>
</div>
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
{genres.map(genre => {
const percentage = (genre.songCount / maxGenreCount) * 100;
@@ -86,14 +87,14 @@ export default function Statistics() {
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
</div>
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
<div
style={{
width: `${percentage}%`,
height: '100%',
background: 'var(--accent)',
<div
style={{
width: `${percentage}%`,
height: '100%',
background: 'var(--accent)',
borderRadius: '4px',
transition: 'width 1s ease-out'
}}
}}
/>
</div>
</div>
+55 -40
View File
@@ -1,24 +1,26 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export type ConnectionMode = 'local' | 'external';
export interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
interface AuthState {
// Server config
serverName: string;
lanIp: string;
externalUrl: string;
username: string;
password: string; // stored encrypted via plugin-store
activeConnection: ConnectionMode;
// Multi-server
servers: ServerProfile[];
activeServerId: string | null;
// Last.fm
// Last.fm (global)
lastfmApiKey: string;
lastfmApiSecret: string;
lastfmSessionKey: string;
lastfmUsername: string;
// Settings
// Settings (global)
minimizeToTray: boolean;
scrobblingEnabled: boolean;
maxCacheMb: number;
@@ -30,15 +32,10 @@ interface AuthState {
connectionError: string | null;
// Actions
setCredentials: (data: {
serverName: string;
lanIp: string;
externalUrl: string;
username: string;
password: string;
}) => void;
setActiveConnection: (mode: ConnectionMode) => void;
toggleConnection: () => void;
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
updateServer: (id: string, data: Partial<Omit<ServerProfile, 'id'>>) => void;
removeServer: (id: string) => void;
setActiveServer: (id: string) => void;
setLoggedIn: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setConnectionError: (e: string | null) => void;
@@ -51,17 +48,18 @@ interface AuthState {
// Derived
getBaseUrl: () => string;
getActiveServer: () => ServerProfile | undefined;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
serverName: '',
lanIp: '',
externalUrl: '',
username: '',
password: '',
activeConnection: 'local',
servers: [],
activeServerId: null,
lastfmApiKey: '',
lastfmApiSecret: '',
lastfmSessionKey: '',
@@ -74,12 +72,31 @@ export const useAuthStore = create<AuthState>()(
isConnecting: false,
connectionError: null,
setCredentials: (data) => set({ ...data, connectionError: null }),
addServer: (profile) => {
const id = generateId();
set(s => ({ servers: [...s.servers, { ...profile, id }] }));
return id;
},
setActiveConnection: (mode) => set({ activeConnection: mode }),
updateServer: (id, data) => {
set(s => ({
servers: s.servers.map(srv => srv.id === id ? { ...srv, ...data } : srv),
}));
},
toggleConnection: () =>
set(s => ({ activeConnection: s.activeConnection === 'local' ? 'external' : 'local' })),
removeServer: (id) => {
set(s => {
const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn,
};
});
},
setActiveServer: (id) => set({ activeServerId: id }),
setLoggedIn: (v) => set({ isLoggedIn: v }),
setConnecting: (v) => set({ isConnecting: v }),
@@ -93,20 +110,18 @@ export const useAuthStore = create<AuthState>()(
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
logout: () => set({
isLoggedIn: false,
username: '',
password: '',
lastfmSessionKey: '',
lastfmUsername: '',
}),
logout: () => set({ isLoggedIn: false }),
getBaseUrl: () => {
const s = get();
if (s.activeConnection === 'local') {
return s.lanIp.startsWith('http') ? s.lanIp : `http://${s.lanIp}`;
}
return s.externalUrl.startsWith('http') ? s.externalUrl : `https://${s.externalUrl}`;
const server = s.servers.find(srv => srv.id === s.activeServerId);
if (!server?.url) return '';
return server.url.startsWith('http') ? server.url : `http://${server.url}`;
},
getActiveServer: () => {
const s = get();
return s.servers.find(srv => srv.id === s.activeServerId);
},
}),
{
+18 -6
View File
@@ -74,6 +74,7 @@ interface PlayerState {
}
let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
function clearProgress() {
if (progressInterval) {
@@ -243,8 +244,15 @@ export const usePlayerStore = create<PlayerState>()(
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
howl.seek(time);
// Update UI immediately for responsiveness
set({ progress, currentTime: time });
// Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive
// before the first one completes (reproducible after the 2nd seek)
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
get().howl?.seek(time);
seekDebounce = null;
}, 100);
},
setVolume: (v) => {
@@ -275,13 +283,17 @@ export const usePlayerStore = create<PlayerState>()(
// Internal: prefetch next N tracks
prefetchUpcoming: (fromIndex: number, queue: Track[]) => {
const { prefetched } = get();
// Unload and clear old prefetches to prevent memory leaks
prefetched.forEach((h, id) => {
h.unload();
});
prefetched.clear();
const toFetch = queue.slice(fromIndex, fromIndex + 3);
toFetch.forEach(track => {
if (!prefetched.has(track.id)) {
const url = buildStreamUrl(track.id);
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
prefetched.set(track.id, h);
}
const url = buildStreamUrl(track.id);
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
prefetched.set(track.id, h);
});
set({ prefetched: new Map(prefetched) });
},
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'latte';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora';
interface ThemeState {
theme: Theme;
+284 -214
View File
@@ -21,23 +21,45 @@
inset: 0;
background-size: cover;
background-position: center;
transition: transform 8s ease;
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05);
}
.hero:hover .hero-bg { transform: scale(1); }
.hero-dots {
position: absolute;
bottom: var(--space-4);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 10;
}
.hero-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.35);
border: none;
padding: 0;
cursor: pointer;
transition: all 0.3s ease;
}
.hero-dot:hover:not(.hero-dot-active) { background: rgba(255, 255, 255, 0.6); }
.hero-dot-active { width: 24px; background: rgba(255, 255, 255, 0.9); }
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(30, 30, 46, 0.92) 0%,
rgba(30, 30, 46, 0.6) 50%,
rgba(30, 30, 46, 0.3) 100%
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 50%,
rgba(0, 0, 0, 0.18) 100%
),
linear-gradient(
to top,
rgba(30, 30, 46, 0.95) 0%,
rgba(0, 0, 0, 0.88) 0%,
transparent 60%
);
}
@@ -72,7 +94,7 @@
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
color: var(--ctp-lavender);
margin-bottom: var(--space-2);
}
@@ -80,16 +102,16 @@
font-family: var(--font-display);
font-size: clamp(24px, 3vw, 36px);
font-weight: 700;
color: var(--text-primary);
color: #ffffff;
margin-bottom: var(--space-1);
line-height: 1.2;
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
text-shadow: 0 2px 12px rgba(0,0,0,0.6);
max-width: 500px;
}
.hero-artist {
font-size: 16px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.72);
margin-bottom: var(--space-3);
}
@@ -189,6 +211,19 @@
color: var(--text-muted);
box-shadow: var(--shadow-sm);
}
.artist-card-avatar-initial {
background: var(--bg-card);
border: 2px solid;
box-shadow: none;
overflow: hidden;
}
.artist-card-avatar-initial span {
font-size: 2.5rem;
font-weight: 800;
font-family: var(--font-display);
line-height: 1;
user-select: none;
}
.artist-card-name {
font-weight: 600;
font-size: 14px;
@@ -269,6 +304,20 @@
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4);
}
.random-albums-progress {
height: 2px;
background: var(--border-subtle);
border-radius: var(--radius-full);
margin-bottom: 1.5rem;
overflow: hidden;
}
.random-albums-progress-fill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
@media (min-width: 1024px) {
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}
@@ -428,12 +477,12 @@
.album-detail-hero {
display: flex;
gap: var(--space-6);
align-items: flex-end;
align-items: flex-start;
padding: var(--space-4) 0 var(--space-6);
}
.album-detail-cover {
width: 180px;
height: 180px;
width: clamp(120px, 15vw, 200px);
height: clamp(120px, 15vw, 200px);
border-radius: var(--radius-lg);
object-fit: cover;
box-shadow: var(--shadow-lg);
@@ -447,8 +496,9 @@
font-size: 48px;
color: var(--text-muted);
}
.album-detail-title { font-family: var(--font-display); font-size: 32px; font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); }
.album-detail-artist { font-size: 16px; color: var(--accent); font-weight: 600; }
.album-detail-meta { min-width: 0; flex: 1; }
.album-detail-title { font-family: var(--font-display); font-size: clamp(20px, 3vw, 32px); font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); overflow-wrap: break-word; }
.album-detail-artist { font-size: clamp(13px, 1.5vw, 16px); color: var(--accent); font-weight: 600; }
.album-detail-artist-link {
background: none;
border: none;
@@ -464,14 +514,60 @@
color: var(--text-primary);
text-decoration: underline;
}
.album-detail-info { display: flex; gap: var(--space-3); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
.album-detail-actions { display: flex; gap: var(--space-3); }
.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); }
.download-hint {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
background: rgba(203, 166, 247, 0.08);
border: 1px solid rgba(203, 166, 247, 0.2);
border-radius: var(--radius-full);
padding: 3px 10px 3px 8px;
cursor: default;
white-space: nowrap;
}
.download-progress-wrap {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
border-radius: var(--radius-md);
background: var(--surface-1);
border: 1px solid var(--border);
min-width: 180px;
color: var(--text-secondary);
font-size: 12px;
}
.download-progress-bar {
flex: 1;
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.download-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--ctp-mauve), var(--ctp-blue));
border-radius: 2px;
transition: width 0.2s ease;
}
.download-progress-pct {
min-width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ─ Tracklist ─ */
.tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header {
display: grid;
grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 11px;
@@ -482,18 +578,53 @@
border-bottom: 1px solid var(--border-subtle);
margin-bottom: var(--space-2);
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
}
.track-row {
display: grid;
grid-template-columns: 36px minmax(120px, 3fr) minmax(100px, 2fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
gap: var(--space-3);
align-items: center;
align-items: start;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
}
.tracklist-total {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
border-top: 1px solid var(--border-subtle);
padding: var(--space-3) var(--space-4);
margin-top: var(--space-1);
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
}
.tracklist-total-label {
grid-column: 1 / -2;
text-align: right;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-right: var(--space-3);
}
.tracklist-total-value {
text-align: right;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.track-row:hover { background: var(--bg-hover); }
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
/* CD / Disc separator */
.disc-header {
@@ -515,7 +646,8 @@
.track-num { font-size: 13px; color: var(--text-muted); text-align: right; font-variant-numeric: tabular-nums; }
.track-info { min-width: 0; }
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; }
.track-artist { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-codec {
display: block;
@@ -632,6 +764,8 @@
.artist-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); transition: background var(--transition-fast); width: 100%; text-align: left; }
.artist-row:hover { background: var(--bg-hover); }
.artist-avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent); flex-shrink: 0; }
.artist-avatar-initial { background: var(--bg-card); border: 2px solid; }
.artist-avatar-initial span { font-size: 1rem; font-weight: 700; font-family: var(--font-display); line-height: 1; user-select: none; }
.artist-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.artist-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
@@ -647,7 +781,20 @@
.settings-section-header { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); margin-bottom: var(--space-3); }
.settings-section-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.settings-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); }
/* ─ Help Page ─ */
.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
.help-item { border-bottom: 1px solid var(--border-subtle); }
.help-item:last-child { border-bottom: none; }
.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); transition: background var(--transition-fast); }
.help-question:hover { background: var(--bg-hover); }
.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); }
.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; }
.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); }
.help-answer { padding: var(--space-3) var(--space-5) var(--space-5); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-hover); border-top: 1px solid var(--border-subtle); }
.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.settings-about { display: flex; flex-direction: column; }
.settings-about-header { display: flex; align-items: center; gap: var(--space-4); }
/* Toggle switch */
.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; }
@@ -726,6 +873,12 @@
.login-status--ok { background: rgba(166, 227, 161, 0.15); color: var(--positive); }
.login-status--error { background: rgba(243, 139, 168, 0.15); color: var(--danger); }
.login-saved-servers { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); }
.login-saved-label { font-size: 11px; font-weight: 600; color: var(--text-muted); letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: var(--space-1); }
.login-server-btn { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); text-align: left; width: 100%; }
.login-divider { display: flex; align-items: center; gap: var(--space-3); color: var(--text-muted); font-size: 12px; margin: var(--space-2) 0; }
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ─ Loading / Empty ─ */
.loading-center { display: flex; justify-content: center; align-items: center; height: 200px; }
.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); }
@@ -752,16 +905,16 @@
to { transform: translateY(0); opacity: 1; }
}
/* Blurred background — GPU layer, will not repaint with React re-renders */
/* Blurred background */
.fs-bg {
position: absolute;
inset: -10%;
background-size: cover;
background-position: center;
filter: blur(50px) brightness(0.28) saturate(1.6);
transform: scale(1.2) translateZ(0);
transform: scale(1.2);
z-index: 0;
will-change: auto;
will-change: opacity;
pointer-events: none;
transition: opacity 700ms ease;
}
@@ -803,32 +956,35 @@
transform: translateY(-1px);
}
/* ── Main layout: cover left, upcoming right ── */
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
.fs-layout {
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: row;
align-items: flex-start;
justify-content: center;
gap: clamp(24px, 4vw, 60px);
padding: clamp(52px, 8vh, 90px) clamp(32px, 5vw, 80px) 12px;
gap: clamp(28px, 4vw, 72px);
padding: 72px clamp(40px, 6vw, 100px) 48px;
min-height: 0;
overflow: visible;
overflow: hidden;
}
/* Left column */
/* Left column: cover + track info + progress + controls */
.fs-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
/* Width = cover size (cover fills 100% of this column) */
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
}
/* Cover: explicit size, same formula as .fs-right */
/* Cover fills the column width */
.fs-cover-wrap {
position: relative;
width: clamp(240px, min(38vw, 50vh), 500px);
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
overflow: hidden;
@@ -854,115 +1010,6 @@
color: var(--text-muted);
}
/* Right column: same explicit width+height as cover */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, min(38vw, 50vh), 500px);
height: clamp(240px, min(38vw, 50vh), 500px);
overflow: hidden;
}
.fs-upcoming-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
flex-shrink: 0;
}
.fs-upcoming-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.fs-upcoming-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
width: 100%;
text-align: left;
transition: background var(--transition-fast);
background: transparent;
flex-shrink: 0;
}
.fs-upcoming-item:hover {
background: rgba(255,255,255,0.07);
}
.fs-upcoming-art {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.fs-upcoming-placeholder {
background: var(--ctp-surface1);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.fs-upcoming-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.fs-upcoming-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* ── Footer: track info + progress + controls (centered, full width) ── */
.fs-footer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
width: 100%;
padding: 0 clamp(40px, 8vw, 120px) 48px;
}
.fs-footer-main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
gap: 16px;
margin-top: clamp(20px, 4vh, 80px);
margin-bottom: auto; /* Pushes controls down to bottom */
}
/* Track metadata */
.fs-track-info {
text-align: center;
@@ -970,9 +1017,9 @@
}
.fs-title {
font-family: var(--font-display);
font-size: clamp(18px, 2.2vw, 28px);
font-size: clamp(16px, 1.8vw, 26px);
font-weight: 800;
color: var(--text-primary);
color: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
white-space: nowrap;
@@ -980,15 +1027,18 @@
text-overflow: ellipsis;
}
.fs-artist {
font-size: clamp(13px, 1.2vw, 16px);
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 600;
color: var(--accent);
color: var(--ctp-lavender);
margin: 0;
}
.fs-album {
font-size: 13px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.6);
margin: 3px 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-codec {
display: inline-block;
@@ -999,21 +1049,11 @@
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: var(--text-muted);
color: rgba(255, 255, 255, 0.45);
margin-top: 6px;
}
/* Progress + volume container */
.fs-bottom {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 700px;
align-items: center;
}
/* Progress */
/* Progress bar */
.fs-progress-wrap {
display: flex;
align-items: center;
@@ -1053,21 +1093,19 @@
}
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
/* Controls: flat centered flex row */
/* Transport controls */
.fs-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
gap: 11px;
}
.fs-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
width: 34px;
height: 34px;
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
@@ -1076,72 +1114,104 @@
}
.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
.fs-btn.active { color: var(--accent); }
.fs-btn-sm { width: 36px; height: 36px; }
.fs-btn-sm { width: 25px; height: 25px; }
.fs-btn-play {
width: 68px;
height: 68px;
background: white;
color: #1e1e2e;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: var(--ctp-lavender);
color: #1e1e2e;
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.06);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
box-shadow: 0 8px 28px rgba(0,0,0,0.6);
}
@keyframes fsIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
/* Right column: upcoming tracks — top-aligned with cover */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, 26vw, 380px);
align-self: flex-start;
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
overflow: hidden;
}
/* Blurred cover background */
.fs-bg {
position: absolute;
inset: -10%;
background-size: cover;
background-position: center;
filter: blur(40px) brightness(0.35) saturate(1.4);
transform: scale(1.15);
z-index: 0;
.fs-upcoming-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
margin: 0;
flex-shrink: 0;
}
.fs-bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
160deg,
rgba(17, 17, 27, 0.55) 0%,
rgba(17, 17, 27, 0.85) 60%,
rgba(17, 17, 27, 0.97) 100%
);
z-index: 0;
.fs-upcoming-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* Close button */
.fs-close {
position: absolute;
top: 20px;
left: 24px;
z-index: 10;
.fs-upcoming-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
width: 100%;
text-align: left;
transition: background var(--transition-fast);
background: transparent;
flex-shrink: 0;
}
.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
.fs-upcoming-art {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
color: var(--text-secondary);
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.fs-upcoming-placeholder {
background: var(--ctp-surface1);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
cursor: pointer;
color: var(--text-muted);
}
.fs-close:hover {
background: rgba(255, 255, 255, 0.16);
color: var(--text-primary);
transform: translateY(-1px);
.fs-upcoming-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.fs-upcoming-name {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Chat */
+78 -10
View File
@@ -164,6 +164,70 @@
color: var(--text-primary);
}
/* ─── Update Toast ─── */
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.update-toast {
margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
display: flex;
flex-direction: column;
gap: 3px;
animation: update-toast-in 0.35s ease both;
}
.update-toast-header {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.update-toast-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.update-toast-version {
font-size: 12px;
color: var(--text-secondary);
padding-left: 22px;
}
.update-toast-link {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-decoration: none;
padding-left: 22px;
opacity: 0.8;
transition: opacity var(--transition-fast);
}
.update-toast-link:hover {
opacity: 1;
text-decoration: underline;
}
.update-toast-icon {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2) 0;
color: var(--accent);
animation: update-toast-in 0.35s ease both;
cursor: default;
}
/* ─── Main Content ─── */
.main-content {
grid-area: main;
@@ -312,7 +376,7 @@
.player-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
}
.player-btn {
@@ -322,29 +386,31 @@
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text-secondary);
color: var(--text-muted);
transition: all var(--transition-fast);
}
.player-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.12);
}
.player-btn-primary {
width: 42px;
height: 42px;
background: var(--text-primary);
color: var(--bg-app);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
border-radius: 50%;
box-shadow: var(--shadow-sm);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
flex-shrink: 0;
}
.player-btn-primary:hover {
background: var(--accent);
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.05);
box-shadow: var(--shadow-glow);
transform: scale(1.07);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
}
.player-progress {
@@ -528,6 +594,8 @@
transition: background var(--transition-fast);
color: var(--text-secondary);
}
/* Prevent child elements from stealing dragenter/dragleave events */
.queue-item > * { pointer-events: none; }
.queue-item:hover {
background: var(--bg-hover);
+315
View File
@@ -56,6 +56,106 @@
--danger: var(--ctp-red);
}
/* ─── Catppuccin Macchiato Variables ─── */
[data-theme='macchiato'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b8c0e0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(36, 39, 58, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(198, 160, 246, 0.15);
--accent-glow: rgba(198, 160, 246, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Frappé Variables ─── */
[data-theme='frappe'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b5bfe2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-rosewater: #f2d5cf;
--ctp-flamingo: #eebebe;
--ctp-pink: #f4b8e4;
--ctp-mauve: #ca9ee6;
--ctp-red: #e78284;
--ctp-maroon: #ea999c;
--ctp-peach: #ef9f76;
--ctp-yellow: #e5c890;
--ctp-green: #a6d189;
--ctp-teal: #81c8be;
--ctp-sky: #99d1db;
--ctp-sapphire: #85c1dc;
--ctp-blue: #8caaee;
--ctp-lavender: #babbf1;
--ctp-text: #c6d0f5;
--ctp-subtext1: #b5bfe2;
--ctp-subtext0: #a5adce;
--ctp-overlay2: #949cbb;
--ctp-overlay1: #838ba7;
--ctp-overlay0: #737994;
--ctp-surface2: #626880;
--ctp-surface1: #51576d;
--ctp-surface0: #414559;
--ctp-base: #303446;
--ctp-mantle: #292c3c;
--ctp-crust: #232634;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(48, 52, 70, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(202, 158, 230, 0.15);
--accent-glow: rgba(202, 158, 230, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Latte Variables (Light Theme) ─── */
[data-theme='latte'] {
color-scheme: light;
@@ -107,6 +207,221 @@
--danger: var(--ctp-red);
}
/* ─── Nord Polar Night (Dark) ─── */
[data-theme='nord'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Polar Night */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Snow Storm (Light) ─── */
[data-theme='nord-snowstorm'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%233b4252%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Snow Storm as backgrounds */
--ctp-crust: #d8dee9;
--ctp-mantle: #e5e9f0;
--ctp-base: #eceff4;
--ctp-surface0: #d8dee9;
--ctp-surface1: #c8d0de;
--ctp-surface2: #b8c2d0;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #636e7e;
--ctp-overlay2: #4c566a;
/* Polar Night as text */
--ctp-text: #2e3440;
--ctp-subtext1: #3b4252;
--ctp-subtext0: #434c5e;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #5e81ac;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #9a7200;
--ctp-green: #4e7c3f;
--ctp-pink: #7e5d8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #eceff4;
--bg-sidebar: #e5e9f0;
--bg-card: #d8dee9;
--bg-hover: #c8d0de;
--bg-player: #e5e9f0;
--bg-glass: rgba(236, 239, 244, 0.80);
--accent: #5e81ac;
--accent-dim: rgba(94, 129, 172, 0.15);
--accent-glow: rgba(94, 129, 172, 0.3);
--text-primary: #2e3440;
--text-secondary:#3b4252;
--text-muted: #636e7e;
--border: #c8d0de;
--border-subtle: #d8dee9;
--positive: #4e7c3f;
--warning: #9a7200;
--danger: #bf616a;
}
/* ─── Nord Frost (Deep Ocean Blue) ─── */
[data-theme='nord-frost'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Deep ocean blue Frost-inspired dark backgrounds */
--ctp-crust: #1a2535;
--ctp-mantle: #1d2b3a;
--ctp-base: #253545;
--ctp-surface0: #2d3f52;
--ctp-surface1: #354a60;
--ctp-surface2: #3d546e;
--ctp-overlay0: #6888a4;
--ctp-overlay1: #7898b4;
--ctp-overlay2: #88a8c4;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost accent */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #8fbcbb;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #253545;
--bg-sidebar: #1d2b3a;
--bg-card: #2d3f52;
--bg-hover: #354a60;
--bg-player: #1a2535;
--bg-glass: rgba(37, 53, 69, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.35);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #7898b4;
--border: #2d3f52;
--border-subtle: #253545;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Aurora (Dark + Aurora Purple) ─── */
[data-theme='nord-aurora'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Polar Night base */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Aurora accents — purple as mauve/lavender for button gradients */
--ctp-mauve: #b48ead;
--ctp-lavender: #a3be8c;
--ctp-pink: #b48ead;
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #b48ead;
--accent-dim: rgba(180, 142, 173, 0.15);
--accent-glow: rgba(180, 142, 173, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Global Base Settings ─── */
:root {
/* Typography */
+93
View File
@@ -0,0 +1,93 @@
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
const objectUrlCache = new Map<string, string>();
let db: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (db) return Promise.resolve(db);
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = e => {
const database = (e.target as IDBOpenDBRequest).result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
req.onsuccess = e => {
db = (e.target as IDBOpenDBRequest).result;
resolve(db!);
};
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function getBlob(key: string): Promise<Blob | null> {
try {
const database = await openDB();
return new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key);
req.onsuccess = () => {
const entry = req.result;
resolve(entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null);
};
req.onerror = () => resolve(null);
});
} catch {
return null;
}
}
async function putBlob(key: string, blob: Blob): Promise<void> {
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ key, blob, timestamp: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore write errors
}
}
/**
* Returns a cached object URL for an image.
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
* @param cacheKey A stable key that identifies the image across sessions.
*/
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
if (!fetchUrl) return '';
// 1. In-memory hit (same session)
const existing = objectUrlCache.get(cacheKey);
if (existing) return existing;
// 2. IndexedDB hit (persisted from previous session)
const blob = await getBlob(cacheKey);
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
return objUrl;
}
// 3. Network fetch → store in IDB → return object URL
try {
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
const newBlob = await resp.blob();
putBlob(cacheKey, newBlob); // fire-and-forget
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
return objUrl;
} catch {
return fetchUrl; // fallback: direct URL
}
}