Compare commits

..

3 Commits

Author SHA1 Message Date
Psychotoxical 867c5fbd3e feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:37:49 +01:00
Psychotoxical e550340565 feat: v1.10.0 — new streaming themes, favourite toggle fix, home page improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:58:05 +01:00
Psychotoxical 57b70e6154 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>
2026-03-21 14:22:02 +01:00
44 changed files with 3872 additions and 856 deletions
+6
View File
@@ -34,3 +34,9 @@ src-tauri/target/
# Documentation
CLAUDE.md
# Claude Code memory (local only)
memory/
# Local scratchpad / notes (not committed)
tmp/
+100
View File
@@ -5,6 +5,106 @@ 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.11.0] - 2026-03-22
### Added
#### Five New Themes — Movies
- **Middle Earth**: Warm parchment light theme. Cream/beige background, dark ebony player and sidebar, gold accent (`#d4af37`). Georgia serif for track names, subtle noise texture.
- **Morpheus**: Pure black terminal aesthetic inspired by The Matrix. Phosphor green accent (`#00ff41`), monospace font.
- **Pandora**: Deep bioluminescent navy inspired by Avatar. Cyan accent (`#00f2ff`), large radii, glow effects.
- **Stark HUD**: Near-black tactical HUD inspired by Iron Man. Cyan accent, JetBrains Mono, uppercase track name.
- **Blade**: Deep black with blood-red accent (`#b30000`). Sharp radii, uppercase track name.
- All five themes in a new **Movies** group in the theme picker.
### Changed
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Appearance**: Language selector moved to the top of the tab, above Theme and Font.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
- **Default font**: Changed from Inter to **Lexend** for new installations.
- **Gapless playback**: Experimental badge removed — gapless is now considered stable.
- **Now Playing — background**: Ken Burns animation (40 s, subtle scale + translate). Background blur increased to eliminate JPEG block artefacts at high blur values.
- **Now Playing — Similar Artists**: Tag cloud redesigned into 2 rows with varied font sizes and vertical offsets for a natural look.
- **Statistics**: "Now Playing" indicator rendered as a styled badge matching the app's badge style.
## [1.10.0] - 2026-03-22
### Added
#### Three New Themes (Streaming Series)
- **Spotless**: Flat dark theme inspired by modern music streaming. Pitch-black sidebar (`#000000`), dark-grey app background (`#121212`), Spotify-green accent (`#1ED760`). White play button, green hover on primary actions.
- **DZR**: Flat light theme inspired by Deezer's modern redesign. White base, light-grey sidebar (`#F5F5F7`), purple accent (`#A238FF`). Crisp typography, large rounded radii.
- **Cupertino Beats**: Apple Music-inspired dark theme. Near-black base (`#1c1c1e`), frosted-glass sidebar and player bar with heavy `backdrop-filter`, red accent (`#fa243c`). Active nav links styled with `accent-dim` background.
- All three themes added to the **Psysonic Themes — Mediaplayer** group in the theme picker.
### Fixed
- **Favourite/Unfavourite toggle**: Right-clicking a song, album, or artist that is already starred now shows "Remove from Favourites" and calls `unstar()` correctly. Previously always showed "Add to Favourites" regardless of starred state.
- `Track` interface gained `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- `starredOverrides: Record<string, boolean>` added to `playerStore` — updated immediately on star/unstar so the context menu and tracklist star icons reflect changes without a page reload.
- **Home page — Artist Discovery**: Replaced card grid (which loaded artist images and caused performance issues) with lightweight pill-buttons — same `artist-ext-link` style as the "Similar Artists" section on artist pages. No image loading, instant render.
- **Now Playing page**: Queue sidebar is no longer automatically hidden when entering the Now Playing page. It now behaves like all other pages and respects the user's current queue visibility setting.
- **Random Mix filter panel**: Background now correctly uses `--bg-card` instead of the undefined `--bg-elevated` token, which caused the panel to render transparent in most themes.
### Changed
- **Home page layout**: Section order is now: Recently Added → Discover → Artist Discovery → Starred → Most Played.
## [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
+29 -12
View File
@@ -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,12 @@ 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` |
| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` |
**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).
@@ -195,7 +210,7 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
@@ -210,4 +225,6 @@ 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
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.11.0
+7 -6
View File
@@ -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).
@@ -44,8 +45,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks
- [x] Replay Gain (track + album mode)
- [x] Gapless playback *(experimental)*
- [x] MilkDrop/Butterchurn visualizer
- [x] Gapless playback
- [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
- [x] Similar Artists via Last.fm, filtered to library
@@ -53,13 +53,14 @@ 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] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, 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
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements
### 📋 Planned
+2 -61
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.8.0",
"version": "1.11.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",
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.8.0
pkgver=1.11.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.8.0"
version = "1.11.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.8.0"
version = "1.11.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+14 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.8.0",
"version": "1.11.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+42 -10
View File
@@ -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();
}, []);
+4 -2
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
@@ -13,7 +14,7 @@ function secureRandomSalt(): string {
function getAuthParams(username: string, password: string) {
const salt = secureRandomSalt();
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
}
function getClient() {
@@ -124,6 +125,7 @@ export interface SubsonicArtistInfo {
smallImageUrl?: string;
mediumImageUrl?: string;
largeImageUrl?: string;
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
}
// ─── API Methods ──────────────────────────────────────────────
@@ -165,7 +167,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
return data.albumList2?.album ?? [];
}
+6 -5
View File
@@ -4,6 +4,7 @@ import { Play } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -50,16 +51,16 @@ 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>
<div className="album-card-info">
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
+2 -1
View File
@@ -86,6 +86,7 @@ export default function AlbumTrackList({
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred,
});
return (
@@ -143,7 +144,7 @@ export default function AlbumTrackList({
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
+1 -1
View File
@@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) {
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
<span className="artist-card-name">{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
+5 -41
View File
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
artists: SubsonicArtist[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadingRef = useRef(false);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
// Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
}
};
const triggerLoadMore = async () => {
if (!onLoadMore || loadingRef.current) return;
loadingRef.current = true;
setLoadingMore(true);
await onLoadMore();
setLoadingMore(false);
loadingRef.current = false;
};
useEffect(() => {
@@ -63,35 +43,19 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
{moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
+32 -9
View File
@@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string {
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -53,6 +53,9 @@ export default function ContextMenu() {
const { type, item, queueIndex } = contextMenu;
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
const handleAction = async (action: () => void | Promise<void>) => {
closeContextMenu();
await action();
@@ -148,8 +151,13 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
@@ -181,8 +189,13 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
})}>
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
@@ -199,8 +212,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
})}>
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
</>
);
@@ -224,8 +242,13 @@ export default function ContextMenu() {
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
+4 -2
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface Props {
@@ -14,7 +15,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
return createPortal(
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
<img
@@ -23,6 +24,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
alt={alt}
onClick={e => e.stopPropagation()}
/>
</div>
</div>,
document.body
);
}
+4 -49
View File
@@ -1,13 +1,12 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
import VisualizerCanvas from './VisualizerCanvas';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -148,10 +147,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const [vizActive, setVizActive] = useState(false);
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
const [presetName, setPresetName] = useState('');
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -182,55 +177,15 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1 — blurred artist image OR visualizer */}
{vizActive && currentTrack ? (
<VisualizerCanvas
trackId={currentTrack.id}
nextPresetTrigger={nextPresetTrigger}
onPresetName={setPresetName}
/>
) : (
<>
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
</>
)}
{/* Layer 1 — blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Visualizer toggle + preset controls */}
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{vizActive && (
<>
{presetName && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{presetName}
</span>
)}
<button
className="fs-btn fs-btn-sm"
onClick={() => setNextPresetTrigger(n => n + 1)}
aria-label={t('player.nextPreset')}
data-tooltip={t('player.nextPreset')}
style={{ color: 'rgba(255,255,255,0.7)' }}
>
<Shuffle size={14} />
</button>
</>
)}
<button
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
onClick={() => setVizActive(v => !v)}
aria-label={t('player.visualizer')}
data-tooltip={t('player.visualizer')}
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
>
<AudioWaveform size={16} />
</button>
</div>
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
+2 -1
View File
@@ -5,6 +5,7 @@ import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, get
import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum';
const INTERVAL_MS = 10000;
@@ -124,7 +125,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
<button
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
+5 -5
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
@@ -74,7 +75,6 @@ export default function PlayerBar() {
<div className="player-track-meta">
<div
className="player-track-name"
data-tooltip={currentTrack?.title ?? ''}
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
@@ -82,7 +82,6 @@ export default function PlayerBar() {
</div>
<div
className="player-track-artist"
data-tooltip={currentTrack?.artist ?? ''}
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
@@ -175,8 +174,8 @@ export default function PlayerBar() {
/>
</div>
{/* EQ Popup */}
{eqOpen && (
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
{eqOpen && createPortal(
<>
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
<div className="eq-popup">
@@ -188,7 +187,8 @@ export default function PlayerBar() {
</div>
<Equalizer />
</div>
</>
</>,
document.body
)}
</footer>
+21 -10
View File
@@ -121,6 +121,7 @@ export default function QueuePanel() {
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentTime = usePlayerStore(s => s.currentTime);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
@@ -138,6 +139,7 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
@@ -284,14 +286,25 @@ export default function QueuePanel() {
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
const dur = h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
const remainingSecs = Math.max(0,
(queue[queueIndex]?.duration ?? 0) - currentTime
+ queue.slice(queueIndex + 1).reduce((acc, t) => acc + (t.duration || 0), 0)
);
const fmt = (secs: number) => {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
return (
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
<span
onClick={() => setShowRemainingTime(v => !v)}
data-tooltip={showRemainingTime ? t('queue.showTotal') : t('queue.showRemaining')}
style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}
>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</span>
);
@@ -316,16 +329,14 @@ export default function QueuePanel() {
)}
</div>
<div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.artist}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.album}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
+37 -5
View File
@@ -9,6 +9,37 @@ 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' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
],
},
{
group: 'Operating Systems',
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: [
@@ -47,12 +78,13 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
],
},
{
group: 'Psysonic Themes',
group: 'Movies',
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' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
],
},
];
+9 -2
View File
@@ -13,6 +13,9 @@ export default function TooltipPortal() {
const boxRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const tooltipRef = useRef<TooltipState | null>(null);
tooltipRef.current = tooltip;
useEffect(() => {
const onOver = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
@@ -26,15 +29,19 @@ export default function TooltipPortal() {
wrap: target.hasAttribute('data-tooltip-wrap'),
});
};
const onOut = (e: MouseEvent) => {
const onOut = () => setTooltip(null);
const onMove = (e: MouseEvent) => {
if (!tooltipRef.current) return;
const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (target) setTooltip(null);
if (!target) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
};
}, []);
-130
View File
@@ -1,130 +0,0 @@
import { useEffect, useRef } from 'react';
import butterchurn from 'butterchurn';
import butterchurnPresets from 'butterchurn-presets';
import { buildStreamUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
interface Props {
trackId: string;
nextPresetTrigger: number;
onPresetName: (name: string) => void;
}
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rafRef = useRef<number>(0);
const presetNamesRef = useRef<string[]>([]);
const presetMapRef = useRef<Record<string, unknown>>({});
const presetIdxRef = useRef(0);
// ── Init audio analysis + butterchurn ──────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Hidden audio element — routed through Web Audio for analysis only.
// NOT connected to AudioDestinationNode → completely silent.
// The Rust/rodio engine plays the actual audio.
const streamUrl = buildStreamUrl(trackId);
const audio = new Audio(streamUrl);
audio.crossOrigin = 'anonymous';
audioRef.current = audio;
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const source = ctx.createMediaElementSource(audio);
// Intentionally no: source.connect(ctx.destination)
// Size canvas to fill its container
const rect = canvas.getBoundingClientRect();
const w = rect.width || window.innerWidth;
const h = rect.height || window.innerHeight;
canvas.width = w * (window.devicePixelRatio || 1);
canvas.height = h * (window.devicePixelRatio || 1);
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
width: canvas.width,
height: canvas.height,
pixelRatio: window.devicePixelRatio || 1,
});
vizRef.current = visualizer;
visualizer.connectAudio(source);
// Presets
const presets = butterchurnPresets.getPresets();
const names = Object.keys(presets);
presetNamesRef.current = names;
presetMapRef.current = presets;
const startIdx = Math.floor(Math.random() * names.length);
presetIdxRef.current = startIdx;
visualizer.loadPreset(presets[names[startIdx]], 2.0);
onPresetName(names[startIdx]);
// Sync position + play state with main player
const { currentTime, isPlaying } = usePlayerStore.getState();
audio.currentTime = currentTime;
if (isPlaying) audio.play().catch(() => {});
// Render loop
const render = () => {
rafRef.current = requestAnimationFrame(render);
visualizer.render();
};
rafRef.current = requestAnimationFrame(render);
// Keep canvas sized to window
const onResize = () => {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
visualizer.setRendererSize(canvas.width, canvas.height);
};
window.addEventListener('resize', onResize);
// Sync play/pause with main player
const unsubscribe = usePlayerStore.subscribe(state => {
const a = audioRef.current;
const c = audioCtxRef.current;
if (!a || !c) return;
if (state.isPlaying) {
c.resume();
a.play().catch(() => {});
} else {
a.pause();
}
});
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', onResize);
unsubscribe();
audio.pause();
audio.src = '';
ctx.close();
vizRef.current = null;
};
}, [trackId]); // re-init on track change
// ── Next preset ────────────────────────────────────────────────────────────
useEffect(() => {
if (!nextPresetTrigger) return;
const viz = vizRef.current;
const names = presetNamesRef.current;
if (!viz || names.length === 0) return;
const next = (presetIdxRef.current + 1) % names.length;
presetIdxRef.current = next;
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
onPresetName(names[next]);
}, [nextPresetTrigger]);
return (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
);
}
+128 -20
View File
@@ -30,7 +30,9 @@ const enTranslation = {
mostPlayed: 'Most Played',
discover: 'Discover',
loadMore: 'Load More',
discoverMore: 'Discover More'
discoverMore: 'Discover More',
discoverArtists: 'Discover Artists',
discoverArtistsMore: 'All Artists'
},
hero: {
eyebrow: 'Featured Album',
@@ -56,6 +58,14 @@ 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',
genreInfo: 'Genre',
trackInfo: 'Track Info',
},
contextMenu: {
playNow: 'Play Now',
@@ -68,6 +78,9 @@ const enTranslation = {
favorite: 'Favorite',
favoriteArtist: 'Favorite Artist',
favoriteAlbum: 'Favorite Album',
unfavorite: 'Remove from Favorites',
unfavoriteArtist: 'Remove Artist from Favorites',
unfavoriteAlbum: 'Remove Album from Favorites',
removeFromQueue: 'Remove from Queue',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
@@ -261,6 +274,7 @@ const enTranslation = {
languageDe: 'German',
languageFr: 'French',
languageNl: 'Dutch',
font: 'Font',
theme: 'Theme',
appearance: 'Appearance',
servers: 'Servers',
@@ -335,8 +349,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 +422,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 +467,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 +512,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',
@@ -531,7 +558,9 @@ const deTranslation = {
mostPlayed: 'Meistgehört',
discover: 'Entdecken',
loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken'
discoverMore: 'Mehr entdecken',
discoverArtists: 'Künstler entdecken',
discoverArtistsMore: 'Alle Künstler'
},
hero: {
eyebrow: 'Album des Augenblicks',
@@ -557,6 +586,14 @@ 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',
genreInfo: 'Genre',
trackInfo: 'Track-Info',
},
contextMenu: {
playNow: 'Direkt abspielen',
@@ -569,6 +606,9 @@ const deTranslation = {
favorite: 'Favorisieren',
favoriteArtist: 'Künstler favorisieren',
favoriteAlbum: 'Album favorisieren',
unfavorite: 'Aus Favoriten entfernen',
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
unfavoriteAlbum: 'Album aus Favoriten entfernen',
removeFromQueue: 'Diesen Song entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
@@ -762,6 +802,7 @@ const deTranslation = {
languageDe: 'Deutsch',
languageFr: 'Französisch',
languageNl: 'Niederländisch',
font: 'Schriftart',
theme: 'Design',
appearance: 'Darstellung',
servers: 'Server',
@@ -836,7 +877,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 +950,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 +995,8 @@ const deTranslation = {
emptyQueue: 'Die Warteschlange ist leer.',
trackSingular: 'Titel',
trackPlural: 'Titel',
showRemaining: 'Restzeit anzeigen',
showTotal: 'Gesamtzeit anzeigen',
},
statistics: {
title: 'Statistiken',
@@ -984,8 +1040,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',
@@ -1032,7 +1086,9 @@ const frTranslation = {
mostPlayed: 'Les plus écoutés',
discover: 'Découvrir',
loadMore: 'Charger plus',
discoverMore: 'Découvrir plus'
discoverMore: 'Découvrir plus',
discoverArtists: 'Découvrir des artistes',
discoverArtistsMore: 'Tous les artistes'
},
hero: {
eyebrow: 'Album en vedette',
@@ -1058,6 +1114,14 @@ 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',
genreInfo: 'Genre',
trackInfo: 'Infos piste',
},
contextMenu: {
playNow: 'Lire maintenant',
@@ -1070,6 +1134,9 @@ const frTranslation = {
favorite: 'Favori',
favoriteArtist: 'Artiste favori',
favoriteAlbum: 'Album favori',
unfavorite: 'Retirer des favoris',
unfavoriteArtist: 'Retirer l\'artiste des favoris',
unfavoriteAlbum: 'Retirer l\'album des favoris',
removeFromQueue: 'Retirer de la file',
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
@@ -1263,6 +1330,7 @@ const frTranslation = {
languageDe: 'Allemand',
languageFr: 'Français',
languageNl: 'Néerlandais',
font: 'Police',
theme: 'Thème',
appearance: 'Apparence',
servers: 'Serveurs',
@@ -1337,7 +1405,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 +1478,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 +1523,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 +1568,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',
@@ -1533,7 +1614,9 @@ const nlTranslation = {
mostPlayed: 'Meest gespeeld',
discover: 'Ontdekken',
loadMore: 'Meer laden',
discoverMore: 'Meer ontdekken'
discoverMore: 'Meer ontdekken',
discoverArtists: 'Artiesten ontdekken',
discoverArtistsMore: 'Alle artiesten'
},
hero: {
eyebrow: 'Uitgelicht album',
@@ -1559,6 +1642,14 @@ 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',
genreInfo: 'Genre',
trackInfo: 'Trackinfo',
},
contextMenu: {
playNow: 'Nu afspelen',
@@ -1571,6 +1662,9 @@ const nlTranslation = {
favorite: 'Favoriet',
favoriteArtist: 'Favoriete artiest',
favoriteAlbum: 'Favoriet album',
unfavorite: 'Verwijderen uit favorieten',
unfavoriteArtist: 'Artiest uit favorieten verwijderen',
unfavoriteAlbum: 'Album uit favorieten verwijderen',
removeFromQueue: 'Uit wachtrij verwijderen',
openAlbum: 'Album openen',
goToArtist: 'Naar artiest',
@@ -1764,6 +1858,7 @@ const nlTranslation = {
languageDe: 'Duits',
languageFr: 'Frans',
languageNl: 'Nederlands',
font: 'Lettertype',
theme: 'Thema',
appearance: 'Weergave',
servers: 'Servers',
@@ -1838,7 +1933,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 +2006,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 +2051,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 +2096,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',
+5 -1
View File
@@ -29,6 +29,7 @@ export default function AlbumDetail() {
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -218,7 +219,10 @@ export default function AlbumDetail() {
hoveredSongId={hoveredSongId}
setHoveredSongId={setHoveredSongId}
ratings={ratings}
starredSongs={starredSongs}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
])}
onPlaySong={handlePlaySong}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
+1 -1
View File
@@ -341,7 +341,7 @@ export default function ArtistDetail() {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
+2 -2
View File
@@ -87,7 +87,7 @@ export default function Favorites() {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
};
return (
<div
@@ -106,7 +106,7 @@ export default function Favorites() {
{i + 1}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
+53 -27
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -10,20 +11,29 @@ export default function Home() {
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
]).then(([s, n, r, f]) => {
getArtists().catch(() => []),
]).then(([s, n, r, f, artists]) => {
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
// Pick 16 random artists via Fisher-Yates shuffle
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, []);
@@ -35,17 +45,15 @@ export default function Home() {
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
// Ensure we don't append duplicates if the API returns them
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) {
setter(prev => [...prev, ...newItems]);
}
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
}
};
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="animate-fade-in">
@@ -58,31 +66,49 @@ export default function Home() {
</div>
) : (
<>
<AlbumRow
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
/>
{randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{randomArtists.map(a => (
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
{a.name}
</button>
))}
<button className="artist-ext-link" onClick={() => navigate('/artists')}
style={{ opacity: 0.6 }}>
{t('home.discoverArtistsMore')}
</button>
</div>
</section>
)}
{starred.length > 0 && (
<AlbumRow
title={t('home.starred')}
albums={starred}
<AlbumRow
title={t('home.starred')}
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')}
moreText={t('home.loadMore')}
/>
)}
<AlbumRow
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.mostPlayed')}
albums={mostPlayed}
<AlbumRow
title={t('home.mostPlayed')}
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
moreText={t('home.loadMore')}
/>
</>
)}
+327 -336
View File
@@ -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,123 @@ 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 {
similarArtists: Array<{ id: string; name: string }>;
onArtistClick: (id: string) => void;
}
function strHash(s: string): number {
let h = 0;
for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
return h;
}
function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
const { t } = useTranslation();
if (similarArtists.length === 0) return null;
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
const h = strHash(name);
const sizePool = [12, 13, 14, 15, 16, 17, 18, 20, 22];
const size = sizePool[(h + idx * 7) % sizePool.length];
const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400;
const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px';
const opacity = 0.6 + ((h % 5) * 0.08);
const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2];
const ty = verticals[(h + idx * 4) % verticals.length];
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
};
return (
<div className="np-tag-cloud">
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '26px' } : undefined}>
{row.map((a, i) => (
<span
key={a.id}
className="np-tag np-tag-clickable"
style={getTagStyle(a.name, rowIdx * 3 + i)}
onClick={() => onArtistClick(a.id)}
data-tooltip={t('nowPlaying.goToArtist')}
>
{a.name}
</span>
))}
</div>
))}
</div>
);
}
// ─── Blurred background ───────────────────────────────────────────────────────
const NpBg = memo(function NpBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
@@ -65,9 +175,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,374 +184,257 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
);
});
// ─── Modals (reused from QueuePanel) ──────────────────────────────────────────
// ─── Album Tracklist ──────────────────────────────────────────────────────────
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>
);
interface NpTrackListProps {
albumTracks: SubsonicSong[];
currentTrackId: string;
album: string;
albumId?: string;
onNavigate: (path: string) => void;
}
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void; onLoad: (id: string) => void }) {
const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) {
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(); }
};
if (albumTracks.length === 0) return null;
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 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)' }}>{album}</em></h3>
{albumId && (
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-album-tracklist">
{albumTracks.map(track => {
const isActive = track.id === currentTrackId;
return (
<div key={track.id}
className={`np-album-track${isActive ? ' active' : ''}`}
onClick={() => albumId && onNavigate(`/album/${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>
);
}
});
// ─── 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 currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
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 setQueueVisible = usePlayerStore(s => s.setQueueVisible);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
// Hide queue panel while on this page, restore on leave
useEffect(() => {
const wasVisible = usePlayerStore.getState().isQueueVisible;
if (wasVisible) setQueueVisible(false);
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; }
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
const [ambilightColors, setAmbilightColors] = useState({
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
ml: '0,0,0', mr: '0,0,0',
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
});
useEffect(() => {
if (!resolvedCover) return;
const img = new Image();
img.onload = () => {
const S = 30;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, S, S);
const data = ctx.getImageData(0, 0, S, S).data;
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
const avg = (x0: number, y0: number, x1: number, y1: number) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = (y * S + x) * 4;
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
}
}
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);
}
};
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
};
setAmbilightColors({
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
});
};
img.src = resolvedCover;
}, [resolvedCover]);
// 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 ?? ''} />
<NpBg url={resolvedCover ?? ''} />
<div className="np-hero-content">
{currentTrack ? (
<>
{/* Cover + glow */}
<div className="np-cover-wrap">
{resolvedCover && (
<img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden="true" />
)}
<div className="np-main">
{currentTrack ? (
<>
{/* ── Hero Card ── */}
<div className="np-hero-card">
{/* Left: meta info */}
<div className="np-hero-left">
<div className="np-hero-info">
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentTrack.title}</div>
<div className="np-artist-album">
<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"
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"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
</div>
</div>
</div>
{/* Center: cover */}
<div className="np-hero-cover-wrap">
<div style={{
position: 'absolute', inset: '-20px', zIndex: 0,
background: `
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
`,
filter: 'blur(28px)',
}} />
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
}
</div>
{/* Meta */}
<div className="np-info">
<div className="np-title">{currentTrack.title}</div>
{/* Right: tag cloud */}
<TagCloud
similarArtists={similarArtists}
onArtistClick={id => navigate(`/artist/${id}`)}
/>
<div className="np-artist-album">
<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"
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"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
</div>
</div>
</>
) : (
<div className="np-empty-state">
<Music size={48} style={{ opacity: 0.3 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
)}
</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)}
{/* ── 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>
)}
</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>
<NpTrackList
albumTracks={albumTracks}
currentTrackId={currentTrack.id}
album={currentTrack.album}
albumId={currentTrack.albumId}
onNavigate={stableNavigate}
/>
</>
) : (
<div className="np-empty-state">
<Music size={48} style={{ opacity: 0.3 }} />
<p>{t('nowPlaying.nothingPlaying')}</p>
</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>
);
}
+8 -8
View File
@@ -210,7 +210,7 @@ export default function RandomMix() {
overflow: 'hidden',
}}>
{/* Left: Blacklist */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
@@ -291,7 +291,7 @@ export default function RandomMix() {
</div>
{/* Right: Genre Mix */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.genreMixTitle')}
</div>
@@ -342,7 +342,7 @@ export default function RandomMix() {
{genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }}
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
@@ -351,7 +351,7 @@ export default function RandomMix() {
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info"><span className="track-title" data-tooltip={song.title}>{song.title}</span></div>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
@@ -391,7 +391,7 @@ export default function RandomMix() {
draggable
onContextMenu={e => {
e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating };
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
@@ -415,15 +415,15 @@ export default function RandomMix() {
</button>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
</div>
{(() => {
+151 -18
View File
@@ -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();
@@ -207,10 +212,11 @@ export default function Settings() {
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ 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: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
];
@@ -325,11 +331,8 @@ export default function Settings() {
{/* Gapless */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div>
@@ -462,16 +465,6 @@ export default function Settings() {
{/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === 'appearance' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Globe size={18} />
@@ -492,9 +485,149 @@ export default function Settings() {
</div>
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</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' && (
<>
+1 -1
View File
@@ -224,7 +224,7 @@ export default function Statistics() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
{track.nowPlaying && (
<span style={{ fontSize: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend';
interface FontState {
font: FontId;
setFont: (font: FontId) => void;
}
export const useFontStore = create<FontState>()(
persist(
(set) => ({
font: 'lexend',
setFont: (font) => set({ font }),
}),
{ name: 'psysonic_font' }
)
);
+80
View File
@@ -0,0 +1,80 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type KeyAction =
| 'play-pause'
| 'next'
| 'prev'
| 'volume-up'
| 'volume-down'
| 'seek-forward'
| 'seek-backward'
| 'toggle-queue'
| 'fullscreen-player'
| 'native-fullscreen';
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
export type Bindings = Record<KeyAction, string | null>;
export const DEFAULT_BINDINGS: Bindings = {
'play-pause': 'Space',
'next': null,
'prev': null,
'volume-up': null,
'volume-down': null,
'seek-forward': null,
'seek-backward': null,
'toggle-queue': null,
'fullscreen-player': null,
'native-fullscreen': 'F11',
};
interface KeybindingsState {
bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void;
resetToDefaults: () => void;
}
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set) => ({
bindings: { ...DEFAULT_BINDINGS },
setBinding: (action, code) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}),
{ name: 'psysonic_keybindings' }
)
);
/** Format an e.code value into a human-readable label. */
export function formatKeyCode(code: string): string {
if (code === 'Space') return 'Space';
if (code === 'ArrowUp') return '↑';
if (code === 'ArrowDown') return '↓';
if (code === 'ArrowLeft') return '←';
if (code === 'ArrowRight') return '→';
if (code === 'Escape') return 'Esc';
if (code === 'Enter') return 'Enter';
if (code === 'Backspace') return '⌫';
if (code === 'Tab') return 'Tab';
if (code === 'Delete') return 'Del';
if (code === 'Home') return 'Home';
if (code === 'End') return 'End';
if (code === 'PageUp') return 'PgUp';
if (code === 'PageDown') return 'PgDn';
if (/^F\d+$/.test(code)) return code;
if (code.startsWith('Key')) return code.slice(3).toUpperCase();
if (code.startsWith('Digit')) return code.slice(5);
if (code === 'Minus') return '-';
if (code === 'Equal') return '=';
if (code === 'BracketLeft') return '[';
if (code === 'BracketRight') return ']';
if (code === 'Semicolon') return ';';
if (code === 'Quote') return "'";
if (code === 'Backslash') return '\\';
if (code === 'Comma') return ',';
if (code === 'Period') return '.';
if (code === 'Slash') return '/';
return code;
}
+9 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
@@ -23,6 +23,7 @@ export interface Track {
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -43,6 +44,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
};
}
@@ -58,6 +60,8 @@ interface PlayerState {
scrobbled: boolean;
lastfmLoved: boolean;
lastfmLovedCache: Record<string, boolean>;
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void;
@@ -151,9 +155,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const progress = current_time / dur;
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
// Scrobble at 50% directly via Last.fm
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
scrobbleSong(track.id, Date.now());
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
@@ -277,6 +282,8 @@ export const usePlayerStore = create<PlayerState>()(
scrobbled: false,
lastfmLoved: false,
lastfmLovedCache: {},
starredOverrides: {},
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
isQueueVisible: true,
isFullscreenOpen: false,
repeatMode: 'off',
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade';
interface ThemeState {
theme: Theme;
+400 -42
View File
@@ -435,6 +435,7 @@
background: var(--accent);
}
.album-card-info {
padding: var(--space-3) var(--space-4) var(--space-4);
}
@@ -664,16 +665,17 @@
z-index: 9000;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
padding-top: 8vh;
padding: 40px;
box-sizing: border-box;
cursor: zoom-out;
backdrop-filter: blur(6px);
}
.cover-lightbox-img {
max-width: 90vw;
max-height: 90vh;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius-lg);
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
@@ -1794,6 +1796,11 @@
}
/* ── Cover breathing ── */
@keyframes ambilight-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1.0; }
}
@keyframes cover-breathe {
0%,
@@ -2619,14 +2626,378 @@
}
/* ─ 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: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 28px;
padding: 32px;
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;
}
/* Left column: meta info, big text */
.np-hero-left {
min-width: 0;
display: flex;
align-items: center;
}
.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: 14px;
justify-content: center;
}
/* Right column: tag cloud */
.np-tag-cloud {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 0;
}
.np-tag-cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
justify-content: flex-end;
align-items: center;
overflow: visible;
}
.np-tag-cloud-header {
width: 100%;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 10px;
text-align: right;
}
.np-tag {
font-size: 13px;
font-weight: 600;
padding: 6px 14px;
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 +3008,7 @@
.np-bg-wrap {
position: absolute;
inset: 0 0 -80px 0;
inset: 0;
z-index: 0;
overflow: hidden;
contain: paint;
@@ -2648,15 +3019,24 @@
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(50px) brightness(0.25) saturate(1.8);
filter: blur(70px) brightness(0.55) saturate(1.6);
transition: opacity 0.6s ease;
will-change: opacity;
animation: np-ken-burns 40s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes np-ken-burns {
0% { transform: scale(1.00) translate(0%, 0%); }
25% { transform: scale(1.04) translate(-2%, -1.5%); }
50% { transform: scale(1.06) translate(1.5%, 2%); }
75% { transform: scale(1.03) translate(-1%, 1.5%); }
100% { transform: scale(1.05) translate(2%, -1%); }
}
.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 {
@@ -2681,35 +3061,13 @@
justify-content: center;
}
.np-cover-glow {
position: absolute;
width: 170px;
height: 170px;
border-radius: 50%;
object-fit: cover;
filter: blur(36px) saturate(1.5) brightness(0.85);
opacity: 0.7;
transform: translateY(12px) scale(1.05);
pointer-events: none;
z-index: 0;
will-change: transform, opacity;
animation: np-glow-pulse 4s ease-in-out infinite;
}
@keyframes np-glow-pulse {
0%, 100% { opacity: 0.6; transform: translateY(14px) scale(1.04); }
50% { opacity: 0.85; transform: translateY(9px) scale(1.10); }
}
.np-cover {
position: relative;
z-index: 1;
width: 160px;
height: 160px;
border-radius: 14px;
width: 280px;
height: 280px;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
animation: cover-breathe 6s ease-in-out infinite;
}
.np-cover-fallback {
@@ -2720,7 +3078,7 @@
color: var(--text-muted);
}
/* Info column */
/* Info column (legacy, kept for compat) */
.np-info {
display: flex;
flex-direction: column;
@@ -2732,18 +3090,18 @@
.np-title {
font-family: var(--font-display);
font-size: 26px;
font-size: 36px;
font-weight: 700;
line-height: 1.2;
line-height: 1.15;
color: white;
}
.np-artist-album {
font-size: 15px;
font-size: 19px;
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 +3118,7 @@
.np-tech-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
justify-content: flex-start;
gap: 8px;
align-items: center;
margin-top: 4px;
+4 -3
View File
@@ -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 {
@@ -575,7 +576,7 @@
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-app);
border-bottom: 1px solid var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
+2306 -13
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -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 };
}
+39
View File
@@ -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);
}