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
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend';
interface FontState {
font: FontId;
setFont: (font: FontId) => void;
}
export const useFontStore = create<FontState>()(
persist(
(set) => ({
font: 'inter',
setFont: (font) => set({ font }),
}),
{ name: 'psysonic_font' }
)
);
+80
View File
@@ -0,0 +1,80 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type KeyAction =
| 'play-pause'
| 'next'
| 'prev'
| 'volume-up'
| 'volume-down'
| 'seek-forward'
| 'seek-backward'
| 'toggle-queue'
| 'fullscreen-player'
| 'native-fullscreen';
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
export type Bindings = Record<KeyAction, string | null>;
export const DEFAULT_BINDINGS: Bindings = {
'play-pause': 'Space',
'next': null,
'prev': null,
'volume-up': null,
'volume-down': null,
'seek-forward': null,
'seek-backward': null,
'toggle-queue': null,
'fullscreen-player': null,
'native-fullscreen': 'F11',
};
interface KeybindingsState {
bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void;
resetToDefaults: () => void;
}
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set) => ({
bindings: { ...DEFAULT_BINDINGS },
setBinding: (action, code) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}),
{ name: 'psysonic_keybindings' }
)
);
/** Format an e.code value into a human-readable label. */
export function formatKeyCode(code: string): string {
if (code === 'Space') return 'Space';
if (code === 'ArrowUp') return '↑';
if (code === 'ArrowDown') return '↓';
if (code === 'ArrowLeft') return '←';
if (code === 'ArrowRight') return '→';
if (code === 'Escape') return 'Esc';
if (code === 'Enter') return 'Enter';
if (code === 'Backspace') return '⌫';
if (code === 'Tab') return 'Tab';
if (code === 'Delete') return 'Del';
if (code === 'Home') return 'Home';
if (code === 'End') return 'End';
if (code === 'PageUp') return 'PgUp';
if (code === 'PageDown') return 'PgDn';
if (/^F\d+$/.test(code)) return code;
if (code.startsWith('Key')) return code.slice(3).toUpperCase();
if (code.startsWith('Digit')) return code.slice(5);
if (code === 'Minus') return '-';
if (code === 'Equal') return '=';
if (code === 'BracketLeft') return '[';
if (code === 'BracketRight') return ']';
if (code === 'Semicolon') return ';';
if (code === 'Quote') return "'";
if (code === 'Backslash') return '\\';
if (code === 'Comma') return ',';
if (code === 'Period') return '.';
if (code === 'Slash') return '/';
return code;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
interface ThemeState {
theme: Theme;