feat: 10-band EQ, connection indicator, new icon (v1.5.0)

### 10-Band Graphic Equalizer
- Full EQ in Rust audio engine via EqSource<S> biquad peak filters
- 10 built-in presets + custom preset save/delete
- EqSource::try_seek() implemented — also fixes waveform seek which broke
  silently when EQ was introduced (rodio returned SeekError::NotSupported)
- EQ state persisted in localStorage, synced to Rust on startup

### Connection Indicator
- LED in header (green/red/pulsing) with server name and LAN/WAN label
- Offline overlay with retry button when server is unreachable
- useConnectionStatus hook

### New App Icon
- New logo (logo-psysonic.png) applied across Login, Sidebar, Settings,
  README and all Tauri platform icons (Windows, macOS, Linux, Android, iOS)

### Now Playing Page
- New /now-playing route added

### Fixes
- WaveformSeek: mousemove/mouseup on window to fix drag outside canvas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-18 22:25:32 +01:00
parent 18199a1f8a
commit 59115a09d2
83 changed files with 2158 additions and 33 deletions
+20 -1
View File
@@ -26,11 +26,16 @@ import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import ConnectionIndicator from './components/ConnectionIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useEqStore } from './store/eqStore';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn, servers, activeServerId } = useAuthStore();
@@ -47,11 +52,16 @@ function AppShell() {
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
useEffect(() => {
initializeFromServerQueue();
}, [initializeFromServerQueue]);
useEffect(() => {
useEqStore.getState().syncToRust();
}, []);
useEffect(() => {
const fn = async () => {
try {
@@ -127,6 +137,7 @@ function AppShell() {
<header className="content-header">
<LiveSearch />
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<NowPlayingDropdown />
<button
className="collapse-btn"
@@ -136,7 +147,14 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
</button>
</header>
<div className="content-body" style={{ padding: 0 }}>
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
isChecking={connRetrying}
/>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
@@ -151,6 +169,7 @@ function AppShell() {
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
</Routes>
+9
View File
@@ -169,6 +169,15 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000)
return data.randomSongs?.song ?? [];
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
} catch {
return null;
}
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
+30
View File
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
interface Props {
status: ConnectionStatus;
isLan: boolean;
serverName: string;
}
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
const { t } = useTranslation();
const label = isLan ? 'LAN' : t('connection.extern');
const title =
status === 'connected'
? t('connection.connected')
: status === 'disconnected'
? t('connection.disconnected')
: t('connection.checking');
return (
<div className="connection-indicator" title={title}>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
<span className="connection-server">{serverName}</span>
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
// ─── Frequency response canvas ────────────────────────────────────────────────
const SAMPLE_RATE = 44100;
const EQ_Q = 1.41;
function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number {
if (Math.abs(gainDb) < 0.01) return 0;
const w0 = (2 * Math.PI * centerHz) / sampleRate;
const A = Math.pow(10, gainDb / 40);
const alpha = Math.sin(w0) / (2 * EQ_Q);
const b0 = 1 + alpha * A;
const b1 = -2 * Math.cos(w0);
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
const a1 = -2 * Math.cos(w0);
const a2 = 1 - alpha / A;
const w = (2 * Math.PI * freq) / sampleRate;
const cosW = Math.cos(w), sinW = Math.sin(w);
const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w);
const numRe = b0 + b1 * cosW + b2 * cos2W;
const numIm = - b1 * sinW - b2 * sin2W;
const denRe = a0 + a1 * cosW + a2 * cos2W;
const denIm = - a1 * sinW - a2 * sin2W;
const numMag2 = numRe * numRe + numIm * numIm;
const denMag2 = denRe * denRe + denIm * denIm;
return 10 * Math.log10(numMag2 / denMag2);
}
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
const fMin = 20, fMax = 20000;
const dbMin = -13, dbMax = 13;
const padL = 36, padR = 8, padT = 8, padB = 1;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const freqToX = (f: number) =>
padL + (Math.log10(f / fMin) / Math.log10(fMax / fMin)) * innerW;
const dbToY = (db: number) =>
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
// Background
ctx.fillStyle = '#0d0d12';
ctx.fillRect(0, 0, W, H);
// Grid: dB lines
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
[-12, -6, 0, 6, 12].forEach(db => {
const y = dbToY(db);
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
});
// Grid: frequency lines
[31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].forEach(f => {
const x = freqToX(f);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(x, padT);
ctx.lineTo(x, H - padB);
ctx.stroke();
});
// Zero line (brighter)
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, dbToY(0));
ctx.lineTo(W - padR, dbToY(0));
ctx.stroke();
// Frequency response curve
const points: [number, number][] = [];
const steps = innerW * 2;
for (let i = 0; i <= steps; i++) {
const f = fMin * Math.pow(fMax / fMin, i / steps);
let totalDb = 0;
for (let band = 0; band < 10; band++) {
totalDb += biquadPeakResponse(f, EQ_BANDS[band].freq, gains[band], SAMPLE_RATE);
}
totalDb = Math.max(dbMin, Math.min(dbMax, totalDb));
points.push([freqToX(f), dbToY(totalDb)]);
}
// Fill under curve
const grad = ctx.createLinearGradient(0, padT, 0, H);
grad.addColorStop(0, accentColor.replace(')', ', 0.25)').replace('rgb', 'rgba'));
grad.addColorStop(1, accentColor.replace(')', ', 0.0)').replace('rgb', 'rgba'));
ctx.beginPath();
ctx.moveTo(points[0][0], dbToY(0));
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo(points[points.length - 1][0], dbToY(0));
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Curve line
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.8;
ctx.stroke();
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
drawCurve(canvas, gains, accent);
}, [gains]);
useEffect(() => { redraw(); }, [redraw]);
useEffect(() => {
const ro = new ResizeObserver(redraw);
if (canvasRef.current) ro.observe(canvasRef.current);
return () => ro.disconnect();
}, [redraw]);
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
const handleSave = () => {
const name = saveName.trim();
if (!name) return;
saveCustomPreset(name);
setSaveName('');
setShowSave(false);
};
return (
<div className="eq-wrap">
{/* Controls bar */}
<div className="eq-controls-bar">
<label className="eq-toggle-label">
<span>{t('settings.eqEnabled')}</span>
<label className="toggle-switch" style={{ marginLeft: 8 }}>
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</label>
<div className="eq-preset-row">
<select
className="input eq-preset-select"
value={selectValue}
onChange={e => applyPreset(e.target.value)}
>
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
<optgroup label={t('settings.eqPresetBuiltin')}>
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
{customPresets.length > 0 && (
<optgroup label={t('settings.eqPresetCustomGroup')}>
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
)}
</select>
{isCustomSaved && (
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
<Trash2 size={13} />
</button>
)}
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
<RotateCcw size={13} />
</button>
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
<Save size={13} />
</button>
</div>
</div>
{showSave && (
<div className="eq-save-row">
<input
type="text" className="input" placeholder={t('settings.eqPresetName')}
value={saveName} onChange={e => setSaveName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()}
autoFocus style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
/>
<button className="btn btn-primary" onClick={handleSave} disabled={!saveName.trim()}>{t('common.save')}</button>
<button className="btn btn-ghost" onClick={() => { setShowSave(false); setSaveName(''); }}>{t('common.cancel')}</button>
</div>
)}
{/* EQ panel */}
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
{/* Frequency response */}
<canvas ref={canvasRef} className="eq-canvas" />
{/* Fader area */}
<div className="eq-faders">
{/* dB scale */}
<div className="eq-db-scale">
{[12, 6, 0, -6, -12].map(db => (
<span key={db} className="eq-db-tick">
{db > 0 ? `+${db}` : db}
</span>
))}
</div>
{/* Bands */}
{EQ_BANDS.map((band, i) => (
<div key={band.freq} className="eq-band">
<span className="eq-gain-val">
{gains[i] > 0 ? '+' : ''}{gains[i].toFixed(1)}
</span>
<div className="eq-fader-track">
<div className="eq-zero-mark" />
<input
type="range"
className="eq-fader"
min={-12} max={12} step={0.5}
value={gains[i]}
onChange={e => setBandGain(i, parseFloat(e.target.value))}
disabled={!enabled}
aria-label={`${band.label} Hz`}
/>
</div>
<span className="eq-freq-label">{band.label}</span>
</div>
))}
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { WifiOff, RefreshCw } from 'lucide-react';
interface Props {
serverName: string;
onRetry: () => void;
isChecking: boolean;
}
export default function OfflineOverlay({ serverName, onRetry, isChecking }: Props) {
const { t } = useTranslation();
return (
<div className="offline-overlay">
<div className="offline-overlay-card">
<WifiOff size={48} className="offline-icon" />
<h2 className="offline-title">{t('connection.offlineTitle')}</h2>
<p className="offline-subtitle">
{t('connection.offlineSubtitle', { server: serverName })}
</p>
<button
className="btn btn-primary offline-retry"
onClick={onRetry}
disabled={isChecking}
>
<RefreshCw size={16} className={isChecking ? 'spin' : ''} />
{isChecking ? t('connection.checking') : t('connection.retry')}
</button>
</div>
</div>
);
}
+20 -3
View File
@@ -1,15 +1,16 @@
import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
} from 'lucide-react';
const PsysonicLogo = () => (
<img src="/logo.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
);
const navItems = [
@@ -68,6 +69,8 @@ export default function Sidebar({
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
@@ -123,7 +126,21 @@ export default function Sidebar({
</NavLink>
))}
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
{/* Now Playing — special styled */}
<NavLink
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.nowPlaying') : undefined}
style={{ marginTop: 'auto' }}
>
<span className="nav-np-icon-wrap">
<AudioLines size={isCollapsed ? 22 : 18} />
{isPlaying && currentTrack && <span className="nav-np-dot" />}
</span>
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
</NavLink>
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
to="/statistics"
+23 -11
View File
@@ -156,25 +156,37 @@ export default function WaveformSeek({ trackId }: Props) {
return () => ro.disconnect();
}, []);
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackId) return;
const rect = canvas.getBoundingClientRect();
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const trackIdRef = useRef(trackId);
trackIdRef.current = trackId;
const seekRef = useRef(seek);
seekRef.current = seek;
useEffect(() => {
const up = () => { isDragging.current = false; };
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
const seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackIdRef.current) return;
const rect = canvas.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
const onUp = () => { isDragging.current = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials } from '../api/subsonic';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
export function isLanUrl(url: string): boolean {
try {
const hostname = new URL(url.startsWith('http') ? url : `http://${url}`).hostname;
return (
hostname === 'localhost' ||
hostname.endsWith('.local') ||
/^127\./.test(hostname) ||
/^10\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
);
} catch {
return false;
}
}
export function useConnectionStatus() {
const [status, setStatus] = useState<ConnectionStatus>('checking');
const [isRetrying, setIsRetrying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const check = useCallback(async () => {
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setStatus('disconnected');
return;
}
if (!navigator.onLine) {
setStatus('disconnected');
return;
}
const ok = await pingWithCredentials(server.url, server.username, server.password);
setStatus(ok ? 'connected' : 'disconnected');
}, []);
const retry = useCallback(async () => {
setIsRetrying(true);
await check();
setIsRetrying(false);
}, [check]);
useEffect(() => {
check();
intervalRef.current = setInterval(check, 30_000);
const handleOnline = () => check();
const handleOffline = () => setStatus('disconnected');
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [check]);
const server = useAuthStore(s => s.getActiveServer());
return {
status,
isRetrying,
retry,
isLan: server ? isLanUrl(server.url) : false,
serverName: server?.name ?? '',
};
}
+44 -2
View File
@@ -13,6 +13,7 @@ const enTranslation = {
playlists: 'Playlists',
randomMix: 'Random Mix',
favorites: 'Favorites',
nowPlaying: 'Now Playing',
system: 'System',
statistics: 'Statistics',
settings: 'Settings',
@@ -54,6 +55,7 @@ const enTranslation = {
loading: 'Loading…',
nobody: 'Nobody is currently listening.',
minutesAgo: '{{n}}m ago',
nothingPlaying: 'Nothing playing yet. Start a track!',
},
contextMenu: {
playNow: 'Play Now',
@@ -214,6 +216,15 @@ const enTranslation = {
savedServers: 'Saved Servers',
addNew: 'Or add a new server',
},
connection: {
connected: 'Connected',
disconnected: 'Disconnected',
checking: 'Connecting…',
extern: 'Extern',
offlineTitle: 'No server connection',
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
retry: 'Retry',
},
common: {
albums: 'Albums',
album: 'Album',
@@ -257,6 +268,16 @@ const enTranslation = {
testingBtn: 'Testing…',
connected: 'Connected',
failed: 'Failed',
eqTitle: 'Equalizer',
eqEnabled: 'Enable Equalizer',
eqPreset: 'Preset',
eqPresetCustom: 'Custom',
eqPresetBuiltin: 'Built-in Presets',
eqPresetCustomGroup: 'My Presets',
eqSavePreset: 'Save as Preset',
eqPresetName: 'Preset name…',
eqDeletePreset: 'Delete preset',
eqResetBands: 'Reset to Flat',
lfmTitle: 'Last.fm Scrobbling',
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
@@ -278,7 +299,7 @@ const enTranslation = {
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
aboutLicense: 'License',
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
aboutRepo: 'Source Code on GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
@@ -433,6 +454,7 @@ const deTranslation = {
playlists: 'Playlists',
randomMix: 'Zufallsmix',
favorites: 'Favoriten',
nowPlaying: 'Now Playing',
system: 'System',
statistics: 'Statistiken',
settings: 'Einstellungen',
@@ -474,6 +496,7 @@ const deTranslation = {
loading: 'Lädt…',
nobody: 'Gerade hört niemand Musik.',
minutesAgo: 'vor {{n}}m',
nothingPlaying: 'Noch nichts am Laufen. Leg einen Track auf!',
},
contextMenu: {
playNow: 'Direkt abspielen',
@@ -634,6 +657,15 @@ const deTranslation = {
savedServers: 'Gespeicherte Server',
addNew: 'Oder neuen Server hinzufügen',
},
connection: {
connected: 'Verbunden',
disconnected: 'Getrennt',
checking: 'Verbinde…',
extern: 'Extern',
offlineTitle: 'Keine Serververbindung',
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
retry: 'Erneut versuchen',
},
common: {
albums: 'Alben',
album: 'Album',
@@ -677,6 +709,16 @@ const deTranslation = {
testingBtn: 'Teste…',
connected: 'Verbunden',
failed: 'Fehlgeschlagen',
eqTitle: 'Equalizer',
eqEnabled: 'Equalizer aktivieren',
eqPreset: 'Preset',
eqPresetCustom: 'Benutzerdefiniert',
eqPresetBuiltin: 'Eingebaute Presets',
eqPresetCustomGroup: 'Meine Presets',
eqSavePreset: 'Als Preset speichern',
eqPresetName: 'Preset-Name…',
eqDeletePreset: 'Preset löschen',
eqResetBands: 'Auf Flat zurücksetzen',
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',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
@@ -698,7 +740,7 @@ const deTranslation = {
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutLicense: 'Lizenz',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
+1 -1
View File
@@ -161,7 +161,7 @@ export default function Artists() {
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div>
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { pingWithCredentials } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PsysonicLogo = () => (
<img src="/logo.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
<img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
);
export default function Login() {
+452
View File
@@ -0,0 +1,452 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Play, Shuffle, Star, Music, Save, FolderOpen, Trash2, X,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
getPlaylists, getPlaylist, createPlaylist, deletePlaylist,
getAlbum, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { useCachedUrl } from '../components/CachedImage';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
function formatDuration(secs: number): string {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
}
function renderStars(rating?: number) {
if (!rating) return null;
return (
<div style={{ display: 'flex', gap: '3px', alignItems: 'center' }}>
{[1, 2, 3, 4, 5].map(i => (
<Star
key={i}
size={14}
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
/>
))}
</div>
);
}
// ─── Blurred background (crossfade on track change) ───────────────────────────
const NpBg = memo(function NpBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const nextId = useRef(1);
useEffect(() => {
if (!url) return;
const id = nextId.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<div className="np-bg-wrap">
{layers.map(l => (
<div
key={l.id}
className="np-bg-layer"
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
/>
))}
<div className="np-orb np-orb-1" />
<div className="np-orb np-orb-2" />
<div className="np-orb np-orb-3" />
<div className="np-bg-overlay" />
</div>
);
});
// ─── Modals (reused from QueuePanel) ──────────────────────────────────────────
function SavePlaylistModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
const { t } = useTranslation();
const [name, setName] = useState('');
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
<input
type="text" className="live-search-field"
placeholder={t('queue.playlistName')} value={name}
onChange={e => setName(e.target.value)} autoFocus
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
</div>
</div>
</div>
);
}
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const fetch = () => {
setLoading(true);
getPlaylists().then(d => { setPlaylists(d); setLoading(false); }).catch(() => setLoading(false));
};
useEffect(() => { fetch(); }, []);
const handleDelete = async (id: string, name: string) => {
if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetch(); }
};
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
{loading ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
: playlists.length === 0 ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 300, overflowY: 'auto' }}>
{playlists.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
<span style={{ fontWeight: 500 }} className="truncate">{p.name}</span>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button className="nav-btn" onClick={() => onLoad(p.id)} style={{ width: 28, height: 28, background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} style={{ width: 28, height: 28, background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
let _dragFromIdx: number | null = null;
export default function NowPlaying() {
const { t } = useTranslation();
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const contextMenu = usePlayerStore(s => s.contextMenu);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const removeTrack = usePlayerStore(s => s.removeTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const clearQueue = usePlayerStore(s => s.clearQueue);
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
// Hide queue panel while on this page, restore on leave
useEffect(() => {
const wasVisible = usePlayerStore.getState().isQueueVisible;
if (wasVisible) setQueueVisible(false);
return () => { if (wasVisible) setQueueVisible(true); };
}, [setQueueVisible]);
// Extra song metadata (genre) fetched via getSong
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
useEffect(() => {
if (!currentTrack) { setSongMeta(null); return; }
getSong(currentTrack.id).then(setSongMeta);
}, [currentTrack?.id]);
// Favorite state
const [starred, setStarred] = useState(false);
useEffect(() => {
setStarred(!!songMeta?.starred);
}, [songMeta]);
const toggleStar = async () => {
if (!currentTrack) return;
if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); }
else { await star(currentTrack.id, 'song'); setStarred(true); }
};
// Cover / background
const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Queue drag-and-drop
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
const draggedIdxRef = useRef<number | null>(null);
const dragOverIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
setDraggedIdx(null); setDragOverIdx(null);
isDraggingInternalRef.current = false;
draggedIdxRef.current = null; dragOverIdxRef.current = null;
};
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
isDraggingInternalRef.current = false;
draggedIdxRef.current = null; dragOverIdxRef.current = null;
setDraggedIdx(null); setDragOverIdx(null);
let parsedData: any = null;
try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch {}
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
_dragFromIdx = null;
let toIdx = queue.length;
if (queueListRef.current) {
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) { toIdx = parseInt(items[i].dataset.queueIdx!); break; }
}
}
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
return;
}
_dragFromIdx = null;
if (!parsedData) return;
if (parsedData.type === 'song') enqueue([parsedData.track]);
else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}
};
// Playlist modals
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
return (
<div className="np-page">
{/* ── Hero ── */}
<div className="np-hero">
<NpBg url={resolvedCover ?? ''} />
<div className="np-hero-content">
{currentTrack ? (
<>
{/* Cover + glow */}
<div className="np-cover-wrap">
{resolvedCover && (
<img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden="true" />
)}
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>
}
</div>
{/* Meta */}
<div className="np-info">
<div className="np-title">{currentTrack.title}</div>
<div className="np-artist-album">
<span
className="np-link"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
>{currentTrack.artist}</span>
<span className="np-sep">·</span>
<span
className="np-link"
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
>{currentTrack.album}</span>
{currentTrack.year && <><span className="np-sep">·</span><span>{currentTrack.year}</span></>}
</div>
<div className="np-tech-row">
{songMeta?.genre && <span className="np-badge">{songMeta.genre}</span>}
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
{renderStars(currentTrack.userRating)}
<button
onClick={toggleStar}
className="np-star-btn"
title={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
</div>
</div>
</>
) : (
<div className="np-empty-state">
<Music size={48} style={{ opacity: 0.3 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
)}
</div>
</div>
{/* ── Queue list ── */}
<div className="np-queue-section">
<div className="np-queue-header">
<div>
<h2 className="np-queue-title">{t('queue.title')}</h2>
{queue.length > 0 && (
<div className="np-queue-meta">
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {formatDuration(totalSecs)}
</div>
)}
</div>
<div className="np-queue-actions">
<button onClick={() => shuffleQueue()} className="np-action-btn" title={t('queue.shuffle')} disabled={queue.length < 2}>
<Shuffle size={15} />
</button>
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
<Save size={15} />
</button>
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
<FolderOpen size={15} />
</button>
<button onClick={clearQueue} className="np-action-btn" title={t('queue.clear')} disabled={queue.length === 0}>
<Trash2 size={15} />
</button>
</div>
</div>
<div
className="np-queue-list"
ref={queueListRef}
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={onDropQueue}
>
{queue.length === 0 ? (
<div className="queue-empty">{t('queue.emptyQueue')}</div>
) : (
queue.map((track, idx) => {
const isActive = idx === queueIndex;
const isDragging = draggedIdx === idx;
const isDragOver = dragOverIdx === idx;
let dragStyle: React.CSSProperties = {};
if (isDragging) dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
else if (isDragOver && draggedIdx !== null) {
dragStyle = draggedIdx > idx
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
return (
<div
key={`${track.id}-${idx}`}
data-queue-idx={idx}
className={`np-queue-item ${isActive ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => playTrack(track, queue)}
onContextMenu={e => { e.preventDefault(); usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx); }}
draggable
onDragStart={e => onDragStart(e, idx)}
onDragEnter={e => onDragEnterItem(e)}
onDragOver={e => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
style={dragStyle}
>
<div className="np-queue-num">
{isActive
? <Play size={12} fill="var(--accent)" color="var(--accent)" />
: <span>{idx + 1}</span>
}
</div>
<div className="np-queue-item-info">
<div className={`np-queue-item-title truncate ${isActive ? 'np-queue-item-active' : ''}`}>{track.title}</div>
<div className="np-queue-item-artist truncate">{track.artist} · {track.album}</div>
</div>
<div className="np-queue-item-duration">{formatTime(track.duration)}</div>
<button
className="np-queue-remove"
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
title={t('queue.remove')}
><X size={13} /></button>
</div>
);
})
)}
</div>
</div>
{saveModalOpen && (
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async name => {
try { await createPlaylist(name, queue.map(t => t.id)); setSaveModalOpen(false); }
catch (e) { console.error(e); }
}}
/>
)}
{loadModalOpen && (
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async id => {
try {
const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
setLoadModalOpen(false);
} catch (e) { console.error(e); }
}}
/>
)}
</div>
);
}
+13 -1
View File
@@ -10,6 +10,7 @@ import { useThemeStore } from '../store/themeStore';
import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
@@ -276,6 +277,17 @@ export default function Settings() {
)}
</section>
{/* Equalizer */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.eqTitle')}</h2>
</div>
<div className="settings-card">
<Equalizer />
</div>
</section>
{/* Last.fm */}
<section className="settings-section">
<div className="settings-section-header">
@@ -460,7 +472,7 @@ export default function Settings() {
</div>
<div className="settings-card settings-about">
<div className="settings-about-header">
<img src="/logo.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
<img src="/logo-psysonic.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
+113
View File
@@ -0,0 +1,113 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
export const EQ_BANDS = [
{ freq: 31, label: '31' },
{ freq: 62, label: '62' },
{ freq: 125, label: '125' },
{ freq: 250, label: '250' },
{ freq: 500, label: '500' },
{ freq: 1000, label: '1k' },
{ freq: 2000, label: '2k' },
{ freq: 4000, label: '4k' },
{ freq: 8000, label: '8k' },
{ freq: 16000, label: '16k' },
];
export interface EqPreset {
name: string;
gains: number[];
builtin: boolean;
}
export const BUILTIN_PRESETS: EqPreset[] = [
{ name: 'Flat', builtin: true, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] },
{ name: 'Bass Boost', builtin: true, gains: [6, 5, 4, 2, 0, 0, 0, 0, 0, 0] },
{ name: 'Treble Boost',builtin: true, gains: [0, 0, 0, 0, 0, 0, 2, 3, 4, 5] },
{ name: 'Rock', builtin: true, gains: [4, 3, 1, 0, -1, -1, 1, 3, 4, 4] },
{ name: 'Pop', builtin: true, gains: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2] },
{ name: 'Jazz', builtin: true, gains: [4, 3, 1, 2, -1, -1, 0, 1, 2, 3] },
{ name: 'Classical', builtin: true, gains: [5, 4, 3, 2, -2, -2, 0, 2, 3, 4] },
{ name: 'Electronic', builtin: true, gains: [5, 4, 1, 0, -2, 1, 1, 2, 4, 5] },
{ name: 'Vocal', builtin: true, gains: [-2, -2, 0, 3, 5, 5, 3, 1, -1, -2] },
{ name: 'Acoustic', builtin: true, gains: [3, 2, 2, 2, 0, 0, 1, 2, 2, 3] },
];
interface EqState {
gains: number[]; // 10 values, -12 to +12 dB
enabled: boolean;
activePreset: string | null;
customPresets: EqPreset[];
setBandGain: (index: number, gain: number) => void;
setEnabled: (v: boolean) => void;
applyPreset: (name: string) => void;
saveCustomPreset: (name: string) => void;
deleteCustomPreset: (name: string) => void;
syncToRust: () => void;
}
function syncEq(gains: number[], enabled: boolean) {
invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {});
}
export const useEqStore = create<EqState>()(
persist(
(set, get) => ({
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: false,
activePreset: 'Flat',
customPresets: [],
setBandGain: (index, gain) => {
const clamped = Math.max(-12, Math.min(12, gain));
const gains = [...get().gains];
gains[index] = clamped;
set({ gains, activePreset: null });
syncEq(gains, get().enabled);
},
setEnabled: (v) => {
set({ enabled: v });
syncEq(get().gains, v);
},
applyPreset: (name) => {
const all = [...BUILTIN_PRESETS, ...get().customPresets];
const preset = all.find(p => p.name === name);
if (!preset) return;
set({ gains: [...preset.gains], activePreset: name });
syncEq(preset.gains, get().enabled);
},
saveCustomPreset: (name) => {
const gains = [...get().gains];
const existing = get().customPresets.filter(p => p.name !== name);
set({ customPresets: [...existing, { name, gains, builtin: false }], activePreset: name });
},
deleteCustomPreset: (name) => {
set(s => ({
customPresets: s.customPresets.filter(p => p.name !== name),
activePreset: s.activePreset === name ? null : s.activePreset,
}));
},
syncToRust: () => {
const { gains, enabled } = get();
syncEq(gains, enabled);
},
}),
{
name: 'psysonic-eq',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
gains: s.gains,
enabled: s.enabled,
activePreset: s.activePreset,
customPresets: s.customPresets,
}),
}
)
);
+2
View File
@@ -47,6 +47,7 @@ interface PlayerState {
isQueueVisible: boolean;
toggleQueue: () => void;
setQueueVisible: (v: boolean) => void;
isFullscreenOpen: boolean;
toggleFullscreen: () => void;
@@ -198,6 +199,7 @@ export const usePlayerStore = create<PlayerState>()(
})),
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleRepeat: () => set(state => {
+694
View File
@@ -220,6 +220,10 @@
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 50% !important;
border: 2px solid;
width: calc(100% - 2 * var(--space-4));
margin: var(--space-3) auto var(--space-3);
}
.artist-card-avatar-initial span {
font-size: 3rem;
@@ -1671,3 +1675,693 @@
border-radius: 3px;
transition: width 0.8s ease-out;
}
/* ─ Connection Indicator ─ */
.connection-indicator {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.connection-led {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.4s ease, box-shadow 0.4s ease;
}
.connection-led--connected {
background: #a6e3a1;
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
}
.connection-led--disconnected {
background: #f38ba8;
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
}
.connection-led--checking {
background: var(--text-muted);
box-shadow: none;
animation: led-pulse 1.2s ease-in-out infinite;
}
@keyframes led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.connection-meta {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.connection-type {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.03em;
}
.connection-server {
font-size: 10px;
color: var(--text-muted);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─ Offline Overlay ─ */
.offline-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.offline-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 48px;
background: var(--bg-app);
border: 1px solid var(--border-subtle);
border-radius: 16px;
text-align: center;
max-width: 360px;
}
.offline-icon {
color: var(--text-muted);
opacity: 0.7;
}
.offline-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.offline-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.offline-retry {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ─ Now Playing Page ─ */
.np-page {
display: flex;
flex-direction: column;
min-height: 100%;
overflow-y: auto;
}
/* Hero */
.np-hero {
position: relative;
min-height: 420px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.np-bg-wrap {
position: absolute;
inset: 0 0 -120px 0;
z-index: 0;
overflow: hidden;
}
.np-bg-layer {
position: absolute;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(50px) brightness(0.25) saturate(1.8);
transition: opacity 0.6s ease;
animation: ken-burns 40s ease-in-out infinite;
transform-origin: center center;
}
.np-bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 40%, var(--bg-app) 100%);
}
/* Orbs */
.np-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.35;
pointer-events: none;
z-index: 0;
}
.np-orb-1 {
background: var(--ctp-mauve);
width: 560px; height: 560px;
top: -180px; left: -120px;
animation: orb-a 18s ease-in-out infinite;
}
.np-orb-2 {
background: var(--ctp-blue);
width: 460px; height: 460px;
bottom: -120px; right: -100px;
animation: orb-b 24s ease-in-out infinite;
animation-delay: -9s;
}
.np-orb-3 {
background: var(--ctp-lavender);
width: 380px; height: 380px;
top: 30%; right: 10%;
animation: orb-c 15s ease-in-out infinite;
animation-delay: -5s;
}
.np-hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 24px;
padding: 48px 56px 52px;
width: 100%;
box-sizing: border-box;
}
/* Cover */
.np-cover-wrap {
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.np-cover-glow {
position: absolute;
width: 220px;
height: 220px;
border-radius: 50%;
object-fit: cover;
filter: blur(40px) saturate(1.6) brightness(0.9);
opacity: 0.75;
transform: translateY(16px) scale(1.05);
pointer-events: none;
z-index: 0;
animation: np-glow-pulse 4s ease-in-out infinite;
}
@keyframes np-glow-pulse {
0%, 100% { opacity: 0.65; transform: translateY(18px) scale(1.05); filter: blur(38px) saturate(1.5) brightness(0.85); }
50% { opacity: 0.9; transform: translateY(12px) scale(1.12); filter: blur(50px) saturate(2.0) brightness(1.0); }
}
.np-cover {
position: relative;
z-index: 1;
width: 210px;
height: 210px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 24px 70px rgba(0,0,0,0.6);
animation: cover-breathe 6s ease-in-out infinite;
}
.np-cover-fallback {
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
color: var(--text-muted);
}
/* Info column */
.np-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: white;
max-width: 600px;
}
.np-title {
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
line-height: 1.2;
color: white;
}
.np-artist-album {
font-size: 15px;
color: rgba(255,255,255,0.75);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
align-items: center;
}
.np-link:hover { color: white; text-decoration: underline; }
.np-sep { opacity: 0.4; }
.np-tech-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.np-badge {
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.85);
letter-spacing: 0.04em;
}
.np-star-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
color: white;
transition: transform 0.15s;
}
.np-star-btn:hover { transform: scale(1.2); }
/* Progress */
.np-progress-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.np-waveform { flex: 1; }
.np-time {
font-size: 12px;
color: rgba(255,255,255,0.6);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 36px;
}
/* Controls */
.np-controls {
display: flex;
align-items: center;
gap: 16px;
margin-top: 4px;
}
.np-ctrl-btn {
background: none;
border: none;
cursor: pointer;
color: rgba(255,255,255,0.75);
display: flex;
align-items: center;
padding: 6px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
}
.np-ctrl-btn:hover { color: white; background: rgba(255,255,255,0.1); }
.np-ctrl-btn:disabled { opacity: 0.3; cursor: default; }
.np-ctrl-btn.np-ctrl-active { color: var(--accent); }
.np-ctrl-lg { color: rgba(255,255,255,0.9); }
.np-ctrl-play {
width: 60px;
height: 60px;
border-radius: 50%;
background: white;
color: var(--bg-app);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.np-ctrl-play:hover { transform: scale(1.07); box-shadow: 0 8px 30px rgba(0,0,0,0.4); }
/* Volume */
.np-volume-row {
display: flex;
align-items: center;
gap: 8px;
}
.np-volume-slider {
width: 120px;
accent-color: white;
opacity: 0.7;
cursor: pointer;
}
.np-volume-slider:hover { opacity: 1; }
.np-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: rgba(255,255,255,0.5);
padding: 40px 0;
}
/* Queue section */
.np-queue-section {
flex: 1;
padding: 144px 56px 40px;
display: flex;
flex-direction: column;
gap: 0;
}
.np-queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 4px;
}
.np-queue-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.np-queue-meta {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.np-queue-actions {
display: flex;
align-items: center;
gap: 4px;
}
.np-action-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 6px 8px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
transition: color 0.15s, background 0.15s;
}
.np-action-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
.np-action-btn:disabled { opacity: 0.3; cursor: default; }
/* Queue list */
.np-queue-list {
display: flex;
flex-direction: column;
}
.np-queue-item {
display: grid;
grid-template-columns: 36px 1fr auto 28px;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.12s;
}
.np-queue-item:hover,
.np-queue-item.context-active {
background: var(--bg-hover);
}
.np-queue-item.active {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.np-queue-num {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 24px;
}
.np-queue-item-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.np-queue-item-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.np-queue-item-active { color: var(--accent); }
.np-queue-item-artist {
font-size: 11px;
color: var(--text-muted);
}
.np-queue-item-duration {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.np-queue-remove {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 24px;
height: 24px;
opacity: 0;
transition: opacity 0.12s, color 0.12s, background 0.12s;
}
.np-queue-item:hover .np-queue-remove { opacity: 1; }
.np-queue-remove:hover { color: var(--ctp-red); background: var(--bg-hover); }
/* ─ Equalizer ─ */
.eq-wrap {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 660px;
}
.eq-controls-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.eq-toggle-label {
display: flex;
align-items: center;
gap: 0;
font-size: 13px;
color: var(--text-secondary);
}
.eq-preset-row {
display: flex;
align-items: center;
gap: 6px;
}
.eq-preset-select {
padding: 5px 10px;
font-size: 12px;
min-width: 140px;
}
.eq-ctrl-btn {
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.eq-ctrl-btn:hover { background: var(--accent); color: var(--bg-app); }
.eq-save-row {
display: flex;
align-items: center;
gap: 8px;
}
/* Main EQ panel — dark canvas area + faders */
.eq-panel {
background: #0d0d12;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
overflow: hidden;
transition: opacity 0.2s;
}
.eq-panel--off {
opacity: 0.4;
pointer-events: none;
}
.eq-canvas {
display: block;
width: 100%;
height: 120px;
}
.eq-faders {
display: flex;
align-items: stretch;
padding: 6px 8px 12px;
gap: 0;
height: 155px;
}
/* dB scale on left */
.eq-db-scale {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 6px 0 0;
width: 28px;
flex-shrink: 0;
}
.eq-db-tick {
font-size: 9px;
color: rgba(255,255,255,0.3);
font-family: monospace;
text-align: right;
line-height: 1;
}
/* Individual band column */
.eq-band {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.eq-gain-val {
font-size: 9px;
color: rgba(255,255,255,0.45);
font-family: monospace;
font-variant-numeric: tabular-nums;
line-height: 1;
height: 12px;
}
/* Fader track the rotated slider is absolute, so this only needs
enough height for the visual fader length. */
.eq-fader-track {
position: relative;
flex: 1;
min-height: 110px;
width: 100%;
}
/* Zero-dB center mark — horizontal line at 50% = 0 dB position */
.eq-zero-mark {
position: absolute;
left: 10%;
right: 10%;
height: 1px;
background: rgba(255,255,255,0.35);
pointer-events: none;
top: 50%;
transform: translateY(-50%);
}
/* Vertical fader via rotate(-90deg) + position:absolute.
Track is vertically centred in the element by WebKit; thumb defaults to element
centre too so translateX(-50%) + no margin-top keeps everything aligned. */
.eq-fader {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%) rotate(-90deg);
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 20px;
cursor: pointer;
background: transparent;
margin: 0;
padding: 0;
border: none;
outline: none;
}
.eq-fader::-webkit-slider-runnable-track {
height: 3px;
background: rgba(255,255,255,0.3);
border-radius: 2px;
}
.eq-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 20px;
background: var(--accent);
border-radius: 3px;
box-shadow: 0 2px 8px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
cursor: grab;
transition: background 0.12s;
}
.eq-fader::-webkit-slider-thumb:active { cursor: grabbing; }
.eq-fader:disabled::-webkit-slider-thumb {
background: rgba(255,255,255,0.15);
box-shadow: none;
}
.eq-freq-label {
font-size: 9px;
color: rgba(255,255,255,0.35);
white-space: nowrap;
}
+39
View File
@@ -134,6 +134,45 @@
opacity: 1;
}
/* Now Playing nav link */
.nav-link-nowplaying {
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.nav-link-nowplaying:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.nav-link-nowplaying.active {
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent);
}
.nav-link-nowplaying svg {
opacity: 1;
}
.nav-np-icon-wrap {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.nav-np-dot {
position: absolute;
top: -3px;
right: -4px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: np-dot-pulse 1.8s ease-in-out infinite;
}
@keyframes np-dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* Collapsed Sidebar Styles */
.sidebar.collapsed .sidebar-brand {
justify-content: center;