Compare commits

..

4 Commits

Author SHA1 Message Date
Psychotoxical d927ef2082 feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:06 +01:00
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
48 changed files with 5955 additions and 1182 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/
+144
View File
@@ -5,6 +5,150 @@ 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.12.0] - 2026-03-22
### Added
- **Synchronized Lyrics**: Lyrics pane integrated into the Queue sidebar, powered by [LRCLIB](https://lrclib.net) — no API key required. Shows time-synced lyrics with auto-scroll and active-line highlighting; falls back to plain text when synced lyrics are unavailable. Access via the microphone icon in the player bar, fullscreen player, or Now Playing page.
#### 15 New Themes
**Games** (new group — 6 themes):
- **Ascalon**: Dark stone fantasy inspired by Guild Wars 1. Near-black base, gold accent (`#d4af37`).
- **Azerothian Gold**: World of Warcraft inspired. Charcoal base, warm gold accent (`#c19e67`).
- **Grand Theft Audio**: GTA-inspired night city aesthetic. Pure black base, green accent (`#57b05a`).
- **Lambda 17**: Half-Life inspired. Deep blue-black base, amber accent (`#ff9d00`).
- **NightCity 2077**: Cyberpunk 2077 inspired. Near-total black base, neon yellow accent (`#FCEE0A`).
- **V-Tactical**: Battlefield inspired. Gunmetal base, burnt orange accent (`#ff8a00`).
**Series** (new group — 3 themes):
- **A Theme of Ice and Fire**: Game of Thrones inspired. Cold dark navy base, ice blue accent (`#70a1ff`).
- **D'oh-matic**: The Simpsons inspired. Cream/yellow light base, blue accent (`#1F75FE`).
- **Heisenberg**: Breaking Bad inspired. Dark desaturated green base, crystal blue accent (`#3fe0ff`).
**Movies** (2 additions):
- **Imperial Sith**: Star Wars dark side. Pure black base, red accent (`#e60000`).
- **Order of the Phoenix**: Harry Potter inspired. Deep charcoal base, ember-orange accent (`#e63900`).
**Operating Systems** (1 addition):
- **W98**: Windows 98 teal desktop aesthetic. Classic teal background, silver card, navy accent (`#000080`).
### Changed
- **Last.fm integration**: Promoted out of beta — scrobbling, Now Playing, love/unlove, Similar Artists, and top stats are considered stable.
- **Crossfade**: No longer marked experimental. Stable on Windows and Linux; macOS under observation.
- **Gapless playback**: Experimental badge removed — considered stable.
- **Theme picker — groups reorganised**: Catppuccin, Nord, and Retro (Gruvbox) merged into a single **Open Source Classics** group. Streaming themes (Spotless, DZR, Cupertino Beats) moved into **Psysonic Themes — Mediaplayer**. The app now ships **47 themes** across **7 groups**.
- **Tokyo Night themes removed**: `tokyo-night`, `tokyo-night-storm`, and `tokyo-night-light` retired to make room for the new groups.
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
### Fixed
- **Text selection on double-click**: Double-clicking song titles or anywhere in the UI no longer accidentally selects text. `user-select: none` applied globally; re-enabled for bio/description text areas.
- **Middle Earth theme — star buttons**: Active favourite star in the album tracklist and album header was barely visible (gold on parchment, ~1.4:1 contrast). Both active and inactive states now use darker brown tones with proper contrast.
- **Middle Earth theme — play button hover**: Hovering the primary play/pause button no longer makes the icon invisible (gold icon on gold background).
## [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
+60 -26
View File
@@ -50,7 +50,13 @@ 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 (47 themes across 7 groups), applied as `data-theme` on `<html>` |
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
| `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,33 +113,59 @@ 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.
47 themes across 7 groups, selectable in Settings via `ThemePicker`. `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 |
|---|---|---|---|
| `mocha` | Catppuccin | dark | Mauve |
| `macchiato` | Catppuccin | medium-dark | Mauve |
| `frappe` | Catppuccin | medium | Mauve |
| `latte` | Catppuccin | light | Mauve |
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
| `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` |
| `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` |
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` |
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
| `latte` | Open Source Classics | Catppuccin light | Mauve |
| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` |
**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 +227,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 +242,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.12.0
+11 -12
View File
@@ -22,17 +22,19 @@ 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**: 47 deeply integrated themes across 7 groups — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Psysonic Mediaplayer — 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.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **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,23 +46,20 @@ 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] Last.fm scrobbling, Now Playing & love/unlove
- [x] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [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] 47 themes across 7 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Psysonic originals, Psysonic Mediaplayer
- [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS)
### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 📋 Planned
- [ ] FLAC seeking fix
+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.12.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.12.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.12.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.8.0"
version = "1.12.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+363 -52
View File
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
@@ -209,7 +210,7 @@ impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
(t * std::f32::consts::FRAC_PI_2).sin()
};
self.sample_count += 1;
Some(sample * gain)
Some((sample * gain).clamp(-1.0, 1.0))
}
}
@@ -226,6 +227,82 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
}
}
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
//
// Every track source is wrapped with this. It passes through at unity gain
// until `trigger` is set to true, at which point it reads `fade_total_samples`
// and applies a cos(t·π/2) envelope:
// gain(t) = cos(t · π/2), t ∈ [0, 1]
// At t = 0 gain = 1, at t = 1 gain = 0.
// After the fade completes, returns None to exhaust the source.
//
// Combined with EqualPowerFadeIn (sin curve) on Track B, this gives a
// symmetric constant-power crossfade: sin²+cos² = 1.
struct TriggeredFadeOut<S: Source<Item = f32>> {
inner: S,
trigger: Arc<AtomicBool>,
fade_total_samples: Arc<AtomicU64>,
fade_progress: u64,
fading: bool,
cached_total: u64,
}
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
Self {
inner,
trigger,
fade_total_samples,
fade_progress: 0,
fading: false,
cached_total: 0,
}
}
}
impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
// Check trigger on first fade sample only (avoid atomic load per sample).
if !self.fading && self.trigger.load(Ordering::Relaxed) {
self.fading = true;
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
self.fade_progress = 0;
}
if self.fading {
if self.fade_progress >= self.cached_total {
// Fade complete — exhaust the source.
return None;
}
let sample = self.inner.next()?;
let t = self.fade_progress as f32 / self.cached_total as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
self.fade_progress += 1;
Some((sample * gain).clamp(-1.0, 1.0))
} else {
self.inner.next()
}
}
}
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// If we seek back during a fade, cancel the fade.
if self.fading {
self.fading = false;
self.trigger.store(false, Ordering::Relaxed);
}
self.fade_progress = 0;
self.inner.try_seek(pos)
}
}
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
//
// This is the key mechanism for gapless: the progress task polls `done` to know
@@ -269,6 +346,48 @@ impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
}
}
// ─── CountingSource — atomic sample counter for drift-free position tracking ─
//
// Wraps the outermost source and increments a shared AtomicU64 on every sample.
// The progress task reads this counter and divides by (sample_rate * channels)
// to get the exact playback position — no wall-clock drift.
struct CountingSource<S: Source<Item = f32>> {
inner: S,
counter: Arc<AtomicU64>,
}
impl<S: Source<Item = f32>> CountingSource<S> {
fn new(inner: S, counter: Arc<AtomicU64>) -> Self {
Self { inner, counter }
}
}
impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
let sample = self.inner.next();
if sample.is_some() {
self.counter.fetch_add(1, Ordering::Relaxed);
}
sample
}
}
impl<S: Source<Item = f32>> Source for CountingSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// Reset counter to the sought position in samples.
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
* self.inner.channels() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
self.inner.try_seek(pos)
}
}
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
//
// MP3/AAC encoders prepend an "encoder delay" (typically 5762112 silent
@@ -326,12 +445,28 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
}
/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify.
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
struct BuiltSource {
source: CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>,
duration_secs: f64,
output_rate: u32,
output_channels: u16,
/// Trigger for the sample-level crossfade fade-out.
fadeout_trigger: Arc<AtomicBool>,
/// Total samples for the fade-out (set before triggering).
fadeout_samples: Arc<AtomicU64>,
}
/// Build a fully-prepared playback source:
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
///
/// `fade_in_dur`:
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
///
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
fn build_source(
data: Vec<u8>,
duration_hint: f64,
@@ -339,12 +474,15 @@ fn build_source(
eq_enabled: Arc<AtomicBool>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
) -> Result<(NotifyingSource<EqualPowerFadeIn<EqSource<DynSource>>>, f64), String> {
sample_counter: Arc<AtomicU64>,
target_rate: u32,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
// Determine effective duration.
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
@@ -356,7 +494,8 @@ fn build_source(
.unwrap_or(duration_hint)
};
// Apply encoder-delay trim and optional end-padding trim.
// Apply encoder-delay trim and optional end-padding trim,
// then resample to the canonical target rate if needed.
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
let delay_dur = Duration::from_secs_f64(
gapless.delay_samples as f64 / sample_rate as f64
@@ -365,19 +504,47 @@ fn build_source(
if let Some(total) = gapless.total_valid_samples {
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
DynSource::new(base.take_duration(valid_dur))
let trimmed = base.take_duration(valid_dur);
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate))
} else {
DynSource::new(trimmed)
}
} else {
DynSource::new(base)
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(base, channels, target_rate))
} else {
DynSource::new(base)
}
}
} else {
DynSource::new(decoder.convert_samples::<f32>())
let converted = decoder.convert_samples::<f32>();
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
} else {
DynSource::new(converted)
}
};
let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate };
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let notifying = NotifyingSource::new(fade_in, done_flag);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
let counting = CountingSource::new(notifying, sample_counter);
Ok((notifying, effective_dur))
Ok(BuiltSource {
source: counting,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
fadeout_trigger,
fadeout_samples,
})
}
// ─── Engine state ─────────────────────────────────────────────────────────────
@@ -389,7 +556,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,
@@ -397,6 +564,9 @@ struct ChainedInfo {
base_volume: f32,
/// Set by NotifyingSource when this chained track's source is exhausted.
source_done: Arc<AtomicBool>,
/// Atomic sample counter for this chained source (swapped into
/// samples_played on transition).
sample_counter: Arc<AtomicU64>,
}
pub struct AudioEngine {
@@ -417,6 +587,16 @@ pub struct AudioEngine {
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
/// Atomic sample counter — incremented by CountingSource in the audio thread.
/// Progress task reads this for drift-free position tracking.
pub samples_played: Arc<AtomicU64>,
/// Sample rate of the currently playing source (for samples → seconds).
pub current_sample_rate: Arc<AtomicU32>,
/// Channel count of the currently playing source.
pub current_channels: Arc<AtomicU32>,
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
pub gapless_switch_at: Arc<AtomicU64>,
}
pub struct AudioCurrent {
@@ -427,6 +607,10 @@ pub struct AudioCurrent {
pub paused_at: Option<f64>,
pub replay_gain_linear: f32,
pub base_volume: f32,
/// Crossfade: trigger for sample-level fade-out of the current source.
pub fadeout_trigger: Option<Arc<AtomicBool>>,
/// Crossfade: total fade samples (set before triggering).
pub fadeout_samples: Option<Arc<AtomicU64>>,
}
impl AudioCurrent {
@@ -446,6 +630,29 @@ 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");
}
}
// macOS: request a smaller CoreAudio buffer to reduce output latency.
// Smaller buffers = lower latency between decoded samples and DAC output,
// which tightens the gap between actual audio and UI event delivery.
#[cfg(target_os = "macos")]
{
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
std::env::set_var("COREAUDIO_BUFFER_SIZE", "512");
}
}
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
.spawn(move || match rodio::OutputStream::try_default() {
@@ -469,6 +676,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder()
@@ -483,6 +692,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
};
(engine, thread)
@@ -561,6 +774,24 @@ pub async fn audio_play(
) -> Result<(), String> {
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
// After a gapless auto-advance, the frontend may fire a stale playTrack()
// call via IPC. If we're within 500 ms of the last gapless switch AND the
// requested URL matches the already-playing chained track, reject it.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
// Within the guard window — suppress this ghost command.
return Ok(());
}
}
}
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
@@ -627,16 +858,29 @@ pub async fn audio_play(
Duration::from_millis(5)
};
// Build source: decode → trim → EQ → fade-in → notify.
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
// Store the actual output rate/channels for position calculation.
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
@@ -668,11 +912,12 @@ pub async fn audio_play(
sink.append(source);
// Atomically swap sinks.
let (old_sink, old_vol) = {
// Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
@@ -680,39 +925,33 @@ pub async fn audio_play(
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
(old, old_vol)
cur.fadeout_trigger = Some(built.fadeout_trigger);
cur.fadeout_samples = Some(built.fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
// Handle old sink: equal-power crossfade or immediate stop.
// Handle old sink: symmetric crossfade or immediate stop.
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
// Duration = actual_fade_secs (Track A's measured remaining time),
// so the fade reaches exactly 0 when the source is exhausted.
const STEP_MS: u64 = 10;
let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32;
for i in (0..=total_steps).rev() {
let alive = {
let fo = fo_arc.lock().unwrap();
match fo.as_ref() {
Some(s) => {
// Equal-power cos curve: gain_a = cos(t · π/2)
// t goes 1→0 as i goes total_steps→0
let t = i as f32 / total_steps as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
s.set_volume(old_vol * gain);
true
}
None => false,
}
// MutexGuard dropped here before the await
};
if !alive { return; }
tokio::time::sleep(Duration::from_millis(STEP_MS)).await;
}
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
@@ -734,6 +973,10 @@ pub async fn audio_play(
state.crossfade_secs.clone(),
done_flag,
app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
);
Ok(())
@@ -802,14 +1045,22 @@ pub async fn audio_chain_preload(
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let done_next = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
@@ -834,6 +1085,7 @@ pub async fn audio_chain_preload(
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
sample_counter: chain_counter,
});
Ok(())
@@ -846,6 +1098,12 @@ pub async fn audio_chain_preload(
/// done flag is set AND `chained_info` has data, it swaps `done` to the
/// chained source's flag and transitions state — all without creating a new
/// task or changing the generation counter.
///
/// Key changes from the previous implementation:
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
/// • Position from atomic sample counter (no wall-clock drift)
/// • Immediate `audio:track_switched` event at decoder boundary
/// • `audio:ended` only fires when no chained successor exists
fn spawn_progress_task(
gen: u64,
gen_counter: Arc<AtomicU64>,
@@ -855,6 +1113,10 @@ fn spawn_progress_task(
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
app: AppHandle,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
) {
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
@@ -862,7 +1124,8 @@ fn spawn_progress_task(
let mut current_done = initial_done;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
tokio::time::sleep(Duration::from_millis(100)).await;
if gen_counter.load(Ordering::SeqCst) != gen {
break;
@@ -871,24 +1134,40 @@ fn spawn_progress_task(
// ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit
// audio:playing for the new track, and continue the loop.
// audio:track_switched for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
current_done = info.source_done;
// Tracking was already updated in audio_play_gapless_chain;
// just update replay gain fields in case they differ.
// Swap the sample counter: the chained source's counter
// is already being incremented by CountingSource. Copy its
// current value into the shared samples_played so the
// progress calculation stays accurate.
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
samples_played.store(chained_samples, Ordering::Relaxed);
// Update tracking state.
{
let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
// Reset play_started to now — the old track physically
// ended, the new one is now actively producing samples.
cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
}
app.emit("audio:playing", info.duration_secs).ok();
// Record the gapless switch timestamp for ghost-command guard.
let switch_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
// Emit the new track_switched event — this is immediate,
// not delayed by 500 ms like the old audio:playing was.
app.emit("audio:track_switched", info.duration_secs).ok();
near_end_ticks = 0;
continue;
}
@@ -897,9 +1176,26 @@ fn spawn_progress_task(
// the near-end logic below.
}
let (pos, dur, is_paused) = {
// ── Position from atomic sample counter ──────────────────────────
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
let ch = channels_arc.load(Ordering::Relaxed) as f64;
let samples = samples_played.load(Ordering::Relaxed) as f64;
let divisor = (rate * ch).max(1.0);
let dur = {
let cur = current_arc.lock().unwrap();
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
cur.duration_secs
};
let is_paused = {
let cur = current_arc.lock().unwrap();
cur.paused_at.is_some()
};
let pos = if is_paused {
let cur = current_arc.lock().unwrap();
cur.paused_at.unwrap_or(0.0)
} else {
(samples / divisor).min(dur.max(0.001))
};
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
@@ -914,7 +1210,8 @@ fn spawn_progress_task(
if dur > end_threshold && pos >= dur - end_threshold {
near_end_ticks += 1;
if near_end_ticks >= 2 {
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
@@ -969,6 +1266,20 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
return Ok(());
}
}
}
// Seeking far back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.8.0",
"version": "1.12.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,7 +23,8 @@
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false
"dragDropEnabled": false,
"devtools": false
}
],
"security": {
+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();
}, []);
+47
View File
@@ -0,0 +1,47 @@
export interface LrclibLyrics {
syncedLyrics: string | null;
plainLyrics: string | null;
}
export interface LrcLine {
time: number; // seconds
text: string;
}
export async function fetchLyrics(
artist: string,
title: string,
album: string,
duration: number,
): Promise<LrclibLyrics | null> {
const params = new URLSearchParams({
artist_name: artist,
track_name: title,
album_name: album,
duration: Math.round(duration).toString(),
});
try {
const res = await fetch(`https://lrclib.net/api/get?${params}`);
if (!res.ok) return null;
const data = await res.json();
return {
syncedLyrics: data.syncedLyrics ?? null,
plainLyrics: data.plainLyrics ?? null,
};
} catch {
return null;
}
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const line of lrc.split('\n')) {
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
if (!match) continue;
const mins = parseInt(match[1], 10);
const secs = parseFloat(match[2]);
const text = match[3].trim();
lines.push({ time: mins * 60 + secs, text });
}
return lines.sort((a, b) => a.time - b.time);
}
+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>
+1 -1
View File
@@ -191,7 +191,7 @@ export default function AlbumHeader({
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+3 -2
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">
@@ -155,7 +156,7 @@ export default function AlbumTrackList({
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
+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
);
}
+17 -48
View File
@@ -1,13 +1,13 @@
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, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
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,9 +148,10 @@ 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 showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
@@ -182,55 +183,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">
@@ -286,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={14} />
</button>
</div>
</div>
+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" />
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
interface Props {
currentTrack: Track | null;
}
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1);
useEffect(() => {
if (!currentTrack || currentTrack.id === fetchedFor) return;
let cancelled = false;
setSyncedLines(null);
setPlainLyrics(null);
setNotFound(false);
setLoading(true);
lineRefs.current = [];
prevActive.current = -1;
fetchLyrics(
currentTrack.artist ?? '',
currentTrack.title,
currentTrack.album ?? '',
currentTrack.duration ?? 0,
).then(result => {
if (cancelled) return;
setLoading(false);
setFetchedFor(currentTrack.id);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
setNotFound(true);
return;
}
if (result.syncedLyrics) {
const lines = parseLrc(result.syncedLyrics);
setSyncedLines(lines.length > 0 ? lines : null);
}
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) { setLoading(false); setNotFound(true); }
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset when track changes
useEffect(() => {
setFetchedFor(null);
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
useEffect(() => {
if (activeIdx < 0 || activeIdx === prevActive.current) return;
prevActive.current = activeIdx;
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeIdx]);
if (!currentTrack) {
return (
<div className="lyrics-pane-empty">
<p className="lyrics-status">{t('player.lyricsNotFound')}</p>
</div>
);
}
return (
<div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
{hasSynced && (
<div className="lyrics-synced">
{syncedLines!.map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
>
{line.text || '\u00A0'}
</div>
))}
</div>
)}
{!hasSynced && plainLyrics && (
<div className="lyrics-plain">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
))}
</div>
)}
</div>
);
}
+21 -7
View File
@@ -1,7 +1,8 @@
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
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -11,6 +12,7 @@ import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -23,11 +25,14 @@ export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
@@ -74,7 +79,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 +86,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}`)}
>
@@ -116,7 +119,7 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
@@ -141,6 +144,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* Lyrics Button */}
<button
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={15} />
</button>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
@@ -175,8 +188,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 +201,8 @@ export default function PlayerBar() {
</div>
<Equalizer />
</div>
</>
</>,
document.body
)}
</footer>
+51 -12
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -121,6 +123,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 +141,10 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
@@ -284,14 +291,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 +334,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>
@@ -337,7 +353,8 @@ export default function QueuePanel() {
</div>
)}
<div className="queue-toolbar">
{activeTab === 'queue' ? (<>
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
@@ -461,6 +478,28 @@ export default function QueuePanel() {
})
)}
</div>
</>) : (
<LyricsPane currentTrack={currentTrack} />
)}
<div className="queue-tab-bar">
<button
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
onClick={() => setTab('queue')}
aria-label={t('queue.title')}
>
<ListMusic size={14} />
{t('queue.title')}
</button>
<button
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
onClick={() => setTab('lyrics')}
aria-label={t('player.lyrics')}
>
<MicVocal size={14} />
{t('player.lyrics')}
</button>
</div>
{saveModalOpen && (
<SavePlaylistModal
+151 -105
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react';
import { useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef {
id: string;
@@ -10,49 +11,85 @@ interface ThemeDef {
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Catppuccin',
group: 'Games',
themes: [
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
],
},
{
group: 'Nord',
group: 'Movies',
themes: [
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
],
},
{
group: 'Retro',
group: 'Open Source Classics',
themes: [
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
],
},
{
group: 'Tokyo Night',
group: 'Operating Systems',
themes: [
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Psysonic Themes',
themes: [
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
],
},
{
group: 'Series',
themes: [
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
],
},
];
@@ -63,93 +100,102 @@ interface Props {
}
export default function ThemePicker({ value, onChange }: Props) {
const initialOpen = THEME_GROUPS.find(g => g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group;
const [openGroup, setOpenGroup] = useState<string | null>(initialOpen);
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{THEME_GROUPS.map(({ group, themes }) => (
<div key={group}>
<div style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-muted)',
marginBottom: '10px',
}}>
{group}
<div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => {
const isOpen = openGroup === group;
const hasActive = themes.some(t => t.id === value);
return (
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
<button className="theme-accordion-header" onClick={() => toggle(group)}>
<span>
{group}
{hasActive && !isOpen && (
<span className="theme-accordion-active-dot" />
)}
</span>
<ChevronDown size={15} className="theme-accordion-chevron" />
</button>
{isOpen && (
<div className="theme-accordion-content">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
{/* main bg */}
<div style={{ background: t.bg, height: '55%' }} />
{/* card tone */}
<div style={{ background: t.card, height: '20%' }} />
{/* accent bar */}
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
))}
);
})}
</div>
);
}
+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' }}
/>
);
}
+144 -24
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',
@@ -498,7 +525,10 @@ const enTranslation = {
repeatOne: 'One',
progress: 'Song Progress',
volume: 'Volume',
toggleQueue: 'Toggle Queue'
toggleQueue: 'Toggle Queue',
lyrics: 'Lyrics',
lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track',
}
};
@@ -531,7 +561,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 +589,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 +609,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 +805,7 @@ const deTranslation = {
languageDe: 'Deutsch',
languageFr: 'Französisch',
languageNl: 'Niederländisch',
font: 'Schriftart',
theme: 'Design',
appearance: 'Darstellung',
servers: 'Server',
@@ -836,7 +880,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 +953,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 +998,8 @@ const deTranslation = {
emptyQueue: 'Die Warteschlange ist leer.',
trackSingular: 'Titel',
trackPlural: 'Titel',
showRemaining: 'Restzeit anzeigen',
showTotal: 'Gesamtzeit anzeigen',
},
statistics: {
title: 'Statistiken',
@@ -984,8 +1043,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',
@@ -999,7 +1056,10 @@ const deTranslation = {
repeatOne: 'Einen',
progress: 'Songfortschritt',
volume: 'Lautstärke',
toggleQueue: 'Warteschlange umschalten'
toggleQueue: 'Warteschlange umschalten',
lyrics: 'Lyrics',
lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
}
};
@@ -1032,7 +1092,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 +1120,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 +1140,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 +1336,7 @@ const frTranslation = {
languageDe: 'Allemand',
languageFr: 'Français',
languageNl: 'Néerlandais',
font: 'Police',
theme: 'Thème',
appearance: 'Apparence',
servers: 'Serveurs',
@@ -1337,7 +1411,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 +1484,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 +1529,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 +1574,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',
@@ -1500,7 +1587,10 @@ const frTranslation = {
repeatOne: 'Un',
progress: 'Progression',
volume: 'Volume',
toggleQueue: 'Afficher/masquer la file'
toggleQueue: 'Afficher/masquer la file',
lyrics: 'Paroles',
lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
}
};
@@ -1533,7 +1623,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 +1651,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 +1671,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 +1867,7 @@ const nlTranslation = {
languageDe: 'Duits',
languageFr: 'Frans',
languageNl: 'Nederlands',
font: 'Lettertype',
theme: 'Thema',
appearance: 'Weergave',
servers: 'Servers',
@@ -1838,7 +1942,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 +2015,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 +2060,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 +2105,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',
@@ -2001,7 +2118,10 @@ const nlTranslation = {
repeatOne: 'Één',
progress: 'Nummervoortgang',
volume: 'Volume',
toggleQueue: 'Wachtrij in-/uitschakelen'
toggleQueue: 'Wachtrij in-/uitschakelen',
lyrics: 'Songtekst',
lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
}
};
+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')}
/>
</>
)}
+340 -336
View File
@@ -1,14 +1,13 @@
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, MicVocal } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
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 +19,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 +40,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 = [10, 11, 12, 13, 14, 15, 16];
const size = sizePool[(h + idx * 7) % sizePool.length];
const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
const pad = size >= 15 ? '5px 10px' : '4px 8px';
const opacity = 0.6 + ((h % 5) * 0.08);
const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1];
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: '14px' } : 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 +176,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 +185,269 @@ 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 showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
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>
<button
className="np-star-btn"
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
data-tooltip={t('player.lyrics')}
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : 'white' }}
>
<MicVocal size={17} />
</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;
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand';
type SidebarTab = 'queue' | 'lyrics';
interface LyricsState {
activeTab: SidebarTab;
setTab: (tab: SidebarTab) => void;
showLyrics: () => void;
showQueue: () => void;
}
export const useLyricsStore = create<LyricsState>()((set) => ({
activeTab: 'queue',
setTab: (tab) => set({ activeTab: tab }),
showLyrics: () => set({ activeTab: 'lyrics' }),
showQueue: () => set({ activeTab: 'queue' }),
}));
+81 -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;
@@ -123,6 +127,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -151,9 +159,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);
@@ -199,6 +208,12 @@ function handleAudioProgress(current_time: number, duration: number) {
}
function handleAudioEnded() {
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - lastGaplessSwitchTime < 600) {
return;
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
@@ -211,6 +226,61 @@ function handleAudioEnded() {
}, 150);
}
/**
* Handle gapless auto-advance: the Rust engine has already switched to the
* next source sample-accurately. We just need to update the UI state without
* touching the audio stream (no playTrack() call!).
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
isAudioPaused = false;
const store = usePlayerStore.getState();
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queue.length) {
nextTrack = queue[nextIdx];
newIndex = nextIdx;
} else if (repeatMode === 'all' && queue.length > 0) {
nextTrack = queue[0];
newIndex = 0;
}
if (!nextTrack) return;
usePlayerStore.setState({
currentTrack: nextTrack,
queueIndex: newIndex,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
lastfmLoved: false,
});
// Report Now Playing to Navidrome + Last.fm
reportNowPlaying(nextTrack.id);
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(queue, nextTrack, 0);
}
function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false;
@@ -236,6 +306,7 @@ export function initAudioListeners(): () => void {
),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
];
// Sync Last.fm loved tracks cache on startup.
@@ -277,6 +348,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',
@@ -357,6 +430,12 @@ export const usePlayerStore = create<PlayerState>()(
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
return;
}
const gen = ++playGeneration;
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
+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' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade' | 'imperial-sith' | 'order-of-the-phoenix' | 'heisenberg' | 'ice-and-fire' | 'doh-matic';
interface ThemeState {
theme: Theme;
+570 -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);
@@ -933,6 +935,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.track-row.track-row-va {
@@ -1212,6 +1215,7 @@
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
user-select: text;
}
.artist-bio a {
@@ -1299,6 +1303,7 @@
line-height: 1.75;
color: var(--text-secondary);
max-width: 100%;
user-select: text;
}
.artist-bio-text a {
@@ -1485,6 +1490,170 @@
overflow: hidden;
}
/* ── Theme Accordion ─────────────────────────────────────────────────────── */
.theme-accordion {
display: flex;
flex-direction: column;
}
.theme-accordion-item {
border-bottom: 1px solid var(--border-subtle);
}
.theme-accordion-item:last-child {
border-bottom: none;
}
.theme-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
width: 100%;
padding: 10px var(--space-4);
text-align: left;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
background: var(--bg-card);
border-left: 3px solid transparent;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.theme-accordion-header:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.theme-accordion-open .theme-accordion-header {
color: var(--accent);
background: var(--bg-hover);
border-left: 3px solid var(--accent);
padding-left: calc(var(--space-5) + 3px);
}
.theme-accordion-chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.theme-accordion-open .theme-accordion-chevron {
transform: rotate(180deg);
color: var(--accent);
}
.theme-accordion-content {
padding: 14px var(--space-4) var(--space-4) calc(var(--space-4) + 3px);
background: var(--bg-app);
border-top: 1px solid var(--border-subtle);
border-left: 3px solid var(--accent);
}
.theme-accordion-active-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
margin-left: 8px;
vertical-align: middle;
opacity: 0.8;
}
/* ── Queue Tab Bar ────────────────────────────────────────────────────────── */
.queue-tab-bar {
display: flex;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.queue-tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
transition: color 0.15s, background 0.15s;
}
.queue-tab-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
.queue-tab-btn.active { color: var(--accent); }
/* ── Lyrics Pane (sidebar) ────────────────────────────────────────────────── */
.lyrics-pane {
flex: 1;
overflow-y: auto;
padding: 16px 16px 8px;
scroll-behavior: smooth;
}
.lyrics-pane-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.lyrics-status {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 40px 0;
}
.lyrics-synced {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0 16px;
}
.lyrics-line {
font-size: 14px;
font-weight: 400;
color: var(--text-muted);
text-align: center;
padding: 5px 8px;
border-radius: 6px;
line-height: 1.6;
transition: color 0.25s, font-size 0.2s, font-weight 0.2s;
cursor: default;
}
.lyrics-line.active {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
}
.lyrics-plain {
padding: 4px 0 16px;
}
.lyrics-plain-line {
font-size: 13px;
line-height: 1.85;
color: var(--text-secondary);
text-align: center;
margin: 0;
}
/* ── Help ─────────────────────────────────────────────────────────────────── */
.help-item {
border-bottom: 1px solid var(--border-subtle);
}
@@ -1794,6 +1963,11 @@
}
/* ── Cover breathing ── */
@keyframes ambilight-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1.0; }
}
@keyframes cover-breathe {
0%,
@@ -2327,6 +2501,7 @@
padding: 6px var(--space-3);
border-radius: var(--radius-md);
transition: background 0.15s;
user-select: none;
}
.playlist-row:hover {
@@ -2619,14 +2794,379 @@
}
/* ─ 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);
user-select: text;
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 +3177,7 @@
.np-bg-wrap {
position: absolute;
inset: 0 0 -80px 0;
inset: 0;
z-index: 0;
overflow: hidden;
contain: paint;
@@ -2648,15 +3188,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 +3230,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 +3247,7 @@
color: var(--text-muted);
}
/* Info column */
/* Info column (legacy, kept for compat) */
.np-info {
display: flex;
flex-direction: column;
@@ -2732,18 +3259,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 +3287,7 @@
.np-tech-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
justify-content: flex-start;
gap: 8px;
align-items: center;
margin-top: 4px;
@@ -2970,6 +3497,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.np-queue-item:hover,
+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;
+3340 -158
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);
}