feat: Replay Gain, Crossfade, Download Folder Modal, Changelog in Settings (v1.6.0)

### Audio
- Replay Gain support (track + album mode, configurable pre-gain, hard limiter)
- Crossfade between tracks (configurable duration 1–10 s)
- Gapless preloading ⚠️ experimental/alpha — enable in Settings → Playback
- Atomic sink swap: old track plays until new one is decoded, then stops cleanly

### UI / UX
- Settings redesigned with tab navigation (General, Playback, About)
- Changelog viewer in Settings → About with collapsible version entries
- Download Folder Modal: choose folder + "remember" checkbox per-download
- EQ popup now accessible directly from the Player Bar
- "Also Featured On" section on Artist pages for non-album appearances

### Fixes
- Bundle identifier changed from dev.psysonic.app → dev.psysonic.player (fixes macOS warning)
- Version sync: all four version files (package.json, Cargo.toml, tauri.conf.json, PKGBUILD) now at 1.6.0

### Known Issues
- FLAC seeking via waveform seekbar does not work (MP3/OGG unaffected) — under investigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-19 20:23:17 +01:00
parent 7de4b97df0
commit 0e88e8a5cd
24 changed files with 2701 additions and 775 deletions
+2
View File
@@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import ConnectionIndicator from './components/ConnectionIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import { useConnectionStatus } from './hooks/useConnectionStatus';
@@ -189,6 +190,7 @@ function AppShell() {
<FullscreenPlayer onClose={toggleFullscreen} />
)}
<ContextMenu />
<DownloadFolderModal />
</div>
);
}
+7
View File
@@ -76,6 +76,13 @@ export interface SubsonicSong {
starred?: string;
genre?: string;
path?: string;
albumArtist?: string;
replayGain?: {
trackGain?: number;
albumGain?: number;
trackPeak?: number;
albumPeak?: number;
};
}
export interface SubsonicPlaylist {
+8 -15
View File
@@ -4,6 +4,7 @@ import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
@@ -21,6 +22,7 @@ export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
@@ -74,25 +76,16 @@ export default function ContextMenu() {
const downloadAlbum = async (albumName: string, albumId: string) => {
try {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${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 = `${sanitizeFilename(albumName)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
}
+56
View File
@@ -0,0 +1,56 @@
import { FolderOpen } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useAuthStore } from '../store/authStore';
export default function DownloadFolderModal() {
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const { t } = useTranslation();
const handleBrowse = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') });
if (selected && typeof selected === 'string') setFolder(selected);
};
if (!isOpen) return null;
return (
<>
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
<div className="eq-popup-header">
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
</div>
<div style={{ padding: '16px 0 12px' }}>
<div className="download-folder-pick-row">
<span className="download-folder-path">
{folder || t('common.noFolderSelected')}
</span>
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
<FolderOpen size={15} /> {t('settings.pickFolder')}
</button>
</div>
<label className="download-remember-row">
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
<span>{t('common.rememberDownloadFolder')}</span>
</label>
</div>
<div className="download-modal-actions">
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
<button
className="btn btn-primary"
onClick={() => confirm(setDownloadFolder)}
disabled={!folder}
>
{t('common.download')}
</button>
</div>
</div>
</>
);
}
+85 -27
View File
@@ -2,6 +2,7 @@ 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';
import { useThemeStore } from '../store/themeStore';
// ─── Frequency response canvas ────────────────────────────────────────────────
@@ -11,31 +12,31 @@ 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 A = Math.pow(10, gainDb / 40);
const alpha = Math.sin(w0) / (2 * EQ_Q);
const b0 = 1 + alpha * A;
const b0 = 1 + alpha * A;
const b1 = -2 * Math.cos(w0);
const b2 = 1 - alpha * A;
const a0 = 1 + alpha / A;
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 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 numIm = - b1 * sinW - b2 * sin2W;
const denRe = a0 + a1 * cosW + a2 * cos2W;
const denIm = - a1 * sinW - a2 * sin2W;
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) {
function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
canvas.width = W * dpr;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
@@ -52,7 +53,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
padT + ((dbMax - db) / (dbMax - dbMin)) * innerH;
// Background
ctx.fillStyle = '#0d0d12';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, W, H);
// Grid: dB lines
@@ -64,7 +65,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillStyle = textColor;
ctx.font = '9px monospace';
ctx.textAlign = 'right';
ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3);
@@ -123,13 +124,70 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri
ctx.stroke();
}
// ─── Custom vertical fader (no native range input) ────────────────────────────
const GAIN_MIN = -12, GAIN_MAX = 12;
interface FaderProps {
value: number;
disabled: boolean;
onChange: (v: number) => void;
}
function VerticalFader({ value, disabled, onChange }: FaderProps) {
const trackRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom
const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN);
const updateFromY = useCallback((clientY: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
dragging.current = true;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
updateFromY(e.clientY);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current || disabled) return;
updateFromY(e.clientY);
};
const onPointerUp = () => { dragging.current = false; };
const thumbPct = gainToPct(value) * 100;
return (
<div
ref={trackRef}
className="eq-fader-custom"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ cursor: disabled ? 'default' : 'pointer' }}
>
<div className="eq-track-line" />
<div className="eq-thumb" style={{ top: `${thumbPct}%`, opacity: disabled ? 0.3 : 1 }} />
</div>
);
}
// ─── 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 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();
@@ -137,13 +195,17 @@ export default function Equalizer() {
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useThemeStore(s => s.theme);
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]);
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)';
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
drawCurve(canvas, gains, accent, bg, text);
}, [gains, theme]);
useEffect(() => { redraw(); }, [redraw]);
@@ -153,8 +215,8 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
const handleSave = () => {
@@ -245,14 +307,10 @@ export default function Equalizer() {
</span>
<div className="eq-fader-track">
<div className="eq-zero-mark" />
<input
type="range"
className="eq-fader"
min={-12} max={12} step={0.5}
<VerticalFader
value={gains[i]}
onChange={e => setBandGain(i, parseFloat(e.target.value))}
disabled={!enabled}
aria-label={`${band.label} Hz`}
onChange={v => setBandGain(i, v)}
/>
</div>
<span className="eq-freq-label">{band.label}</span>
+30 -2
View File
@@ -1,12 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -20,6 +21,7 @@ function formatTime(seconds: number): string {
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
@@ -125,6 +127,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
onClick={() => setEqOpen(v => !v)}
aria-label="Equalizer"
data-tooltip="Equalizer"
>
<SlidersHorizontal size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
@@ -149,6 +161,22 @@ export default function PlayerBar() {
/>
</div>
{/* EQ Popup */}
{eqOpen && (
<>
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
<div className="eq-popup">
<div className="eq-popup-header">
<span className="eq-popup-title">Equalizer</span>
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
<X size={16} />
</button>
</div>
<Equalizer />
</div>
</>
)}
</footer>
);
}
+46
View File
@@ -123,6 +123,7 @@ const enTranslation = {
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
openedInBrowser: 'Opened in browser',
featuredOn: 'Also Featured On',
},
favorites: {
title: 'Favorites',
@@ -241,6 +242,10 @@ const enTranslation = {
use: 'Use',
add: 'Add',
active: 'Active',
download: 'Download',
chooseDownloadFolder: 'Choose download folder',
noFolderSelected: 'No folder selected',
rememberDownloadFolder: 'Remember this folder',
},
settings: {
title: 'Settings',
@@ -294,6 +299,7 @@ const enTranslation = {
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
pickFolderTitle: 'Select Download Folder',
clearFolder: 'Clear download folder',
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
@@ -304,6 +310,7 @@ const enTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
changelog: 'Changelog',
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
@@ -311,6 +318,22 @@ const enTranslation = {
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom keywords added yet.',
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
tabPlayback: 'Playback',
tabLibrary: 'Library',
tabAppearance: 'Appearance',
tabServer: 'Server',
tabAbout: 'About',
playbackTitle: 'Playback',
replayGain: 'Replay Gain',
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
replayGainMode: 'Mode',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}}s',
gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
},
help: {
title: 'Help',
@@ -564,6 +587,7 @@ const deTranslation = {
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
openedInBrowser: 'Im Browser geöffnet',
featuredOn: 'Auch enthalten auf',
},
favorites: {
title: 'Favoriten',
@@ -682,6 +706,10 @@ const deTranslation = {
use: 'Verwenden',
add: 'Hinzufügen',
active: 'Aktiv',
download: 'Herunterladen',
chooseDownloadFolder: 'Download-Ordner wählen',
noFolderSelected: 'Kein Ordner ausgewählt',
rememberDownloadFolder: 'Ordner merken',
},
settings: {
title: 'Einstellungen',
@@ -735,6 +763,7 @@ const deTranslation = {
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
pickFolderTitle: 'Download-Ordner auswählen',
clearFolder: 'Download-Ordner löschen',
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
@@ -745,6 +774,7 @@ const deTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
changelog: 'Changelog',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
@@ -752,6 +782,22 @@ const deTranslation = {
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
tabPlayback: 'Wiedergabe',
tabLibrary: 'Bibliothek',
tabAppearance: 'Darstellung',
tabServer: 'Server',
tabAbout: 'Info',
playbackTitle: 'Wiedergabe',
replayGain: 'Replay Gain',
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
replayGainMode: 'Modus',
replayGainTrack: 'Track',
replayGainAlbum: 'Album',
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}}s',
gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
},
help: {
title: 'Hilfe',
+10 -14
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
@@ -24,6 +25,7 @@ export default function AlbumDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -106,6 +108,11 @@ export default function AlbumDetail() {
const handleDownload = async () => {
if (!album) return;
const { name, id: albumId } = album.album;
// Ask for folder before starting download if not already set
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
setDownloadProgress(0);
try {
const url = buildDownloadUrl(albumId);
@@ -133,20 +140,9 @@ export default function AlbumDetail() {
}
const blob = new Blob(chunks);
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${sanitizeFilename(name)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
setDownloadProgress(null);
+44 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
@@ -45,6 +45,7 @@ export default function ArtistDetail() {
const navigate = useNavigate();
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
const [loading, setLoading] = useState(true);
@@ -60,17 +61,45 @@ export default function ArtistDetail() {
useEffect(() => {
if (!id) return;
setLoading(true);
let ownAlbumIds: Set<string>;
getArtist(id).then(artistData => {
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => [])
getTopSongs(artistData.artist.name).catch(() => []),
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
]);
}).then(([artistInfo, songsData]) => {
}).then(([artistInfo, songsData, searchResults]) => {
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
const featuredSongs = (searchResults.songs ?? []).filter(
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
);
const albumMap = new Map<string, SubsonicAlbum>();
featuredSongs.forEach(song => {
if (!albumMap.has(song.albumId)) {
albumMap.set(song.albumId, {
id: song.albumId,
name: song.album,
artist: song.albumArtist ?? '',
artistId: '',
coverArt: song.coverArt,
songCount: 1,
duration: song.duration,
year: song.year,
});
} else {
const a = albumMap.get(song.albumId)!;
a.songCount++;
a.duration += song.duration;
}
});
setFeaturedAlbums([...albumMap.values()]);
setLoading(false);
}).catch(err => {
console.error(err);
@@ -308,6 +337,18 @@ export default function ArtistDetail() {
) : (
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
)}
{/* Also Featured On */}
{featuredAlbums.length > 0 && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.featuredOn')}
</h2>
<div className="album-grid-wrap">
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
</>
)}
</div>
);
}
+581 -361
View File
@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
} from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { useAuthStore, ServerProfile } from '../store/authStore';
@@ -14,6 +16,8 @@ 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'];
type Tab = 'playback' | 'library' | 'appearance' | 'server' | 'about';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
@@ -78,6 +82,7 @@ export default function Settings() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>('playback');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -145,378 +150,593 @@ export default function Settings() {
}
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
];
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('settings.title')}</h1>
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('settings.title')}</h1>
{/* Language */}
<section className="settings-section">
<div className="settings-section-header">
<Globe size={18} />
<h2>{t('settings.language')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
aria-label={t('settings.language')}
>
<option value="en">{t('settings.languageEn')}</option>
<option value="de">{t('settings.languageDe')}</option>
</select>
</div>
</div>
</section>
{/* Tab navigation */}
<nav className="settings-tabs" aria-label="Settings navigation">
{tabs.map(tab => (
<button
key={tab.id}
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
{/* Theme */}
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={theme.theme}
onChange={(e) => theme.setTheme(e.target.value as any)}
aria-label={t('settings.theme')}
>
<option value="mocha">Catppuccin Mocha</option>
<option value="macchiato">Catppuccin Macchiato</option>
<option value="frappe">Catppuccin Frappé</option>
<option value="latte">Catppuccin Latte</option>
<option value="nord">Nord · Polar Night</option>
<option value="nord-snowstorm">Nord · Snowstorm</option>
<option value="nord-frost">Nord · Frost</option>
<option value="nord-aurora">Nord · Aurora</option>
</select>
</div>
</div>
</section>
{/* ── Playback ─────────────────────────────────────────────────────────── */}
{activeTab === 'playback' && (
<>
{/* 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>
{/* Servers */}
<section className="settings-section">
<div className="settings-section-header">
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
{/* Replay Gain + Crossfade + Gapless */}
<section className="settings-section">
<div className="settings-section-header">
<Music2 size={18} />
<h2>{t('settings.playbackTitle')}</h2>
</div>
<div className="settings-card">
{/* Replay Gain */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.replayGainEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
<button
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setReplayGainMode('track')}
>
{t('settings.replayGainTrack')}
</button>
<button
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setReplayGainMode('album')}
>
{t('settings.replayGainAlbum')}
</button>
</div>
)}
{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 className="divider" />
{/* Crossfade */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.crossfadeEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={1}
max={10}
step={0.5}
value={auth.crossfadeSecs}
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
style={{ width: 120 }}
id="crossfade-secs-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
</span>
</div>
)}
<div className="divider" />
{/* Gapless */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
{/* Scrobbling */}
<section className="settings-section">
<div className="settings-section-header">
<Music2 size={18} />
<h2>{t('settings.lfmTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
<p style={{ marginBottom: '0.5rem' }}>
{t('settings.lfmDesc1')}{' '}
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
{' '}{t('settings.lfmDesc1b')}
</p>
<p>{t('settings.lfmDesc2')}</p>
</div>
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
<div>
<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={t('settings.scrobbleEnabled')}>
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
</>
)}
{/* ── Library ──────────────────────────────────────────────────────────── */}
{activeTab === 'library' && (
<>
{/* Cache */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
</div>
<input
type="range"
min={100}
max={2000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
style={{ width: 120 }}
id="cache-size-slider"
/>
</div>
</div>
</section>
{/* Random Mix */}
<section className="settings-section">
<div className="settings-section-header">
<Shuffle size={18} />
<h2>{t('settings.randomMixTitle')}</h2>
</div>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.randomMixBlacklistDesc')}
</p>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
{auth.customGenreBlacklist.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
) : (
auth.customGenreBlacklist.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
padding: '2px 8px', fontSize: 12, fontWeight: 500,
}}>
{genre}
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
aria-label={`Remove ${genre}`}
>×</button>
</span>
))
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
<input
className="input"
type="text"
value={newGenre}
onChange={e => setNewGenre(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newGenre.trim()) {
const trimmed = newGenre.trim();
if (!auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}
}}
placeholder={t('settings.randomMixBlacklistPlaceholder')}
style={{ fontSize: 13 }}
/>
<button
className="btn btn-ghost"
onClick={() => {
const trimmed = newGenre.trim();
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}}
disabled={!newGenre.trim()}
>
{t('settings.randomMixBlacklistAdd')}
</button>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
}}>
{genre}
</span>
))}
</div>
</div>
</section>
</>
)}
{/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === 'appearance' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={theme.theme}
onChange={(e) => theme.setTheme(e.target.value as any)}
aria-label={t('settings.theme')}
>
<option value="mocha">Catppuccin Mocha</option>
<option value="macchiato">Catppuccin Macchiato</option>
<option value="frappe">Catppuccin Frappé</option>
<option value="latte">Catppuccin Latte</option>
<option value="nord">Nord · Polar Night</option>
<option value="nord-snowstorm">Nord · Snowstorm</option>
<option value="nord-frost">Nord · Frost</option>
<option value="nord-aurora">Nord · Aurora</option>
</select>
</div>
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Globe size={18} />
<h2>{t('settings.language')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<select
className="input"
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
aria-label={t('settings.language')}
>
<option value="en">{t('settings.languageEn')}</option>
<option value="de">{t('settings.languageDe')}</option>
</select>
</div>
</div>
</section>
</>
)}
{/* ── Server ───────────────────────────────────────────────────────────── */}
{activeTab === 'server' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
{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 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>
)}
{showAddForm ? (
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
) : (
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<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={t('settings.trayTitle')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
<span className="toggle-track" />
</label>
</div>
<div className="divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
{auth.downloadFolder || t('settings.downloadsDefault')}
</div>
</div>
);
})}
</div>
)}
{showAddForm ? (
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
) : (
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
</section>
{/* 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">
<Music2 size={18} />
<h2>{t('settings.lfmTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
<p style={{ marginBottom: '0.5rem' }}>
{t('settings.lfmDesc1')}{' '}
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
{' '}{t('settings.lfmDesc1b')}
</p>
<p>{t('settings.lfmDesc2')}</p>
</div>
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
<div>
<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={t('settings.scrobbleEnabled')}>
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
{/* App Behavior */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<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={t('settings.trayTitle')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} id="tray-toggle" />
<span className="toggle-track" />
</label>
</div>
<div className="divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
</div>
<input
type="range"
min={100}
max={2000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
style={{ width: 120 }}
id="cache-size-slider"
/>
</div>
<div className="divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
{auth.downloadFolder || t('settings.downloadsDefault')}
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{auth.downloadFolder && (
<button
className="btn btn-ghost"
onClick={() => auth.setDownloadFolder('')}
aria-label={t('settings.clearFolder')}
data-tooltip={t('settings.clearFolder')}
style={{ color: 'var(--text-muted)' }}
>
<X size={16} />
</button>
)}
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn">
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</div>
</div>
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn" style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.pickFolder')}
</section>
</>
)}
{/* ── About ────────────────────────────────────────────────────────────── */}
{activeTab === 'about' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
<h2>{t('settings.aboutTitle')}</h2>
</div>
<div className="settings-card settings-about">
<div className="settings-about-header">
<img src="/logo-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
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} {appVersion}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
{t('settings.aboutFeatures')}
</p>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
</div>
<button
className="btn btn-ghost"
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
>
<ExternalLink size={14} />
{t('settings.aboutRepo')}
</button>
</div>
</section>
<ChangelogSection />
<section className="settings-section">
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
<LogOut size={16} /> {t('settings.logout')}
</button>
</div>
</div>
</section>
{/* Random Mix */}
<section className="settings-section">
<div className="settings-section-header">
<Shuffle size={18} />
<h2>{t('settings.randomMixTitle')}</h2>
</div>
<div className="settings-card">
{/* Description */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.randomMixBlacklistDesc')}
</p>
{/* Custom blacklist chips */}
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
{auth.customGenreBlacklist.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
) : (
auth.customGenreBlacklist.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
padding: '2px 8px', fontSize: 12, fontWeight: 500,
}}>
{genre}
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
aria-label={`Remove ${genre}`}
>×</button>
</span>
))
)}
</div>
{/* Add input */}
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
<input
className="input"
type="text"
value={newGenre}
onChange={e => setNewGenre(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newGenre.trim()) {
const trimmed = newGenre.trim();
if (!auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}
}}
placeholder={t('settings.randomMixBlacklistPlaceholder')}
style={{ fontSize: 13 }}
/>
<button
className="btn btn-ghost"
onClick={() => {
const trimmed = newGenre.trim();
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}}
disabled={!newGenre.trim()}
>
{t('settings.randomMixBlacklistAdd')}
</button>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
{/* Hardcoded list */}
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
}}>
{genre}
</span>
))}
</div>
</div>
</section>
{/* Logout */}
<section className="settings-section">
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
<LogOut size={16} /> {t('settings.logout')}
</button>
</section>
{/* About */}
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
<h2>{t('settings.aboutTitle')}</h2>
</div>
<div className="settings-card settings-about">
<div className="settings-about-header">
<img src="/logo-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
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} {appVersion}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
{t('settings.aboutFeatures')}
</p>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
</div>
<button
className="btn btn-ghost"
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
>
<ExternalLink size={14} />
{t('settings.aboutRepo')}
</button>
</div>
</section>
</section>
</>
)}
</div>
);
}
// ─── Changelog renderer ───────────────────────────────────────────────────────
function renderInline(text: string): React.ReactNode[] {
// Splits on **bold**, *italic*, `code` and renders each part.
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
function ChangelogSection() {
const { t } = useTranslation();
const versions = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## ['));
return blocks.map(block => {
const lines = block.split('\n');
const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18"
const versionMatch = headerLine.match(/## \[([^\]]+)\]/);
const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/);
const version = versionMatch?.[1] ?? '';
const date = dateMatch?.[1] ?? '';
// Parse the rest into rendered lines
const body = lines.slice(1).join('\n').trim();
return { version, date, body };
});
}, []);
return (
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
<h2>{t('settings.changelog')}</h2>
</div>
<div className="changelog-list">
{versions.map(({ version, date, body }) => (
<details key={version} className="changelog-entry" open={version === appVersion}>
<summary className="changelog-summary">
<span className="changelog-version">v{version}</span>
<span className="changelog-date">{date}</span>
</summary>
<div className="changelog-body">
{body.split('\n').map((line, i) => {
if (line.startsWith('### ')) {
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
}
if (line.startsWith('#### ')) {
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
}
if (line.startsWith('- ')) {
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
}
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
</details>
))}
</div>
</section>
);
}
+20
View File
@@ -27,6 +27,11 @@ interface AuthState {
downloadFolder: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
replayGainMode: 'track' | 'album';
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
// Status
isLoggedIn: boolean;
@@ -48,6 +53,11 @@ interface AuthState {
setDownloadFolder: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setReplayGainMode: (v: 'track' | 'album') => void;
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
logout: () => void;
// Derived
@@ -74,6 +84,11 @@ export const useAuthStore = create<AuthState>()(
downloadFolder: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
replayGainMode: 'track',
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -117,6 +132,11 @@ export const useAuthStore = create<AuthState>()(
setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => set({ replayGainEnabled: v }),
setReplayGainMode: (v) => set({ replayGainMode: v }),
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
logout: () => set({ isLoggedIn: false }),
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand';
// Module-level callback — not in Zustand state to avoid serialization issues
let _resolve: ((folder: string | null) => void) | null = null;
interface DownloadModalStore {
isOpen: boolean;
folder: string;
remember: boolean;
requestFolder: () => Promise<string | null>;
setFolder: (f: string) => void;
setRemember: (r: boolean) => void;
confirm: (setDownloadFolder: (v: string) => void) => void;
cancel: () => void;
}
export const useDownloadModalStore = create<DownloadModalStore>((set, get) => ({
isOpen: false,
folder: '',
remember: false,
requestFolder: () =>
new Promise<string | null>(resolve => {
_resolve = resolve;
set({ isOpen: true, folder: '', remember: false });
}),
setFolder: (folder) => set({ folder }),
setRemember: (remember) => set({ remember }),
confirm: (setDownloadFolder) => {
const { folder, remember } = get();
if (!folder) return;
if (remember) setDownloadFolder(folder);
_resolve?.(folder);
_resolve = null;
set({ isOpen: false });
},
cancel: () => {
_resolve?.(null);
_resolve = null;
set({ isOpen: false });
},
}));
+65
View File
@@ -19,6 +19,30 @@ export interface Track {
bitRate?: number;
suffix?: string;
userRating?: number;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
}
export function songToTrack(song: SubsonicSong): Track {
return {
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
duration: song.duration,
coverArt: song.coverArt,
track: song.track,
year: song.year,
bitRate: song.bitRate,
suffix: song.suffix,
userRating: song.userRating,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
};
}
interface PlayerState {
@@ -125,6 +149,20 @@ function handleAudioProgress(current_time: number, duration: number) {
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
// Gapless preload: buffer next track when 30s remain
const { gaplessEnabled } = useAuthStore.getState();
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id);
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
}
}
}
function handleAudioEnded() {
@@ -167,7 +205,20 @@ export function initAudioListeners(): () => void {
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
];
// Initial sync of crossfade settings to Rust audio engine on startup.
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
// Keep crossfade settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
});
return () => {
unsubAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -238,10 +289,17 @@ export const usePlayerStore = create<PlayerState>()(
});
const url = buildStreamUrl(track.id);
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play failed:', err);
@@ -277,10 +335,17 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
const vol = get().volume;
set({ isPlaying: true });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = authStateCold.replayGainEnabled
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
invoke('audio_play', {
url: buildStreamUrl(currentTrack.id),
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
+1415 -312
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -489,6 +489,74 @@
min-width: 0;
}
.player-eq-btn {
color: var(--text-muted);
flex-shrink: 0;
transition: color 0.15s;
}
.player-eq-btn:hover,
.player-eq-btn.active {
color: var(--accent);
}
/* ─── EQ Popup ─── */
.eq-popup-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
}
.eq-popup {
position: fixed;
z-index: 201;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(700px, 92vw);
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.eq-popup .eq-wrap {
max-width: none;
}
.eq-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.eq-popup-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.eq-popup-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg-hover);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.eq-popup-close:hover {
background: var(--accent);
color: var(--bg-app);
}
/* ─── Queue Panel ─── */
.queue-panel {
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />