mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -34,3 +34,9 @@ src-tauri/target/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
@@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.9.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Three New Themes
|
||||
- **Neon Drift**: Deep midnight-blue background (`#12132c`) with electric cyan accent (`#00f2ff`) — subtle synthwave/cyberpunk aesthetic. Glowing player track name, cyan-glow nav active state, neon-lit primary buttons, glowing range slider thumb.
|
||||
- **Cupertino Light**: macOS Ventura-inspired light theme. Clean white base, Apple-grey sidebar (`#f2f2f7`), Apple blue accent (`#0071e3`). Frosted-glass sidebar and player bar with `backdrop-filter: blur`. Solid blue pill nav active (white text, no left border).
|
||||
- **Cupertino Dark**: macOS Ventura-inspired dark theme. Space Grey base (`#1e1e1f`), dark frosted sidebar, vibrant blue accent (`#007aff`). Same pill nav active as Cupertino Light. Solid blue Play/Pause button with glow.
|
||||
|
||||
#### New Theme Group: Betriebssysteme
|
||||
- OS-aesthetic themes are now consolidated into one group: **Cupertino Light**, **Cupertino Dark**, **Aero Glass**, **Luna Teal**.
|
||||
- **Psysonic Themes** and **Psysonic Themes — Mediaplayer** moved to the top of the theme picker.
|
||||
|
||||
#### Configurable Keybindings
|
||||
- New `keybindingsStore` with 10 bindable actions: Play/Pause, Next, Previous, Volume Up/Down, Seek ±10 s, Toggle Queue, Fullscreen Player, Native Fullscreen.
|
||||
- Rebind any action in **Settings → Keybindings** — click the key badge, press any key, saved immediately to `localStorage`.
|
||||
- Defaults: `Space` = Play/Pause, `F11` = Native Fullscreen. All other actions unbound by default.
|
||||
|
||||
#### Font Picker
|
||||
- 10 UI fonts selectable in **Settings → Appearance**: Inter, Outfit, DM Sans, Nunito, Rubik, Space Grotesk, Figtree, Manrope, Plus Jakarta Sans, Lexend.
|
||||
- Persisted in `localStorage` (`psysonic_font`), applied via `data-font` attribute on `<html>`.
|
||||
|
||||
#### Home Page — Instant Play
|
||||
- **Album cards**: "Details" button replaced with a **Play** button — clicking plays the album immediately with a smooth 700 ms fade-out of the current track.
|
||||
- **Hero**: "Play Album" button now starts playback directly (with fade-out) instead of navigating to the album detail page.
|
||||
- Fade-out implemented via `playAlbum.ts` utility: fades volume to 0 over 700 ms, restores volume in the store (no Rust side-effect) before handing off to `playTrack`.
|
||||
|
||||
#### Now Playing Page — Layout & Readability
|
||||
- **3-column hero layout**: album cover + info (left, `flex: 1`) — EQ bars (centre, fixed width) — tag cloud (right, `flex: 1`). EQ bars are now truly centred regardless of content length on either side.
|
||||
- **Background**: increased brightness from `0.25` to `0.55`, reduced overlay opacity from `0.55` to `0.38` — background art is now visible instead of near-black.
|
||||
- **Text contrast**: track times, card links (artist/album), and section title opacity all increased for better readability on the blurred background.
|
||||
|
||||
### Changed
|
||||
|
||||
#### Theme Renames — Trademark-Safe Names
|
||||
All media-player and OS-themed theme IDs and labels have been renamed to avoid potential trademark conflicts:
|
||||
|
||||
| Old Name | New Name |
|
||||
|---|---|
|
||||
| Classic Winamp | WnAmp |
|
||||
| Musicmatch Jukebox | Navy Jukebox |
|
||||
| WMP8 Classic | Cobalt Media |
|
||||
| PowerDVD Classic | Onyx Cinema |
|
||||
| Win7 Aero | Aero Glass |
|
||||
| WinXP Luna | Luna Teal |
|
||||
|
||||
> **Note**: If you had one of these themes selected, your preference will reset to Mocha on first launch. Re-select your preferred theme in Settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linux — ALSA underruns**: `PIPEWIRE_LATENCY` (`4096/48000` ≈ 85 ms) and `PULSE_LATENCY_MSEC` (`85`) are now set before audio stream creation, reducing the frequency of ALSA `snd_pcm_recover` underrun events on PipeWire systems. Existing user-set values are respected.
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -50,7 +50,10 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
|
||||
| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `<html>` |
|
||||
| `src/store/themeStore.ts` | Theme selection (30 themes), applied as `data-theme` on `<html>` |
|
||||
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
|
||||
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
|
||||
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
|
||||
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
|
||||
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `<TooltipPortal />` mount |
|
||||
| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
|
||||
@@ -107,12 +110,25 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
|
||||
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||
|
||||
### Themes
|
||||
21 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
30 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
||||
|
||||
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by Classic Winamp for orange volume, yellow accent elsewhere).
|
||||
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
|
||||
|
||||
| Theme | Group | Style | Accent |
|
||||
|---|---|---|---|
|
||||
| `poison` | Psysonic Themes | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
|
||||
| `nucleo` | Psysonic Themes | warm brass/cream light | Brass `#7a5218` |
|
||||
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
|
||||
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
|
||||
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
|
||||
| `wnamp` | Psysonic — Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
|
||||
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
||||
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
||||
| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` |
|
||||
| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
||||
| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` |
|
||||
| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` |
|
||||
| `mocha` | Catppuccin | dark | Mauve |
|
||||
| `macchiato` | Catppuccin | medium-dark | Mauve |
|
||||
| `frappe` | Catppuccin | medium | Mauve |
|
||||
@@ -127,13 +143,9 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
|
||||
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
|
||||
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
|
||||
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
|
||||
| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` |
|
||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` |
|
||||
| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` |
|
||||
| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
|
||||
| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
|
||||
| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` |
|
||||
| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` |
|
||||
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
|
||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
|
||||
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
|
||||
|
||||
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
|
||||
|
||||
@@ -210,4 +222,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
|
||||
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
|
||||
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
|
||||
- **Version**: 1.8.0
|
||||
- **Version**: 1.9.0
|
||||
|
||||
@@ -22,7 +22,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — with smooth glassmorphism effects and micro-animations.
|
||||
- 🎨 **Gorgeous UI**: 30 deeply integrated themes across 7 groups — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme (Cupertino, Aero Glass, Luna Teal), and Psysonic originals (WnAmp, Poison, Nucleo, Neon Drift…) — with smooth glassmorphism effects and micro-animations.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
@@ -31,8 +31,9 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 🌊 **MilkDrop Visualizer**: Full-screen Butterchurn/MilkDrop visualizer in the Ambient Stage with hundreds of presets and smooth transitions.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
|
||||
@@ -45,7 +46,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback *(experimental)*
|
||||
- [x] MilkDrop/Butterchurn visualizer
|
||||
- [x] Waveform seekbar
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
@@ -53,9 +53,11 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with keyword filter & Super Genre mix
|
||||
- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
|
||||
- [x] 30 themes across 7 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme, Psysonic originals, Psysonic Mediaplayer
|
||||
- [x] Internationalization (English, German, French, Dutch)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
|
||||
### 🚧 In Progress
|
||||
- [ ] **Last.fm integration** — stabilising, moving out of beta
|
||||
|
||||
Generated
+2
-61
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.4.5",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
@@ -17,8 +17,6 @@
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -1667,16 +1665,6 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1724,27 +1712,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn/-/butterchurn-2.6.7.tgz",
|
||||
"integrity": "sha512-BJiRA8L0L2+84uoG2SSfkp0kclBuN+vQKf217pK7pMlwEO2ZEg3MtO2/o+l8Qpr8Nbejg8tmL1ZHD1jmhiaaqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/butterchurn-presets": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz",
|
||||
"integrity": "sha512-4MdM8ripz/VfH1BCldrIKdAc/1ryJFBDvqlyow6Ivo1frwj0H3duzvSMFC7/wIjAjxb1QpwVHVqGqS9uAFKhpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"ecma-proposal-math-extensions": "0.0.2",
|
||||
"lodash": "^4.17.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1807,14 +1774,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
@@ -1872,12 +1831,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecma-proposal-math-extensions": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ecma-proposal-math-extensions/-/ecma-proposal-math-extensions-0.0.2.tgz",
|
||||
"integrity": "sha512-80BnDp2Fn7RxXlEr5HHZblniY4aQ97MOAicdWWpSo0vkQiISSE9wLR4SqxKsu4gCtXFBIPPzy8JMhay4NWRg/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
@@ -2236,12 +2189,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2496,12 +2443,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -20,8 +20,6 @@
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.7.7",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: stelle <stelle@psychotoxical.dev>
|
||||
pkgname=psysonic
|
||||
pkgver=1.8.0
|
||||
pkgver=1.9.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+1
-1
@@ -3139,7 +3139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"md5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
+14
-1
@@ -389,7 +389,7 @@ pub(crate) struct PreloadedTrack {
|
||||
|
||||
/// Info about the track that has been appended (chained) to the current Sink
|
||||
/// but whose source has not yet started playing (gapless mode only).
|
||||
struct ChainedInfo {
|
||||
pub(crate) struct ChainedInfo {
|
||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||
url: String,
|
||||
duration_secs: f64,
|
||||
@@ -446,6 +446,19 @@ impl AudioCurrent {
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
|
||||
// Request a larger audio buffer from PipeWire/PulseAudio to reduce ALSA underruns.
|
||||
// Only set if the user hasn't already configured these themselves.
|
||||
// PIPEWIRE_LATENCY: 4096 frames / 48000 Hz ≈ 85 ms — enough to absorb scheduler jitter.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
||||
}
|
||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
||||
}
|
||||
}
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
.spawn(move || match rodio::OutputStream::try_default() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+42
-10
@@ -38,7 +38,9 @@ import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore } from './store/keybindingsStore';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -207,24 +209,49 @@ function TauriEventBridge() {
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const { minimizeToTray } = useAuthStore();
|
||||
|
||||
// Spacebar → play/pause, F11 → window fullscreen
|
||||
// Configurable keybindings
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code === 'F11') {
|
||||
e.preventDefault();
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
if (e.code !== 'Space') return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
|
||||
const { bindings } = useKeybindingsStore.getState();
|
||||
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
|
||||
|
||||
const action = (Object.entries(bindings) as [string, string | null][])
|
||||
.find(([, code]) => code === e.code)?.[0];
|
||||
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
|
||||
switch (action) {
|
||||
case 'play-pause': togglePlay(); break;
|
||||
case 'next': next(); break;
|
||||
case 'prev': previous(); break;
|
||||
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
|
||||
break;
|
||||
}
|
||||
case 'seek-backward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.max(0, s.currentTime - 10));
|
||||
break;
|
||||
}
|
||||
case 'toggle-queue': toggleQueue(); break;
|
||||
case 'fullscreen-player': toggleFullscreen(); break;
|
||||
case 'native-fullscreen': {
|
||||
const win = getCurrentWindow();
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [togglePlay]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten: Array<() => void> = [];
|
||||
@@ -255,11 +282,16 @@ function TauriEventBridge() {
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const font = useFontStore(s => s.font);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface SubsonicArtistInfo {
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 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
|
||||
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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+96
-16
@@ -56,6 +56,12 @@ const enTranslation = {
|
||||
nobody: 'Nobody is currently listening.',
|
||||
minutesAgo: '{{n}}m ago',
|
||||
nothingPlaying: 'Nothing playing yet. Start a track!',
|
||||
aboutArtist: 'About the Artist',
|
||||
fromAlbum: 'From this Album',
|
||||
viewAlbum: 'View Album',
|
||||
goToArtist: 'Go to Artist',
|
||||
readMore: 'Read more',
|
||||
showLess: 'Show less',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
@@ -261,6 +267,7 @@ const enTranslation = {
|
||||
languageDe: 'German',
|
||||
languageFr: 'French',
|
||||
languageNl: 'Dutch',
|
||||
font: 'Font',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
servers: 'Servers',
|
||||
@@ -335,8 +342,23 @@ const enTranslation = {
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAppearance: 'Appearance',
|
||||
tabShortcuts: 'Shortcuts',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
shortcutsReset: 'Reset to defaults',
|
||||
shortcutListening: 'Press a key…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Clear',
|
||||
shortcutPlayPause: 'Play / Pause',
|
||||
shortcutNext: 'Next track',
|
||||
shortcutPrev: 'Previous track',
|
||||
shortcutVolumeUp: 'Volume up',
|
||||
shortcutVolumeDown: 'Volume down',
|
||||
shortcutSeekForward: 'Seek forward 10s',
|
||||
shortcutSeekBackward: 'Seek backward 10s',
|
||||
shortcutToggleQueue: 'Toggle queue',
|
||||
shortcutFullscreenPlayer: 'Fullscreen player',
|
||||
shortcutNativeFullscreen: 'Native fullscreen',
|
||||
playbackTitle: 'Playback',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
|
||||
@@ -393,8 +415,6 @@ const enTranslation = {
|
||||
a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.',
|
||||
q22: 'What is the waveform in the player bar?',
|
||||
a22: 'The waveform seekbar replaces the classic progress slider. Click anywhere on it to jump to that position, or drag to scrub. The played portion glows with a blue-to-mauve gradient, the buffered range appears slightly brighter, and the unplayed portion is faded.',
|
||||
q23: 'How do I use the MilkDrop visualizer?',
|
||||
a23: 'Open the fullscreen player (click the album art in the player bar), then click the waveform icon in the top-right corner. The visualizer starts with a random MilkDrop preset. Use the shuffle button next to it to cycle through hundreds of presets.',
|
||||
q24: 'Can I shuffle the queue?',
|
||||
a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.',
|
||||
q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?',
|
||||
@@ -440,6 +460,8 @@ const enTranslation = {
|
||||
emptyQueue: 'The queue is empty.',
|
||||
trackSingular: 'track',
|
||||
trackPlural: 'tracks',
|
||||
showRemaining: 'Show remaining time',
|
||||
showTotal: 'Show total time',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
@@ -483,8 +505,6 @@ const enTranslation = {
|
||||
openFullscreen: 'Open Fullscreen Player',
|
||||
fullscreen: 'Fullscreen Player',
|
||||
closeFullscreen: 'Close Fullscreen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Next preset',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
noTitle: 'No Title',
|
||||
stop: 'Stop',
|
||||
@@ -557,6 +577,12 @@ const deTranslation = {
|
||||
nobody: 'Gerade hört niemand Musik.',
|
||||
minutesAgo: 'vor {{n}}m',
|
||||
nothingPlaying: 'Noch nichts am Laufen. Leg einen Track auf!',
|
||||
aboutArtist: 'Über den Künstler',
|
||||
fromAlbum: 'Aus diesem Album',
|
||||
viewAlbum: 'Album öffnen',
|
||||
goToArtist: 'Zum Künstler',
|
||||
readMore: 'Mehr lesen',
|
||||
showLess: 'Weniger anzeigen',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Direkt abspielen',
|
||||
@@ -762,6 +788,7 @@ const deTranslation = {
|
||||
languageDe: 'Deutsch',
|
||||
languageFr: 'Französisch',
|
||||
languageNl: 'Niederländisch',
|
||||
font: 'Schriftart',
|
||||
theme: 'Design',
|
||||
appearance: 'Darstellung',
|
||||
servers: 'Server',
|
||||
@@ -836,7 +863,22 @@ const deTranslation = {
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAppearance: 'Darstellung',
|
||||
tabShortcuts: 'Tastenkürzel',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
shortcutListening: 'Taste drücken…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Löschen',
|
||||
shortcutPlayPause: 'Wiedergabe / Pause',
|
||||
shortcutNext: 'Nächster Titel',
|
||||
shortcutPrev: 'Vorheriger Titel',
|
||||
shortcutVolumeUp: 'Lautstärke erhöhen',
|
||||
shortcutVolumeDown: 'Lautstärke verringern',
|
||||
shortcutSeekForward: '10s vorspulen',
|
||||
shortcutSeekBackward: '10s zurückspulen',
|
||||
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabAbout: 'Info',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
@@ -894,8 +936,6 @@ const deTranslation = {
|
||||
a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.',
|
||||
q22: 'Was ist die Waveform in der Playerleiste?',
|
||||
a22: 'Die Waveform-Leiste ersetzt den klassischen Fortschrittsbalken. Klick auf eine beliebige Stelle zum Springen, ziehen zum Scrubben. Der gespielte Teil leuchtet in einem Blau-Mauve-Farbverlauf, der gepufferte Bereich erscheint etwas heller, der ungespielter Teil ist abgeblendet.',
|
||||
q23: 'Wie benutze ich den MilkDrop-Visualizer?',
|
||||
a23: 'Vollbild-Player öffnen (Klick auf das Album-Cover in der Playerleiste), dann oben rechts auf das Waveform-Icon klicken. Der Visualizer startet mit einem zufälligen MilkDrop-Preset. Mit dem Shuffle-Button daneben durch Hunderte von Presets wechseln.',
|
||||
q24: 'Kann ich die Warteschlange mischen?',
|
||||
a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.',
|
||||
q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?',
|
||||
@@ -941,6 +981,8 @@ const deTranslation = {
|
||||
emptyQueue: 'Die Warteschlange ist leer.',
|
||||
trackSingular: 'Titel',
|
||||
trackPlural: 'Titel',
|
||||
showRemaining: 'Restzeit anzeigen',
|
||||
showTotal: 'Gesamtzeit anzeigen',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
@@ -984,8 +1026,6 @@ const deTranslation = {
|
||||
openFullscreen: 'Vollbild-Player öffnen',
|
||||
fullscreen: 'Vollbild-Player',
|
||||
closeFullscreen: 'Vollbild schließen',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Nächstes Preset',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
noTitle: 'Kein Titel',
|
||||
stop: 'Stop',
|
||||
@@ -1058,6 +1098,12 @@ const frTranslation = {
|
||||
nobody: 'Personne n\'écoute en ce moment.',
|
||||
minutesAgo: 'il y a {{n}} min',
|
||||
nothingPlaying: 'Rien en cours. Lancez un morceau !',
|
||||
aboutArtist: "À propos de l'artiste",
|
||||
fromAlbum: 'De cet album',
|
||||
viewAlbum: "Voir l'album",
|
||||
goToArtist: "Aller à l'artiste",
|
||||
readMore: 'Lire la suite',
|
||||
showLess: 'Réduire',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Lire maintenant',
|
||||
@@ -1263,6 +1309,7 @@ const frTranslation = {
|
||||
languageDe: 'Allemand',
|
||||
languageFr: 'Français',
|
||||
languageNl: 'Néerlandais',
|
||||
font: 'Police',
|
||||
theme: 'Thème',
|
||||
appearance: 'Apparence',
|
||||
servers: 'Serveurs',
|
||||
@@ -1337,7 +1384,22 @@ const frTranslation = {
|
||||
tabPlayback: 'Lecture',
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabAppearance: 'Apparence',
|
||||
tabShortcuts: 'Raccourcis',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
shortcutListening: 'Appuyez sur une touche…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Effacer',
|
||||
shortcutPlayPause: 'Lecture / Pause',
|
||||
shortcutNext: 'Piste suivante',
|
||||
shortcutPrev: 'Piste précédente',
|
||||
shortcutVolumeUp: 'Volume +',
|
||||
shortcutVolumeDown: 'Volume -',
|
||||
shortcutSeekForward: 'Avancer de 10s',
|
||||
shortcutSeekBackward: 'Reculer de 10s',
|
||||
shortcutToggleQueue: 'Afficher/masquer la file',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabAbout: 'À propos',
|
||||
playbackTitle: 'Lecture',
|
||||
replayGain: 'Replay Gain',
|
||||
@@ -1395,8 +1457,6 @@ const frTranslation = {
|
||||
a17: 'Un scrobble est soumis après avoir écouté 50% d\'une piste.',
|
||||
q22: 'Qu\'est-ce que la forme d\'onde dans la barre du lecteur ?',
|
||||
a22: 'La barre de forme d\'onde remplace le curseur de progression classique. Cliquez n\'importe où pour sauter à cette position, ou faites glisser pour naviguer.',
|
||||
q23: 'Comment utiliser le visualiseur MilkDrop ?',
|
||||
a23: 'Ouvrez le lecteur plein écran (cliquez sur la pochette), puis cliquez sur l\'icône de forme d\'onde en haut à droite. Utilisez le bouton aléatoire pour parcourir les préréglages.',
|
||||
q24: 'Puis-je mélanger la file d\'attente ?',
|
||||
a24: 'Oui. Ouvrez le panneau de file et cliquez sur l\'icône de mélange. La piste en cours reste en position 1 — les autres sont réorganisées aléatoirement.',
|
||||
q25: 'Les liens Last.fm et Wikipédia s\'ouvrent-ils dans un navigateur ?',
|
||||
@@ -1442,6 +1502,8 @@ const frTranslation = {
|
||||
emptyQueue: 'La file d\'attente est vide.',
|
||||
trackSingular: 'piste',
|
||||
trackPlural: 'pistes',
|
||||
showRemaining: 'Afficher le temps restant',
|
||||
showTotal: 'Afficher la durée totale',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiques',
|
||||
@@ -1485,8 +1547,6 @@ const frTranslation = {
|
||||
openFullscreen: 'Ouvrir le lecteur plein écran',
|
||||
fullscreen: 'Lecteur plein écran',
|
||||
closeFullscreen: 'Fermer le plein écran',
|
||||
visualizer: 'Visualiseur',
|
||||
nextPreset: 'Préréglage suivant',
|
||||
closeTooltip: 'Fermer (Échap)',
|
||||
noTitle: 'Sans titre',
|
||||
stop: 'Arrêter',
|
||||
@@ -1559,6 +1619,12 @@ const nlTranslation = {
|
||||
nobody: 'Er luistert momenteel niemand.',
|
||||
minutesAgo: '{{n}} min geleden',
|
||||
nothingPlaying: 'Nog niets bezig. Start een nummer!',
|
||||
aboutArtist: 'Over de artiest',
|
||||
fromAlbum: 'Van dit album',
|
||||
viewAlbum: 'Album openen',
|
||||
goToArtist: 'Naar artiest',
|
||||
readMore: 'Meer lezen',
|
||||
showLess: 'Minder tonen',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Nu afspelen',
|
||||
@@ -1764,6 +1830,7 @@ const nlTranslation = {
|
||||
languageDe: 'Duits',
|
||||
languageFr: 'Frans',
|
||||
languageNl: 'Nederlands',
|
||||
font: 'Lettertype',
|
||||
theme: 'Thema',
|
||||
appearance: 'Weergave',
|
||||
servers: 'Servers',
|
||||
@@ -1838,7 +1905,22 @@ const nlTranslation = {
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAppearance: 'Weergave',
|
||||
tabShortcuts: 'Sneltoetsen',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
shortcutListening: 'Druk op een toets…',
|
||||
shortcutUnbound: '—',
|
||||
shortcutClear: 'Wissen',
|
||||
shortcutPlayPause: 'Afspelen / Pauzeren',
|
||||
shortcutNext: 'Volgend nummer',
|
||||
shortcutPrev: 'Vorig nummer',
|
||||
shortcutVolumeUp: 'Volume omhoog',
|
||||
shortcutVolumeDown: 'Volume omlaag',
|
||||
shortcutSeekForward: '10s vooruitspoelen',
|
||||
shortcutSeekBackward: '10s terugspoelen',
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabAbout: 'Over',
|
||||
playbackTitle: 'Afspelen',
|
||||
replayGain: 'Replay Gain',
|
||||
@@ -1896,8 +1978,6 @@ const nlTranslation = {
|
||||
a17: 'Een scrobble wordt verstuurd nadat je 50% van een nummer hebt geluisterd.',
|
||||
q22: 'Wat is de golfvorm in de spelerbalk?',
|
||||
a22: 'De golfvormzoekbalk vervangt de klassieke voortgangsschuifregelaar. Klik ergens om naar die positie te springen, of sleep om door het nummer te navigeren.',
|
||||
q23: 'Hoe gebruik ik de MilkDrop-visualizer?',
|
||||
a23: 'Open de volledig-schermspeler (klik op de albumhoes) en klik dan op het golfvormpictogram rechtsboven. Gebruik de willekeurigknop om door honderden presets te bladeren.',
|
||||
q24: 'Kan ik de wachtrij shufflen?',
|
||||
a24: 'Ja. Open het wachtrijpaneel en klik op het shufflepictogram. Het huidige nummer blijft op positie 1 — alle overige nummers worden willekeurig geherordend.',
|
||||
q25: 'Openen Last.fm- en Wikipedia-links in een browser?',
|
||||
@@ -1943,6 +2023,8 @@ const nlTranslation = {
|
||||
emptyQueue: 'De wachtrij is leeg.',
|
||||
trackSingular: 'nummer',
|
||||
trackPlural: 'nummers',
|
||||
showRemaining: 'Resterende tijd tonen',
|
||||
showTotal: 'Totale tijd tonen',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistieken',
|
||||
@@ -1986,8 +2068,6 @@ const nlTranslation = {
|
||||
openFullscreen: 'Volledig-schermspeler openen',
|
||||
fullscreen: 'Volledig-schermspeler',
|
||||
closeFullscreen: 'Volledig scherm sluiten',
|
||||
visualizer: 'Visualizer',
|
||||
nextPreset: 'Volgende preset',
|
||||
closeTooltip: 'Sluiten (Escape)',
|
||||
noTitle: 'Geen titel',
|
||||
stop: 'Stoppen',
|
||||
|
||||
+226
-315
@@ -1,14 +1,12 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play, Shuffle, Star, Music, Save, FolderOpen, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { Music, Star, ExternalLink } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import {
|
||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||
getPlaylists, getPlaylist, createPlaylist, deletePlaylist,
|
||||
getAlbum, SubsonicPlaylist, SubsonicSong,
|
||||
getAlbum, getArtistInfo,
|
||||
SubsonicSong, SubsonicArtistInfo,
|
||||
} from '../api/subsonic';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
|
||||
@@ -20,13 +18,20 @@ function formatTime(s: number): string {
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDuration(secs: number): string {
|
||||
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')}`;
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function renderStars(rating?: number) {
|
||||
@@ -34,18 +39,104 @@ function renderStars(rating?: number) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '3px', alignItems: 'center' }}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Star
|
||||
key={i}
|
||||
size={14}
|
||||
<Star key={i} size={13}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'rgba(255,255,255,0.4)'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blurred background (crossfade on track change) ───────────────────────────
|
||||
// ─── Animated EQ Bars ─────────────────────────────────────────────────────────
|
||||
|
||||
const BAR_COUNT = 24;
|
||||
|
||||
const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) {
|
||||
const barsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const heights = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => 0.08));
|
||||
const targets = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => Math.random() * 0.5 + 0.1));
|
||||
const speeds = useRef<number[]>(Array.from({ length: BAR_COUNT }, () => 0.06 + Math.random() * 0.08));
|
||||
const rafRef = useRef<number>();
|
||||
|
||||
const animate = useCallback(() => {
|
||||
heights.current = heights.current.map((h, i) => {
|
||||
const t = targets.current[i];
|
||||
const newH = h + (t - h) * speeds.current[i];
|
||||
if (Math.abs(newH - t) < 0.015) {
|
||||
targets.current[i] = Math.random() * 0.88 + 0.06;
|
||||
speeds.current[i] = 0.05 + Math.random() * 0.10;
|
||||
}
|
||||
return newH;
|
||||
});
|
||||
barsRef.current.forEach((bar, i) => {
|
||||
if (bar) bar.style.height = `${Math.round(heights.current[i] * 100)}%`;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// Settle bars to a low resting height
|
||||
heights.current = heights.current.map(() => 0.08);
|
||||
barsRef.current.forEach(bar => {
|
||||
if (bar) bar.style.height = '8%';
|
||||
});
|
||||
}
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [isPlaying, animate]);
|
||||
|
||||
return (
|
||||
<div className="np-eq-wrap">
|
||||
<div className="np-eq-bars">
|
||||
{Array.from({ length: BAR_COUNT }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="np-eq-bar"
|
||||
ref={el => { barsRef.current[i] = el; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Tag Cloud ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TagCloudProps {
|
||||
genre?: string;
|
||||
year?: number;
|
||||
similarArtists: Array<{ id: string; name: string }>;
|
||||
onArtistClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function TagCloud({ genre, year, similarArtists, onArtistClick }: TagCloudProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasTags = genre || year || similarArtists.length > 0;
|
||||
if (!hasTags) return null;
|
||||
|
||||
return (
|
||||
<div className="np-tag-cloud">
|
||||
{genre && <span className="np-tag np-tag-accent">{genre}</span>}
|
||||
{year && <span className="np-tag">{year}</span>}
|
||||
{similarArtists.slice(0, 6).map(a => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="np-tag np-tag-clickable"
|
||||
onClick={() => onArtistClick(a.id)}
|
||||
data-tooltip={t('nowPlaying.goToArtist')}
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blurred background ───────────────────────────────────────────────────────
|
||||
|
||||
const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
@@ -65,9 +156,7 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="np-bg-wrap">
|
||||
{layers.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className="np-bg-layer"
|
||||
<div key={l.id} className="np-bg-layer"
|
||||
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
|
||||
/>
|
||||
))}
|
||||
@@ -76,92 +165,14 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Modals (reused from QueuePanel) ──────────────────────────────────────────
|
||||
|
||||
function SavePlaylistModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
|
||||
<input
|
||||
type="text" className="live-search-field"
|
||||
placeholder={t('queue.playlistName')} value={name}
|
||||
onChange={e => setName(e.target.value)} autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
|
||||
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetch = () => {
|
||||
setLoading(true);
|
||||
getPlaylists().then(d => { setPlaylists(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { fetch(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetch(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} /></button>
|
||||
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
|
||||
: playlists.length === 0 ? <p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{playlists.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span style={{ fontWeight: 500 }} className="truncate">{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id)} style={{ width: 28, height: 28, background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} style={{ width: 28, height: 28, background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function NowPlaying() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||
const removeTrack = usePlayerStore(s => s.removeTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
|
||||
|
||||
// Hide queue panel while on this page, restore on leave
|
||||
@@ -171,157 +182,171 @@ export default function NowPlaying() {
|
||||
return () => { if (wasVisible) setQueueVisible(true); };
|
||||
}, [setQueueVisible]);
|
||||
|
||||
// Extra song metadata (genre) fetched via getSong
|
||||
// Extra song metadata
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentTrack) { setSongMeta(null); return; }
|
||||
getSong(currentTrack.id).then(setSongMeta);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Favorite state
|
||||
const [starred, setStarred] = useState(false);
|
||||
// Artist info (bio + similar artists)
|
||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
useEffect(() => {
|
||||
setStarred(!!songMeta?.starred);
|
||||
}, [songMeta]);
|
||||
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
||||
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
// Album tracks
|
||||
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.albumId) { setAlbumTracks([]); return; }
|
||||
getAlbum(currentTrack.albumId).then(d => setAlbumTracks(d.songs)).catch(() => setAlbumTracks([]));
|
||||
}, [currentTrack?.albumId]);
|
||||
|
||||
// Bio expand toggle
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
useEffect(() => { setBioExpanded(false); }, [currentTrack?.artistId]);
|
||||
|
||||
// Favorite
|
||||
const [starred, setStarred] = useState(false);
|
||||
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
|
||||
const toggleStar = async () => {
|
||||
if (!currentTrack) return;
|
||||
if (starred) { await unstar(currentTrack.id, 'song'); setStarred(false); }
|
||||
else { await star(currentTrack.id, 'song'); setStarred(true); }
|
||||
};
|
||||
|
||||
// Cover / background
|
||||
// Cover
|
||||
const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Queue drag-and-drop
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
isDraggingInternalRef.current = true;
|
||||
draggedIdxRef.current = index;
|
||||
_dragFromIdx = index;
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
|
||||
};
|
||||
const onDragEnterItem = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
};
|
||||
const onDragOverItem = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
dragOverIdxRef.current = index;
|
||||
setDragOverIdx(index);
|
||||
};
|
||||
const onDragEnd = () => {
|
||||
setDraggedIdx(null); setDragOverIdx(null);
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null; dragOverIdxRef.current = null;
|
||||
};
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null; dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null); setDragOverIdx(null);
|
||||
|
||||
let parsedData: any = null;
|
||||
try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch {}
|
||||
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
let toIdx = queue.length;
|
||||
if (queueListRef.current) {
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const rect = items[i].getBoundingClientRect();
|
||||
if (e.clientY < rect.top + rect.height / 2) { toIdx = parseInt(items[i].dataset.queueIdx!); break; }
|
||||
}
|
||||
}
|
||||
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
|
||||
return;
|
||||
}
|
||||
_dragFromIdx = null;
|
||||
if (!parsedData) return;
|
||||
if (parsedData.type === 'song') enqueue([parsedData.track]);
|
||||
else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
};
|
||||
|
||||
// Playlist modals
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
{/* ── Hero ── */}
|
||||
<div className="np-hero">
|
||||
<NpBg url={resolvedCover ?? ''} />
|
||||
|
||||
<div className="np-hero-content">
|
||||
<div className="np-main">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{/* Cover + glow */}
|
||||
<div className="np-cover-wrap">
|
||||
{resolvedCover && (
|
||||
<img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden="true" />
|
||||
)}
|
||||
{/* ── Hero Card ── */}
|
||||
<div className="np-hero-card">
|
||||
|
||||
{/* Left: cover + meta info */}
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedCover && <img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden />}
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" />
|
||||
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>
|
||||
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="np-info">
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title">{currentTrack.title}</div>
|
||||
|
||||
<div className="np-artist-album">
|
||||
<span
|
||||
className="np-link"
|
||||
<span className="np-link"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.artist}</span>
|
||||
<span className="np-sep">·</span>
|
||||
<span
|
||||
className="np-link"
|
||||
<span className="np-link"
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
>{currentTrack.album}</span>
|
||||
{currentTrack.year && <><span className="np-sep">·</span><span>{currentTrack.year}</span></>}
|
||||
</div>
|
||||
|
||||
<div className="np-tech-row">
|
||||
{songMeta?.genre && <span className="np-badge">{songMeta.genre}</span>}
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
<button
|
||||
onClick={toggleStar}
|
||||
className="np-star-btn"
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: EQ bars */}
|
||||
<EQBars isPlaying={isPlaying} />
|
||||
|
||||
{/* Right: tag cloud */}
|
||||
<TagCloud
|
||||
genre={songMeta?.genre}
|
||||
year={currentTrack.year}
|
||||
similarArtists={similarArtists}
|
||||
onArtistClick={id => navigate(`/artist/${id}`)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── About the Artist ── */}
|
||||
{(artistInfo?.biography || artistInfo?.largeImageUrl) && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
{currentTrack.artistId && (
|
||||
<button className="np-card-link" onClick={() => navigate(`/artist/${currentTrack.artistId}`)}>
|
||||
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{artistInfo.largeImageUrl && (
|
||||
<img src={artistInfo.largeImageUrl} alt={currentTrack.artist} className="np-artist-thumb" />
|
||||
)}
|
||||
{artistInfo.biography && (
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── From this Album ── */}
|
||||
{albumTracks.length > 0 && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{currentTrack.album}</em></h3>
|
||||
{currentTrack.albumId && (
|
||||
<button className="np-card-link" onClick={() => navigate(`/album/${currentTrack.albumId}`)}>
|
||||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{albumTracks.map(track => {
|
||||
const isActive = track.id === currentTrack.id;
|
||||
return (
|
||||
<div key={track.id}
|
||||
className={`np-album-track${isActive ? ' active' : ''}`}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>
|
||||
<span className="np-album-track-num">
|
||||
{isActive
|
||||
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
|
||||
: track.track ?? '—'
|
||||
}
|
||||
</span>
|
||||
<span className="np-album-track-title truncate">{track.title}</span>
|
||||
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
@@ -331,119 +356,5 @@ export default function NowPlaying() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Queue list ── */}
|
||||
<div className="np-queue-section">
|
||||
<div className="np-queue-header">
|
||||
<div>
|
||||
<h2 className="np-queue-title">{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (
|
||||
<div className="np-queue-meta">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {formatDuration(totalSecs)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-queue-actions">
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" data-tooltip={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="np-queue-list"
|
||||
ref={queueListRef}
|
||||
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDrop={onDropQueue}
|
||||
>
|
||||
{queue.length === 0 ? (
|
||||
<div className="queue-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
const isDragging = draggedIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isDragging) dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
else if (isDragOver && draggedIdx !== null) {
|
||||
dragStyle = draggedIdx > idx
|
||||
? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }
|
||||
: { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
data-queue-idx={idx}
|
||||
className={`np-queue-item ${isActive ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => playTrack(track, queue)}
|
||||
onContextMenu={e => { e.preventDefault(); usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx); }}
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, idx)}
|
||||
onDragEnter={e => onDragEnterItem(e)}
|
||||
onDragOver={e => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="np-queue-num">
|
||||
{isActive
|
||||
? <Play size={12} fill="var(--accent)" color="var(--accent)" />
|
||||
: <span>{idx + 1}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="np-queue-item-info">
|
||||
<div className={`np-queue-item-title truncate ${isActive ? 'np-queue-item-active' : ''}`}>{track.title}</div>
|
||||
<div className="np-queue-item-artist truncate">{track.artist} · {track.album}</div>
|
||||
</div>
|
||||
<div className="np-queue-item-duration">{formatTime(track.duration)}</div>
|
||||
<button
|
||||
className="np-queue-remove"
|
||||
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
|
||||
data-tooltip={t('queue.remove')}
|
||||
><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveModalOpen && (
|
||||
<SavePlaylistModal
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async name => {
|
||||
try { await createPlaylist(name, queue.map(t => t.id)); setSaveModalOpen(false); }
|
||||
catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async id => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const tracks: Track[] = data.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
|
||||
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
|
||||
setLoadModalOpen(false);
|
||||
} catch (e) { console.error(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+138
-2
@@ -4,7 +4,7 @@ import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
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
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
@@ -13,6 +13,8 @@ import CustomSelect from '../components/CustomSelect';
|
||||
import ThemePicker from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,7 +22,7 @@ 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';
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -83,6 +85,9 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const fontStore = useFontStore();
|
||||
const kb = useKeybindingsStore();
|
||||
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -210,6 +215,7 @@ export default function Settings() {
|
||||
{ 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: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
];
|
||||
@@ -492,9 +498,139 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Type size={18} />
|
||||
<h2>{t('settings.font')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{([
|
||||
{ id: 'inter', label: 'Inter', sample: 'The quick brown fox' },
|
||||
{ id: 'outfit', label: 'Outfit', sample: 'The quick brown fox' },
|
||||
{ id: 'dm-sans', label: 'DM Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'nunito', label: 'Nunito', sample: 'The quick brown fox' },
|
||||
{ id: 'rubik', label: 'Rubik', sample: 'The quick brown fox' },
|
||||
{ id: 'space-grotesk', label: 'Space Grotesk', sample: 'The quick brown fox' },
|
||||
{ id: 'figtree', label: 'Figtree', sample: 'The quick brown fox' },
|
||||
{ id: 'manrope', label: 'Manrope', sample: 'The quick brown fox' },
|
||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', sample: 'The quick brown fox' },
|
||||
{ id: 'lexend', label: 'Lexend', sample: 'The quick brown fox' },
|
||||
] as { id: FontId; label: string; sample: string }[]).map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => fontStore.setFont(f.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
background: fontStore.font === f.id ? 'var(--accent-dim)' : 'transparent',
|
||||
border: `1px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md)', padding: '10px 14px',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${fontStore.font === f.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: fontStore.font === f.id ? 'var(--accent)' : 'transparent',
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{f.label}</div>
|
||||
<div style={{ fontFamily: `'${f.label}', sans-serif`, fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{f.sample}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.tabShortcuts')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
['next', t('settings.shortcutNext')],
|
||||
['prev', t('settings.shortcutPrev')],
|
||||
['volume-up', t('settings.shortcutVolumeUp')],
|
||||
['volume-down', t('settings.shortcutVolumeDown')],
|
||||
['seek-forward', t('settings.shortcutSeekForward')],
|
||||
['seek-backward', t('settings.shortcutSeekBackward')],
|
||||
['toggle-queue', t('settings.shortcutToggleQueue')],
|
||||
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
|
||||
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
|
||||
] as [KeyAction, string][]).map(([action, label]) => {
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
return (
|
||||
<div key={action} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isListening) { setListeningFor(null); return; }
|
||||
setListeningFor(action);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code !== 'Escape') {
|
||||
// unbind any existing action with this key first
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === e.code)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, e.code);
|
||||
}
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
}}
|
||||
style={{
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
onClick={() => kb.setBinding(action, null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||||
data-tooltip={t('settings.shortcutClear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'server' && (
|
||||
<>
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -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,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;
|
||||
|
||||
+393
-13
@@ -2619,14 +2619,394 @@
|
||||
}
|
||||
|
||||
/* ─ Now Playing Page ─ */
|
||||
/* ─── Now Playing Page ─────────────────────────────────────── */
|
||||
.np-page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
/* Main scrollable content */
|
||||
.np-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 28px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Hero card: 3-column (cover+info | EQ | tag cloud) ── */
|
||||
.np-hero-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.30);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Left column: cover + meta, left-aligned — takes equal share with right */
|
||||
.np-hero-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.np-hero-cover-wrap {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.np-hero-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Center column: fixed width so it stays truly centered between equal outer columns */
|
||||
.np-eq-wrap {
|
||||
flex: 0 0 clamp(100px, 20vw, 220px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.np-eq-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.np-eq-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
max-width: 8px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.85;
|
||||
height: 8%;
|
||||
transition: background 0.3s;
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
/* Right column: tag cloud — takes equal share with left so EQ stays centered */
|
||||
.np-tag-cloud {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
align-content: flex-start;
|
||||
padding-top: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.np-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.np-tag-accent {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.np-tag-clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.np-tag-clickable:hover {
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Info cards (artist / album) ── */
|
||||
.np-info-card {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.np-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.np-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.np-card-link {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.np-card-link:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Artist bio ── */
|
||||
.np-artist-bio-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.np-artist-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.np-bio-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.np-bio-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.np-bio-text.expanded {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
|
||||
.np-bio-text a { color: var(--accent); }
|
||||
|
||||
.np-bio-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
padding: 0;
|
||||
align-self: flex-start;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.np-bio-toggle:hover { opacity: 1; }
|
||||
|
||||
/* ── Album tracklist ── */
|
||||
.np-album-tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.np-album-track {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.np-album-track:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.np-album-track.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.np-album-track-num {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.np-album-track.active .np-album-track-num {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.np-album-track-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.80);
|
||||
}
|
||||
.np-album-track.active .np-album-track-title {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.np-album-track-dur {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Queue Sidebar ── */
|
||||
.np-queue-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.np-qs-header {
|
||||
padding: 16px 14px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.np-qs-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.np-qs-meta {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.40);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.np-qs-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.np-qs-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.np-qs-item {
|
||||
display: grid;
|
||||
grid-template-columns: 26px 1fr 36px 22px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.np-qs-item:hover,
|
||||
.np-qs-item.context-active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.np-qs-item.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.np-qs-num {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.30);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.np-qs-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.np-qs-track-title {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.80);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.np-qs-track-title.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.np-qs-track-artist {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.np-qs-dur {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.30);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Shared np- classes (kept) ── */
|
||||
/* Hero (old, kept for NpBg) */
|
||||
.np-hero {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
@@ -2637,7 +3017,7 @@
|
||||
|
||||
.np-bg-wrap {
|
||||
position: absolute;
|
||||
inset: 0 0 -80px 0;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
@@ -2648,7 +3028,7 @@
|
||||
inset: -15%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(50px) brightness(0.25) saturate(1.8);
|
||||
filter: blur(40px) brightness(0.55) saturate(1.6);
|
||||
transition: opacity 0.6s ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
@@ -2656,7 +3036,7 @@
|
||||
.np-bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 40%, var(--bg-app) 100%);
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.np-hero-content {
|
||||
@@ -2683,8 +3063,8 @@
|
||||
|
||||
.np-cover-glow {
|
||||
position: absolute;
|
||||
width: 170px;
|
||||
height: 170px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
filter: blur(36px) saturate(1.5) brightness(0.85);
|
||||
@@ -2704,8 +3084,8 @@
|
||||
.np-cover {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
width: 196px;
|
||||
height: 196px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
|
||||
@@ -2720,7 +3100,7 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Info column */
|
||||
/* Info column (legacy, kept for compat) */
|
||||
.np-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2743,7 +3123,7 @@
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -2760,7 +3140,7 @@
|
||||
.np-tech-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -324,8 +324,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
flex: 0 0 auto;
|
||||
max-width: min(40vw, 480px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
|
||||
.player-track-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-track-name {
|
||||
|
||||
+1527
-13
File diff suppressed because it is too large
Load Diff
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
declare module 'butterchurn' {
|
||||
interface Visualizer {
|
||||
connectAudio(audioNode: AudioNode): void;
|
||||
loadPreset(preset: unknown, blendTime: number): void;
|
||||
render(): void;
|
||||
setRendererSize(width: number, height: number): void;
|
||||
}
|
||||
interface CreateOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
pixelRatio?: number;
|
||||
}
|
||||
function createVisualizer(ctx: AudioContext, canvas: HTMLCanvasElement, opts: CreateOptions): Visualizer;
|
||||
export default { createVisualizer };
|
||||
}
|
||||
|
||||
declare module 'butterchurn-presets' {
|
||||
function getPresets(): Record<string, unknown>;
|
||||
export default { getPresets };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { songToTrack } from '../store/playerStore';
|
||||
|
||||
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const steps = 16;
|
||||
const stepMs = durationMs / steps;
|
||||
let step = 0;
|
||||
const id = setInterval(() => {
|
||||
step++;
|
||||
setVolume(Math.max(0, from * (1 - step / steps)));
|
||||
if (step >= steps) {
|
||||
clearInterval(id);
|
||||
resolve();
|
||||
}
|
||||
}, stepMs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function playAlbum(albumId: string): Promise<void> {
|
||||
const albumData = await getAlbum(albumId);
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
if (!tracks.length) return;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const { isPlaying, volume } = store;
|
||||
|
||||
if (isPlaying) {
|
||||
await fadeOut(store.setVolume, volume, 700);
|
||||
// Restore volume only in the Zustand store — do NOT call audio_set_volume here,
|
||||
// otherwise the old track glitches back to full volume before playTrack stops it.
|
||||
// playTrack reads state.volume and passes it to audio_play, so the new track
|
||||
// starts at the correct volume without the Rust engine ever hearing a restore.
|
||||
usePlayerStore.setState({ volume });
|
||||
}
|
||||
|
||||
usePlayerStore.getState().playTrack(tracks[0], tracks);
|
||||
}
|
||||
Reference in New Issue
Block a user