feat: v1.9.0 — new themes, keybindings, font picker, home play behavior

Themes:
- Add Neon Drift (midnight blue / electric cyan synthwave)
- Add Cupertino Light + Cupertino Dark (macOS Ventura-inspired, frosted glass)
- Add Betriebssysteme group (Cupertino, Aero Glass, Luna Teal)
- Rename all trademarked theme IDs to original names (WnAmp, Navy Jukebox,
  Cobalt Media, Onyx Cinema, Aero Glass, Luna Teal)
- Reorder ThemePicker: Psysonic Themes first, then Mediaplayer, Betriebssysteme

Keybindings:
- New keybindingsStore with 10 bindable actions (play/pause, next, prev,
  volume, seek ±10s, queue, fullscreen, native fullscreen)
- Settings UI for rebinding; defaults: Space=play-pause, F11=native-fullscreen

Font picker:
- New fontStore; 10 UI fonts selectable in Settings → Appearance
- Applied via data-font attribute on <html>

Home page:
- AlbumCard: Details button → Play button via playAlbum() utility
- Hero: Play Album starts playback with 700ms fade-out instead of navigating
- playAlbum.ts: fade, store-only volume restore, playTrack handoff

Now Playing:
- 3-column hero layout; EQ bars truly centred (flex:1 on both outer columns)
- Background brightness 0.25→0.55, overlay 0.55→0.38 (art now visible)
- Better text contrast for track times, card links, section titles

Linux audio:
- Set PIPEWIRE_LATENCY + PULSE_LATENCY_MSEC before stream creation
  to reduce ALSA snd_pcm_recover underrun frequency on PipeWire

Remove butterchurn visualizer (VisualizerCanvas, butterchurn.d.ts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-21 14:22:02 +01:00
parent 0b1ed8cc5a
commit 57b70e6154
30 changed files with 2760 additions and 702 deletions
+4 -3
View File
@@ -4,6 +4,7 @@ import { Play } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -50,10 +51,10 @@ export default function AlbumCard({ album }: AlbumCardProps) {
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
aria-label={`Details zu ${album.name}`}
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
>
Details
<Play size={15} fill="currentColor" />
</button>
</div>
</div>
+4 -49
View File
@@ -1,13 +1,12 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
import VisualizerCanvas from './VisualizerCanvas';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -148,10 +147,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const [vizActive, setVizActive] = useState(false);
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
const [presetName, setPresetName] = useState('');
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -182,55 +177,15 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1 — blurred artist image OR visualizer */}
{vizActive && currentTrack ? (
<VisualizerCanvas
trackId={currentTrack.id}
nextPresetTrigger={nextPresetTrigger}
onPresetName={setPresetName}
/>
) : (
<>
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
</>
)}
{/* Layer 1 — blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Visualizer toggle + preset controls */}
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{vizActive && (
<>
{presetName && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{presetName}
</span>
)}
<button
className="fs-btn fs-btn-sm"
onClick={() => setNextPresetTrigger(n => n + 1)}
aria-label={t('player.nextPreset')}
data-tooltip={t('player.nextPreset')}
style={{ color: 'rgba(255,255,255,0.7)' }}
>
<Shuffle size={14} />
</button>
</>
)}
<button
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
onClick={() => setVizActive(v => !v)}
aria-label={t('player.visualizer')}
data-tooltip={t('player.visualizer')}
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
>
<AudioWaveform size={16} />
</button>
</div>
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
+2 -1
View File
@@ -5,6 +5,7 @@ import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, get
import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
const INTERVAL_MS = 10000;
@@ -124,7 +125,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
<button
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
+20 -7
View File
@@ -121,6 +121,7 @@ export default function QueuePanel() {
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
@@ -138,6 +139,7 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
@@ -284,14 +286,25 @@ export default function QueuePanel() {
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
const dur = h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
const remainingSecs = Math.max(0,
(queue[queueIndex]?.duration ?? 0) - currentTime
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
);
const fmt = (secs: number) => {
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')}`;
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
return (
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
<span
onClick={() => setShowRemainingTime(v => !v)}
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</span>
);
+28 -9
View File
@@ -9,6 +9,34 @@ interface ThemeDef {
}
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Psysonic Themes',
themes: [
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
],
},
{
group: 'Betriebssysteme',
themes: [
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'aero-glass', label: 'Aero Glass', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'luna-teal', label: 'Luna Teal', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Catppuccin',
themes: [
@@ -46,15 +74,6 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
],
},
{
group: 'Psysonic Themes',
themes: [
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
],
},
];
interface Props {
-130
View File
@@ -1,130 +0,0 @@
import { useEffect, useRef } from 'react';
import butterchurn from 'butterchurn';
import butterchurnPresets from 'butterchurn-presets';
import { buildStreamUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
interface Props {
trackId: string;
nextPresetTrigger: number;
onPresetName: (name: string) => void;
}
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rafRef = useRef<number>(0);
const presetNamesRef = useRef<string[]>([]);
const presetMapRef = useRef<Record<string, unknown>>({});
const presetIdxRef = useRef(0);
// ── Init audio analysis + butterchurn ──────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Hidden audio element — routed through Web Audio for analysis only.
// NOT connected to AudioDestinationNode → completely silent.
// The Rust/rodio engine plays the actual audio.
const streamUrl = buildStreamUrl(trackId);
const audio = new Audio(streamUrl);
audio.crossOrigin = 'anonymous';
audioRef.current = audio;
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const source = ctx.createMediaElementSource(audio);
// Intentionally no: source.connect(ctx.destination)
// Size canvas to fill its container
const rect = canvas.getBoundingClientRect();
const w = rect.width || window.innerWidth;
const h = rect.height || window.innerHeight;
canvas.width = w * (window.devicePixelRatio || 1);
canvas.height = h * (window.devicePixelRatio || 1);
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
width: canvas.width,
height: canvas.height,
pixelRatio: window.devicePixelRatio || 1,
});
vizRef.current = visualizer;
visualizer.connectAudio(source);
// Presets
const presets = butterchurnPresets.getPresets();
const names = Object.keys(presets);
presetNamesRef.current = names;
presetMapRef.current = presets;
const startIdx = Math.floor(Math.random() * names.length);
presetIdxRef.current = startIdx;
visualizer.loadPreset(presets[names[startIdx]], 2.0);
onPresetName(names[startIdx]);
// Sync position + play state with main player
const { currentTime, isPlaying } = usePlayerStore.getState();
audio.currentTime = currentTime;
if (isPlaying) audio.play().catch(() => {});
// Render loop
const render = () => {
rafRef.current = requestAnimationFrame(render);
visualizer.render();
};
rafRef.current = requestAnimationFrame(render);
// Keep canvas sized to window
const onResize = () => {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
visualizer.setRendererSize(canvas.width, canvas.height);
};
window.addEventListener('resize', onResize);
// Sync play/pause with main player
const unsubscribe = usePlayerStore.subscribe(state => {
const a = audioRef.current;
const c = audioCtxRef.current;
if (!a || !c) return;
if (state.isPlaying) {
c.resume();
a.play().catch(() => {});
} else {
a.pause();
}
});
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', onResize);
unsubscribe();
audio.pause();
audio.src = '';
ctx.close();
vizRef.current = null;
};
}, [trackId]); // re-init on track change
// ── Next preset ────────────────────────────────────────────────────────────
useEffect(() => {
if (!nextPresetTrigger) return;
const viz = vizRef.current;
const names = presetNamesRef.current;
if (!viz || names.length === 0) return;
const next = (presetIdxRef.current + 1) % names.length;
presetIdxRef.current = next;
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
onPresetName(names[next]);
}, [nextPresetTrigger]);
return (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
);
}