mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user