feat: v1.8.0 — new themes, queue toolbar, lightbox, i18n (fr/nl)

Themes:
- Add Poison (phosphor green on charcoal), Nucleo (warm brass light), Classic Winamp (LCD glow, Winamp yellow/orange) themes
- Overhaul Psychowave — refined deep violet, no longer WIP
- Reorganise ThemePicker into groups: Catppuccin, Nord, Retro, Tokyo Night, Psysonic Themes
- Add --volume-accent CSS var for per-theme volume slider colour override

Queue:
- Full toolbar redesign with centred round buttons (Shuffle/Save/Load/Clear/Gapless/Crossfade)
- Crossfade popover with 1–10 s range slider, right-aligned to avoid viewport overflow
- Queue header: title + count + duration inline in accent colour, close button removed
- Tech info (codec/bitrate) as frosted-glass overlay badge on cover art

UI:
- CoverLightbox shared component for album cover (AlbumHeader) and artist avatar (ArtistDetail)
- NowPlayingDropdown: separate spinning/loading state — button always clickable, icon spins 600 ms min
- Settings: "Experimental" badge on Crossfade and Gapless toggles
- Help page: 2-column grid layout, new Crossfade & Gapless Q&A entry, updated theme/language entries

i18n:
- Full French (fr) and Dutch (nl) translations across all namespaces
- Language selector sorted alphabetically (nl, en, fr, de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-21 01:05:16 +01:00
parent c9c68a0e57
commit 0b1ed8cc5a
32 changed files with 3378 additions and 504 deletions
+73 -40
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
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 CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
@@ -49,6 +50,8 @@ export default function ArtistDetail() {
const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue);
@@ -58,45 +61,18 @@ export default function ArtistDetail() {
useEffect(() => {
if (!id) return;
setLoading(true);
let ownAlbumIds: Set<string>;
setFeaturedAlbums([]);
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(() => []),
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
]);
}).then(([artistInfo, songsData, searchResults]) => {
}).then(([artistInfo, songsData]) => {
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);
@@ -104,6 +80,41 @@ export default function ArtistDetail() {
});
}, [id]);
// "Also Featured On" — loaded in background after main content renders
useEffect(() => {
if (!id || !artist) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
setFeaturedLoading(true);
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
.catch(() => ({ songs: [], albums: [], artists: [] }))
.then(searchResults => {
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()]);
setFeaturedLoading(false);
});
}, [artist?.id]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
setSimilarArtists([]);
@@ -213,16 +224,30 @@ export default function ArtistDetail() {
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
{lightboxOpen && (
<CoverLightbox
src={buildCoverArtUrl(coverId, 2000)}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="artist-detail-header">
<div className="artist-detail-avatar">
{coverId ? (
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<button
className="artist-detail-avatar-btn"
onClick={() => setLightboxOpen(true)}
aria-label={`${artist.name} Bild vergrößern`}
>
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</button>
) : (
<Users size={64} color="var(--text-muted)" />
)}
@@ -389,14 +414,22 @@ export default function ArtistDetail() {
)}
{/* Also Featured On */}
{featuredAlbums.length > 0 && (
{(featuredLoading || 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>
{featuredLoading ? (
<div className="album-grid-wrap">
{[...Array(4)].map((_, i) => (
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
))}
</div>
) : (
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}
</>
)}
</div>
+2 -1
View File
@@ -45,6 +45,7 @@ export default function Help() {
{ q: t('help.q22'), a: t('help.a22') },
{ q: t('help.q23'), a: t('help.a23') },
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
],
},
{
@@ -100,7 +101,7 @@ export default function Help() {
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<div className="settings-section-header">
+89 -96
View File
@@ -1,15 +1,16 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import {
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 { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmIsConfigured, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { pingWithCredentials } from '../api/subsonic';
@@ -83,9 +84,10 @@ export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
const navigate = useNavigate();
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>('playback');
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -287,7 +289,12 @@ export default function Settings() {
{/* Crossfade */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{t('settings.crossfade')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
@@ -318,7 +325,12 @@ export default function Settings() {
{/* Gapless */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{t('settings.gapless')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
@@ -329,80 +341,6 @@ export default function Settings() {
</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">
{auth.lastfmSessionKey ? (
/* ── Connected state ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
{lfmUserInfo && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => auth.disconnectLastfm()}
>
{t('settings.lfmDisconnect')}
</button>
</div>
<div className="settings-toggle-row">
<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>
) : lfmState === 'waiting' ? (
/* ── Waiting for browser auth — auto-polling ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{t('settings.lfmConnecting')}
</div>
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
{t('common.cancel')}
</button>
</div>
) : (
/* ── Not connected ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('settings.lfmConnectDesc')}
</p>
{lfmState === 'error' && (
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
)}
{lastfmIsConfigured() ? (
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
{t('settings.lfmConnect')}
</button>
) : (
<p style={{ fontSize: 12, color: 'var(--warning)' }}>
VITE_LASTFM_API_KEY / VITE_LASTFM_API_SECRET not set in build environment.
</p>
)}
</div>
)}
</div>
</section>
</>
)}
@@ -530,23 +468,7 @@ export default function Settings() {
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<CustomSelect
value={theme.theme}
onChange={v => theme.setTheme(v as any)}
options={[
{ value: 'mocha', label: 'Catppuccin Mocha' },
{ value: 'macchiato', label: 'Catppuccin Macchiato' },
{ value: 'frappe', label: 'Catppuccin Frappé' },
{ value: 'latte', label: 'Catppuccin Latte' },
{ value: 'nord', label: 'Nord · Polar Night' },
{ value: 'nord-snowstorm', label: 'Nord · Snowstorm' },
{ value: 'nord-frost', label: 'Nord · Frost' },
{ value: 'nord-aurora', label: 'Nord · Aurora' },
{ value: 'psychowave', label: 'Psychowave' },
]}
/>
</div>
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
@@ -561,7 +483,9 @@ export default function Settings() {
value={i18n.language}
onChange={v => i18n.changeLanguage(v)}
options={[
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
]}
/>
@@ -653,6 +577,75 @@ export default function Settings() {
)}
</section>
{/* Last.fm */}
<section className="settings-section">
<div className="settings-section-header">
<LastfmIcon size={18} />
<h2>{t('settings.lfmTitle')}</h2>
</div>
<div className="settings-card">
{auth.lastfmSessionKey ? (
/* ── Connected state ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
{lfmUserInfo && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => auth.disconnectLastfm()}
>
{t('settings.lfmDisconnect')}
</button>
</div>
<div className="settings-toggle-row">
<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>
) : lfmState === 'waiting' ? (
/* ── Waiting for browser auth — auto-polling ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{t('settings.lfmConnecting')}
</div>
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
{t('common.cancel')}
</button>
</div>
) : (
/* ── Not connected ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('settings.lfmConnectDesc')}
</p>
{lfmState === 'error' && (
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
)}
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
{t('settings.lfmConnect')}
</button>
</div>
)}
</div>
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">