chore: prepare release v1.0.1 and ignore CLAUDE.md

This commit is contained in:
Psychotoxical
2026-03-11 21:56:26 +01:00
parent 730eb877ea
commit 6456b3e561
35 changed files with 1355 additions and 740 deletions
+3
View File
@@ -26,3 +26,6 @@ dist-ssr
# Tauri
src-tauri/target/
# Documentation
CLAUDE.md
+12
View File
@@ -5,6 +5,18 @@ 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.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.1",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.0"
version = "1.0.1"
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"]}}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.0",
"version": "1.0.1",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
+2 -2
View File
@@ -28,8 +28,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}</>;
}
+72 -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,44 @@ 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()}`;
}
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 +338,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 +360,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 +368,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 +378,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 -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>
+37 -49
View File
@@ -7,8 +7,18 @@ 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();
@@ -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;
@@ -94,36 +81,41 @@ export default function ContextMenu() {
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 (
<>
{/* 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 }}
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} /> Direkt abspielen
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => {
if (!currentTrack) {
@@ -135,10 +127,10 @@ export default function ContextMenu() {
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronRight size={14} /> Als Nächstes abspielen
<ChevronRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> Zur Warteschlange hinzufügen
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
@@ -150,17 +142,15 @@ export default function ContextMenu() {
}));
enqueue(tracks);
})}>
<ListPlus size={14} /> Ganzes Album einreihen
<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} /> Song-Radio starten
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> Favorisieren
<Star size={14} /> {t('contextMenu.favorite')}
</div>
</>
);
@@ -170,21 +160,18 @@ export default function ContextMenu() {
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 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} /> Zum Künstler
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> Album favorisieren
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> Herunterladen (ZIP)
<Download size={14} /> {t('contextMenu.download')}
</div>
</>
);
@@ -195,11 +182,11 @@ export default function ContextMenu() {
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> Künstler-Radio starten
<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} /> Künstler favorisieren
<Star size={14} /> {t('contextMenu.favoriteArtist')}
</div>
</>
);
@@ -210,20 +197,21 @@ export default function ContextMenu() {
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> Direkt abspielen
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
Diesen Song entfernen
{t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> Song-Radio starten
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
</>
);
})()}
</div>
</>
);
}
+11 -8
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -71,7 +72,7 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
value={progress}
onChange={handleSeek}
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
aria-label="Songfortschritt"
aria-label="progress"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
@@ -84,10 +85,11 @@ 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'}>
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
</button>
);
@@ -98,6 +100,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);
@@ -121,13 +124,13 @@ 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} />
<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')}>
<ChevronDown size={28} />
</button>
@@ -148,7 +151,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
{/* 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
@@ -196,17 +199,17 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<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">
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label="Nächster Titel">
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<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'}`}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
</button>
+8 -6
View File
@@ -3,8 +3,10 @@ import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
export default function Hero() {
const { t } = useTranslation();
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
const navigate = useNavigate();
@@ -24,7 +26,7 @@ export default function Hero() {
<div
className="hero"
role="banner"
aria-label="Album des Augenblicks"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
@@ -41,7 +43,7 @@ export default function Hero() {
<img className="hero-cover" src={coverUrl} 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 +56,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"
@@ -74,10 +76,10 @@ export default function Hero() {
} catch (err) { }
}}
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>
+8 -6
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,7 +130,7 @@ 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}
+7 -6
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const [loading, setLoading] = useState(false);
@@ -20,7 +22,6 @@ export default function NowPlayingDropdown() {
}
};
// Fetch when the dropdown is opened
useEffect(() => {
if (isOpen) {
fetchNowPlaying();
@@ -43,7 +44,7 @@ export default function NowPlayingDropdown() {
<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' }}
>
@@ -83,7 +84,7 @@ 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>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={fetchNowPlaying}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
@@ -95,11 +96,11 @@ export default function NowPlayingDropdown() {
{loading && nowPlaying.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Lädt...
{t('nowPlaying.loading')}
</div>
) : nowPlaying.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' }}>
@@ -118,7 +119,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>
+411 -20
View File
@@ -18,6 +18,182 @@ const enTranslation = {
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
},
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…',
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',
trackFormat: 'Format',
trackFavorite: 'Favorite',
trackRating: 'Rating',
trackDuration: 'Duration',
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',
},
randomMix: {
title: 'Random Mix',
remix: 'Remix',
remixTooltip: 'Load new random songs',
playAll: 'Play All',
trackTitle: 'Title',
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',
language: 'Language',
@@ -25,19 +201,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',
@@ -59,7 +245,7 @@ const enTranslation = {
cancel: 'Cancel',
save: 'Save',
loadPlaylist: 'Load Playlist',
loading: 'Loading...',
loading: 'Loading',
noPlaylists: 'No playlists found.',
load: 'Load',
delete: 'Delete',
@@ -70,9 +256,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',
@@ -106,6 +302,182 @@ const deTranslation = {
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
},
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…',
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',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Bewertung',
trackDuration: 'Dauer',
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',
},
randomMix: {
title: 'Zufallsmix',
remix: 'Neu mixen',
remixTooltip: 'Neue Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
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',
language: 'Sprache',
@@ -113,19 +485,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',
@@ -147,7 +529,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 +540,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 +582,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);
});
+67 -58
View File
@@ -9,6 +9,15 @@ 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 { 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 +38,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 +67,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 +80,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();
@@ -88,16 +117,12 @@ export default function AlbumDetail() {
getAlbum(id).then(async data => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
// Fetch related albums by the same artist
try {
const artistData = await getArtist(data.album.artistId);
// Filter out the current album from the related list
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
} catch (e) {
console.error('Failed to fetch related albums', e);
@@ -144,7 +169,7 @@ 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);
};
@@ -158,21 +183,20 @@ export default function AlbumDetail() {
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);
} finally {
setDownloading(false);
}
@@ -181,45 +205,34 @@ export default function AlbumDetail() {
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));
}
};
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) : '';
@@ -241,7 +254,7 @@ export default function AlbumDetail() {
<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 ? (
@@ -250,12 +263,12 @@ export default function AlbumDetail() {
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
<span className="badge" style={{ marginBottom: '0.5rem' }}>Album</span>
<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={`Zu ${info.artist} wechseln`}
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
onClick={() => navigate(`/artist/${info.artistId}`)}
>
{info.artist}
@@ -271,7 +284,7 @@ export default function AlbumDetail() {
<span style={{ margin: '0 4px' }}>·</span>
<button
className="album-detail-artist-link"
data-tooltip={`Weitere Alben von ${info.recordLabel} anzeigen`}
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
@@ -282,14 +295,14 @@ export default function AlbumDetail() {
<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
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> Einreihen
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
@@ -297,18 +310,18 @@ export default function AlbumDetail() {
className="btn btn-ghost"
id="album-star-btn"
onClick={toggleStar}
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
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"} />
{isStarred ? 'Favorit' : 'Als Favorit'}
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
<ExternalLink size={16} /> Künstler-Bio
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</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)'}
<Download size={16} /> {downloading ? t('albumDetail.downloading') : t('albumDetail.download')}
</button>
</div>
</div>
@@ -319,15 +332,14 @@ export default function AlbumDetail() {
<div className="tracklist">
<div className="tracklist-header">
<div style={{ textAlign: 'center' }}>#</div>
<div>Titel</div>
<div>Format</div>
<div style={{ textAlign: 'center' }}>Favorit</div>
<div>Bewertung</div>
<div style={{ textAlign: 'right' }}>Dauer</div>
<div>{t('albumDetail.trackTitle')}</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;
@@ -368,10 +380,7 @@ 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('application/json', JSON.stringify({ type: 'song', track }));
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
@@ -393,7 +402,7 @@ export default function AlbumDetail() {
<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"} />
@@ -415,7 +424,7 @@ export default function AlbumDetail() {
{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>
<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 -5
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';
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,16 @@ 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') },
{ value: 'newest', label: t('albums.sortNewest') },
{ value: 'random', label: t('albums.sortRandom') },
];
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
+42 -36
View File
@@ -5,6 +5,7 @@ import AlbumCard from '../components/AlbumCard';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -12,7 +13,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 +39,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);
@@ -44,7 +62,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 +81,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 +115,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 +136,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,10 +152,9 @@ 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 ? (
@@ -161,11 +174,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 && (
@@ -181,15 +193,14 @@ export default function ArtistDetail() {
</div>
)}
{/* Star toggle */}
<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'}
{t('artistDetail.favorite')}
</button>
</div>
@@ -197,27 +208,27 @@ export default function ArtistDetail() {
{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 +237,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,10 +262,7 @@ 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
@@ -268,11 +276,9 @@ export default function ArtistDetail() {
<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 +290,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 +298,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>
);
+15 -12
View File
@@ -3,14 +3,17 @@ import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
import { Users, LayoutGrid, List } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
const ALPHABET = ['Alle', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
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);
@@ -33,7 +36,7 @@ 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,11 +66,11 @@ 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"
@@ -79,7 +82,7 @@ export default function Artists() {
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>
@@ -87,7 +90,7 @@ export default function Artists() {
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 +114,7 @@ export default function Artists() {
transition: 'all 0.2s'
}}
>
{l}
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
@@ -151,7 +154,7 @@ export default function Artists() {
<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>
@@ -198,7 +201,7 @@ export default function Artists() {
<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 +216,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>
+8 -10
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,10 +75,7 @@ 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('application/json', JSON.stringify({ type: 'song', track }));
}}
>
<button
+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')}
/>
</>
)}
+4 -2
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[]>([]);
@@ -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 => (
+86 -65
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>
<label htmlFor="login-url">{t('login.serverUrl')}</label>
<input
id="login-lan-ip"
id="login-url"
className="input"
type="text"
placeholder="192.168.1.100:4533"
value={form.lanIp}
onChange={update('lanIp')}
placeholder={t('login.serverUrlPlaceholder')}
value={form.url}
onChange={update('url')}
autoComplete="off"
/>
</div>
<div className="form-group">
<label htmlFor="login-external-url">Externe URL (FQDN)</label>
<input
id="login-external-url"
className="input"
type="text"
placeholder="music.example.com"
value={form.externalUrl}
onChange={update('externalUrl')}
autoComplete="off"
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="login-username">Benutzername</label>
<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>
+10 -9
View File
@@ -2,8 +2,10 @@ 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);
@@ -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,15 +61,14 @@ 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' }}>
@@ -91,7 +92,7 @@ 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>
@@ -101,12 +102,12 @@ export default function Playlists() {
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"
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} />
+15 -25
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>
@@ -87,10 +80,10 @@ export default function RandomMix() {
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
<span></span>
<span>Titel</span>
<span>Album</span>
<span style={{ textAlign: 'center' }}>Favorit</span>
<span style={{ textAlign: 'right' }}>Dauer</span>
<span>{t('randomMix.trackTitle')}</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) => (
@@ -108,17 +101,14 @@ 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('application/json', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
data-tooltip="Abspielen"
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
</button>
@@ -136,7 +126,7 @@ export default function RandomMix() {
<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"} />
+191 -64
View File
@@ -1,32 +1,130 @@
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
} from 'lucide-react';
import { useAuthStore } from '../store/authStore';
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 = () => {
@@ -87,59 +185,84 @@ export default function Settings() {
</div>
</section>
{/* Connection */}
{/* Servers */}
<section className="settings-section">
<div className="settings-section-header">
<Wifi size={18} />
<h2>{t('settings.connection')}</h2>
</div>
<div className="settings-card">
<div className="form-row">
<div className="form-group">
<label htmlFor="settings-lan">{t('settings.lanIp')}</label>
<input id="settings-lan" className="input" value={lanIp} onChange={e => setLanIp(e.target.value)} placeholder="192.168.1.100:4533" />
</div>
<div className="form-group">
<label htmlFor="settings-ext">{t('settings.externalUrl')}</label>
<input id="settings-ext" className="input" value={externalUrl} onChange={e => setExternalUrl(e.target.value)} placeholder="music.example.com" />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '0.75rem' }}>
<button className="btn btn-primary" onClick={testConnection} id="settings-test-conn-btn" disabled={connStatus === 'testing'}>
{connStatus === 'testing' ? t('settings.testingBtn') : t('settings.testBtn')}
</button>
{connStatus === 'ok' && <span style={{ color: 'var(--positive)', display: 'flex', alignItems: 'center', gap: '4px' }}><CheckCircle2 size={16} /> {t('settings.connected')}</span>}
{connStatus === 'error' && <span style={{ color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}><WifiOff size={16} /> {t('settings.failed')}</span>}
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
{auth.servers.length === 0 && !showAddForm ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{t('settings.noServers')}
</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>
)}
{/* 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')}
{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>
<button
className={`conn-toggle-btn ${auth.activeConnection === 'external' ? 'active' : ''}`}
onClick={() => auth.toggleConnection()}
id="conn-extern-btn"
aria-pressed={auth.activeConnection === 'external'}
>
<Globe size={13} /> {t('settings.connExternal')}
</button>
</div>
</div>
</div>
)}
</section>
{/* Last.fm */}
@@ -150,7 +273,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 +286,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 +306,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>
+9 -8
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 ? (
@@ -56,24 +58,23 @@ export default function Statistics() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<AlbumRow
title="Meistgespielte Alben"
title={t('statistics.mostPlayed')}
albums={frequent}
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
moreText="Mehr laden"
moreText={t('statistics.loadMore')}
/>
<AlbumRow
title="Höchstbewertete Alben"
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' }}>
+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);
},
}),
{
+6 -2
View File
@@ -275,13 +275,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);
}
});
set({ prefetched: new Map(prefetched) });
},
+6
View File
@@ -726,6 +726,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); }