mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
chore: prepare release v1.0.1 and ignore CLAUDE.md
This commit is contained in:
@@ -26,3 +26,6 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -2732,7 +2732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -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 @@
|
||||
{"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,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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (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: 'A–Z (Album)',
|
||||
sortByArtist: 'A–Z (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
@@ -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>
|
||||
|
||||
@@ -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: 'A–Z (Album)' },
|
||||
{ value: 'alphabeticalByArtist', label: 'A–Z (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
@@ -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
@@ -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
@@ -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
@@ -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')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user