mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,50 @@ 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/),
|
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).
|
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
|
## [1.11.0] - 2026-03-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
|||||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
| `src/store/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/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-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 (30 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/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/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/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. |
|
||||||
@@ -110,7 +113,7 @@ 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`.
|
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
|
||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
30 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
|
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 WnAmp 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).
|
||||||
|
|
||||||
@@ -125,30 +128,44 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
|
|||||||
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
|
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
|
||||||
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
|
||||||
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
|
||||||
| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` |
|
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
|
||||||
| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
|
||||||
| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` |
|
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
|
||||||
| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` |
|
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
|
||||||
| `mocha` | Catppuccin | dark | Mauve |
|
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
|
||||||
| `macchiato` | Catppuccin | medium-dark | Mauve |
|
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
|
||||||
| `frappe` | Catppuccin | medium | Mauve |
|
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
|
||||||
| `latte` | Catppuccin | light | Mauve |
|
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
|
||||||
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
|
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
|
||||||
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
|
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
|
||||||
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
|
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
|
||||||
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
|
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
|
||||||
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
|
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
|
||||||
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
|
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
|
||||||
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
|
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
|
||||||
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
|
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
|
||||||
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
|
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
|
||||||
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
|
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
|
||||||
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
|
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
|
||||||
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
|
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
|
||||||
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
|
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
|
||||||
| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` |
|
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
|
||||||
| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` |
|
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
|
||||||
| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` |
|
| `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).
|
**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).
|
||||||
|
|
||||||
@@ -227,4 +244,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
|||||||
- **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`).
|
- **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`).
|
||||||
- **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.
|
- **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.
|
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
|
||||||
- **Version**: 1.11.0
|
- **Version**: 1.12.0
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎨 **Gorgeous UI**: 30 deeply integrated themes across 7 groups — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme (Cupertino, Aero Glass, Luna Teal), and Psysonic originals (WnAmp, Poison, Nucleo, Neon Drift…) — with smooth glassmorphism effects and micro-animations.
|
- 🎨 **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.
|
- ⚡ **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.
|
- 🌍 **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.
|
- 📻 **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.
|
- 💾 **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 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.
|
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||||
@@ -47,22 +48,19 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
|||||||
- [x] Replay Gain (track + album mode)
|
- [x] Replay Gain (track + album mode)
|
||||||
- [x] Gapless playback
|
- [x] Gapless playback
|
||||||
- [x] Waveform seekbar
|
- [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] Similar Artists via Last.fm, filtered to library
|
||||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||||
|
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
|
||||||
- [x] Multi-server support
|
- [x] Multi-server support
|
||||||
- [x] IndexedDB image caching
|
- [x] IndexedDB image caching
|
||||||
- [x] Random Mix with keyword filter & Super Genre mix
|
- [x] Random Mix with keyword filter & Super Genre mix
|
||||||
- [x] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, Psysonic originals, Psysonic Mediaplayer
|
- [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] Internationalization (English, German, French, Dutch)
|
||||||
- [x] AUR package (Arch / CachyOS)
|
- [x] AUR package (Arch / CachyOS)
|
||||||
- [x] Configurable keybindings
|
- [x] Configurable keybindings
|
||||||
- [x] Font picker (10 UI fonts)
|
- [x] Font picker (10 UI fonts)
|
||||||
|
|
||||||
### 🚧 In Progress
|
|
||||||
- [ ] **Last.fm integration** — stabilising, moving out of beta
|
|
||||||
- [ ] **Crossfade** — stability improvements
|
|
||||||
|
|
||||||
### 📋 Planned
|
### 📋 Planned
|
||||||
- [ ] FLAC seeking fix
|
- [ ] FLAC seeking fix
|
||||||
- [ ] General UI polish & visual refinement
|
- [ ] General UI polish & visual refinement
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.11.0",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||||
pkgname=psysonic
|
pkgname=psysonic
|
||||||
pkgver=1.11.0
|
pkgver=1.12.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Generated
+1
-1
@@ -3139,7 +3139,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"md5",
|
"md5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
+349
-51
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||||
use rodio::{Decoder, Sink, Source};
|
use rodio::{Decoder, Sink, Source};
|
||||||
|
use rodio::source::UniformSourceIterator;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
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()
|
(t * std::f32::consts::FRAC_PI_2).sin()
|
||||||
};
|
};
|
||||||
self.sample_count += 1;
|
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 ───────
|
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
|
||||||
//
|
//
|
||||||
// This is the key mechanism for gapless: the progress task polls `done` to know
|
// 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) ─────────────────────────────────────────
|
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
|
||||||
@@ -326,12 +445,28 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
|||||||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
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`:
|
/// `fade_in_dur`:
|
||||||
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
|
||||||
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-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
|
/// • `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(
|
fn build_source(
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
@@ -339,12 +474,15 @@ fn build_source(
|
|||||||
eq_enabled: Arc<AtomicBool>,
|
eq_enabled: Arc<AtomicBool>,
|
||||||
done_flag: Arc<AtomicBool>,
|
done_flag: Arc<AtomicBool>,
|
||||||
fade_in_dur: Duration,
|
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 gapless = parse_gapless_info(&data);
|
||||||
|
|
||||||
let cursor = Cursor::new(data);
|
let cursor = Cursor::new(data);
|
||||||
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
|
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
|
||||||
let sample_rate = decoder.sample_rate();
|
let sample_rate = decoder.sample_rate();
|
||||||
|
let channels = decoder.channels();
|
||||||
|
|
||||||
// Determine effective duration.
|
// Determine effective duration.
|
||||||
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
|
||||||
@@ -356,7 +494,8 @@ fn build_source(
|
|||||||
.unwrap_or(duration_hint)
|
.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 dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
|
||||||
let delay_dur = Duration::from_secs_f64(
|
let delay_dur = Duration::from_secs_f64(
|
||||||
gapless.delay_samples as f64 / sample_rate as 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 {
|
if let Some(total) = gapless.total_valid_samples {
|
||||||
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
|
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 {
|
} 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 {
|
} 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 eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
|
||||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Engine state ─────────────────────────────────────────────────────────────
|
||||||
@@ -397,6 +564,9 @@ pub(crate) struct ChainedInfo {
|
|||||||
base_volume: f32,
|
base_volume: f32,
|
||||||
/// Set by NotifyingSource when this chained track's source is exhausted.
|
/// Set by NotifyingSource when this chained track's source is exhausted.
|
||||||
source_done: Arc<AtomicBool>,
|
source_done: Arc<AtomicBool>,
|
||||||
|
/// Atomic sample counter for this chained source (swapped into
|
||||||
|
/// samples_played on transition).
|
||||||
|
sample_counter: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioEngine {
|
pub struct AudioEngine {
|
||||||
@@ -417,6 +587,16 @@ pub struct AudioEngine {
|
|||||||
/// Info about the next-up chained track (gapless mode).
|
/// Info about the next-up chained track (gapless mode).
|
||||||
/// The progress task reads this when `current_source_done` fires.
|
/// The progress task reads this when `current_source_done` fires.
|
||||||
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
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 {
|
pub struct AudioCurrent {
|
||||||
@@ -427,6 +607,10 @@ pub struct AudioCurrent {
|
|||||||
pub paused_at: Option<f64>,
|
pub paused_at: Option<f64>,
|
||||||
pub replay_gain_linear: f32,
|
pub replay_gain_linear: f32,
|
||||||
pub base_volume: 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 {
|
impl AudioCurrent {
|
||||||
@@ -459,6 +643,16 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
let thread = std::thread::Builder::new()
|
||||||
.name("psysonic-audio-stream".into())
|
.name("psysonic-audio-stream".into())
|
||||||
.spawn(move || match rodio::OutputStream::try_default() {
|
.spawn(move || match rodio::OutputStream::try_default() {
|
||||||
@@ -482,6 +676,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
paused_at: None,
|
paused_at: None,
|
||||||
replay_gain_linear: 1.0,
|
replay_gain_linear: 1.0,
|
||||||
base_volume: 0.8,
|
base_volume: 0.8,
|
||||||
|
fadeout_trigger: None,
|
||||||
|
fadeout_samples: None,
|
||||||
})),
|
})),
|
||||||
generation: Arc::new(AtomicU64::new(0)),
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
http_client: reqwest::Client::builder()
|
http_client: reqwest::Client::builder()
|
||||||
@@ -496,6 +692,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||||
chained_info: Arc::new(Mutex::new(None)),
|
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)
|
(engine, thread)
|
||||||
@@ -574,6 +774,24 @@ pub async fn audio_play(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
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 ─────────────────────────────────────────────────
|
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
|
||||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
// 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
|
// advance. The source is live in the queue — just return and let the
|
||||||
@@ -640,16 +858,29 @@ pub async fn audio_play(
|
|||||||
Duration::from_millis(5)
|
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 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,
|
data,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
state.eq_gains.clone(),
|
state.eq_gains.clone(),
|
||||||
state.eq_enabled.clone(),
|
state.eq_enabled.clone(),
|
||||||
done_flag.clone(),
|
done_flag.clone(),
|
||||||
fade_in_dur,
|
fade_in_dur,
|
||||||
|
state.samples_played.clone(),
|
||||||
|
target_rate,
|
||||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
).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 {
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -681,11 +912,12 @@ pub async fn audio_play(
|
|||||||
|
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
|
|
||||||
// Atomically swap sinks.
|
// Atomically swap sinks — extract old sink + its fade-out trigger.
|
||||||
let (old_sink, old_vol) = {
|
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||||
let mut cur = state.current.lock().unwrap();
|
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 = cur.sink.take();
|
||||||
|
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||||
|
let old_fo_samples = cur.fadeout_samples.take();
|
||||||
cur.sink = Some(sink);
|
cur.sink = Some(sink);
|
||||||
cur.duration_secs = duration_secs;
|
cur.duration_secs = duration_secs;
|
||||||
cur.seek_offset = 0.0;
|
cur.seek_offset = 0.0;
|
||||||
@@ -693,39 +925,33 @@ pub async fn audio_play(
|
|||||||
cur.paused_at = None;
|
cur.paused_at = None;
|
||||||
cur.replay_gain_linear = gain_linear;
|
cur.replay_gain_linear = gain_linear;
|
||||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
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 crossfade_enabled {
|
||||||
if let Some(old) = old_sink {
|
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);
|
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||||
let fo_arc = state.fading_out_sink.clone();
|
let fo_arc = state.fading_out_sink.clone();
|
||||||
|
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
|
tokio::time::sleep(cleanup_dur).await;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||||
s.stop();
|
s.stop();
|
||||||
}
|
}
|
||||||
@@ -747,6 +973,10 @@ pub async fn audio_play(
|
|||||||
state.crossfade_secs.clone(),
|
state.crossfade_secs.clone(),
|
||||||
done_flag,
|
done_flag,
|
||||||
app,
|
app,
|
||||||
|
state.samples_played.clone(),
|
||||||
|
state.current_sample_rate.clone(),
|
||||||
|
state.current_channels.clone(),
|
||||||
|
state.gapless_switch_at.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -815,14 +1045,22 @@ pub async fn audio_chain_preload(
|
|||||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||||
|
|
||||||
let done_next = Arc::new(AtomicBool::new(false));
|
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,
|
data,
|
||||||
duration_hint,
|
duration_hint,
|
||||||
state.eq_gains.clone(),
|
state.eq_gains.clone(),
|
||||||
state.eq_enabled.clone(),
|
state.eq_enabled.clone(),
|
||||||
done_next.clone(),
|
done_next.clone(),
|
||||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||||
|
chain_counter.clone(),
|
||||||
|
target_rate,
|
||||||
).map_err(|e| e.to_string())?;
|
).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.
|
// Final gen check — reject if a manual skip happened during decode.
|
||||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||||
@@ -847,6 +1085,7 @@ pub async fn audio_chain_preload(
|
|||||||
replay_gain_linear: gain_linear,
|
replay_gain_linear: gain_linear,
|
||||||
base_volume: volume.clamp(0.0, 1.0),
|
base_volume: volume.clamp(0.0, 1.0),
|
||||||
source_done: done_next,
|
source_done: done_next,
|
||||||
|
sample_counter: chain_counter,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -859,6 +1098,12 @@ pub async fn audio_chain_preload(
|
|||||||
/// done flag is set AND `chained_info` has data, it swaps `done` to the
|
/// 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
|
/// chained source's flag and transitions state — all without creating a new
|
||||||
/// task or changing the generation counter.
|
/// 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(
|
fn spawn_progress_task(
|
||||||
gen: u64,
|
gen: u64,
|
||||||
gen_counter: Arc<AtomicU64>,
|
gen_counter: Arc<AtomicU64>,
|
||||||
@@ -868,6 +1113,10 @@ fn spawn_progress_task(
|
|||||||
crossfade_secs_arc: Arc<AtomicU32>,
|
crossfade_secs_arc: Arc<AtomicU32>,
|
||||||
initial_done: Arc<AtomicBool>,
|
initial_done: Arc<AtomicBool>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
samples_played: Arc<AtomicU64>,
|
||||||
|
sample_rate_arc: Arc<AtomicU32>,
|
||||||
|
channels_arc: Arc<AtomicU32>,
|
||||||
|
gapless_switch_at: Arc<AtomicU64>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut near_end_ticks: u32 = 0;
|
let mut near_end_ticks: u32 = 0;
|
||||||
@@ -875,7 +1124,8 @@ fn spawn_progress_task(
|
|||||||
let mut current_done = initial_done;
|
let mut current_done = initial_done;
|
||||||
|
|
||||||
loop {
|
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 {
|
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||||
break;
|
break;
|
||||||
@@ -884,24 +1134,40 @@ fn spawn_progress_task(
|
|||||||
// ── Gapless transition detection ─────────────────────────────────
|
// ── Gapless transition detection ─────────────────────────────────
|
||||||
// If the current source is exhausted AND we have a chained track
|
// If the current source is exhausted AND we have a chained track
|
||||||
// ready, transition seamlessly: swap tracking state, emit
|
// 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) {
|
if current_done.load(Ordering::SeqCst) {
|
||||||
let chained = chained_arc.lock().unwrap().take();
|
let chained = chained_arc.lock().unwrap().take();
|
||||||
if let Some(info) = chained {
|
if let Some(info) = chained {
|
||||||
// Swap to the chained source's done flag.
|
// Swap to the chained source's done flag.
|
||||||
current_done = info.source_done;
|
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();
|
let mut cur = current_arc.lock().unwrap();
|
||||||
cur.replay_gain_linear = info.replay_gain_linear;
|
cur.replay_gain_linear = info.replay_gain_linear;
|
||||||
cur.base_volume = info.base_volume;
|
cur.base_volume = info.base_volume;
|
||||||
// Reset play_started to now — the old track physically
|
cur.duration_secs = info.duration_secs;
|
||||||
// ended, the new one is now actively producing samples.
|
|
||||||
cur.seek_offset = 0.0;
|
cur.seek_offset = 0.0;
|
||||||
cur.play_started = Some(Instant::now());
|
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;
|
near_end_ticks = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -910,9 +1176,26 @@ fn spawn_progress_task(
|
|||||||
// the near-end logic below.
|
// 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();
|
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();
|
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
|
||||||
@@ -927,7 +1210,8 @@ fn spawn_progress_task(
|
|||||||
|
|
||||||
if dur > end_threshold && pos >= dur - end_threshold {
|
if dur > end_threshold && pos >= dur - end_threshold {
|
||||||
near_end_ticks += 1;
|
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);
|
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
app.emit("audio:ended", ()).ok();
|
app.emit("audio:ended", ()).ok();
|
||||||
break;
|
break;
|
||||||
@@ -982,6 +1266,20 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
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.
|
// Seeking far back invalidates any pending gapless chain.
|
||||||
let cur_pos = {
|
let cur_pos = {
|
||||||
let cur = state.current.lock().unwrap();
|
let cur = state.current.lock().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.11.0",
|
"version": "1.12.0",
|
||||||
"identifier": "dev.psysonic.player",
|
"identifier": "dev.psysonic.player",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"decorations": true,
|
"decorations": true,
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"dragDropEnabled": false
|
"dragDropEnabled": false,
|
||||||
|
"devtools": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -191,7 +191,7 @@ export default function AlbumHeader({
|
|||||||
id="album-star-btn"
|
id="album-star-btn"
|
||||||
onClick={onToggleStar}
|
onClick={onToggleStar}
|
||||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
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'} />
|
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
{t('albumDetail.favorite')}
|
{t('albumDetail.favorite')}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export default function AlbumTrackList({
|
|||||||
className="btn btn-ghost track-star-btn"
|
className="btn btn-ghost track-star-btn"
|
||||||
onClick={e => onToggleSongStar(song, e)}
|
onClick={e => onToggleSongStar(song, e)}
|
||||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
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'} />
|
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward,
|
Play, Pause, SkipBack, SkipForward,
|
||||||
ChevronDown, Repeat, Repeat1, Square, Music
|
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
const stop = usePlayerStore(s => s.stop);
|
const stop = usePlayerStore(s => s.stop);
|
||||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||||
|
|
||||||
|
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 duration = currentTrack?.duration ?? 0;
|
||||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||||
@@ -241,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
>
|
>
|
||||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
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';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -12,6 +12,7 @@ import WaveformSeek from './WaveformSeek';
|
|||||||
import Equalizer from './Equalizer';
|
import Equalizer from './Equalizer';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
@@ -24,11 +25,14 @@ export default function PlayerBar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [eqOpen, setEqOpen] = useState(false);
|
const [eqOpen, setEqOpen] = useState(false);
|
||||||
|
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||||
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
const {
|
const {
|
||||||
currentTrack, isPlaying, currentTime, volume,
|
currentTrack, isPlaying, currentTime, volume,
|
||||||
togglePlay, next, previous, setVolume,
|
togglePlay, next, previous, setVolume,
|
||||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||||
lastfmLoved, toggleLastfmLove,
|
lastfmLoved, toggleLastfmLove,
|
||||||
|
isQueueVisible, toggleQueue,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
const { lastfmSessionKey } = useAuthStore();
|
const { lastfmSessionKey } = useAuthStore();
|
||||||
|
|
||||||
@@ -115,7 +119,7 @@ export default function PlayerBar() {
|
|||||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||||
data-tooltip={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>
|
||||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||||
<SkipForward size={19} />
|
<SkipForward size={19} />
|
||||||
@@ -140,6 +144,16 @@ export default function PlayerBar() {
|
|||||||
<span className="player-time">{formatTime(duration)}</span>
|
<span className="player-time">{formatTime(duration)}</span>
|
||||||
</div>
|
</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 */}
|
{/* EQ Button */}
|
||||||
<button
|
<button
|
||||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Track, usePlayerStore } from '../store/playerStore';
|
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 { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
|
import LyricsPane from './LyricsPane';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
@@ -139,6 +141,9 @@ export default function QueuePanel() {
|
|||||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||||
|
|
||||||
|
const activeTab = useLyricsStore(s => s.activeTab);
|
||||||
|
const setTab = useLyricsStore(s => s.setTab);
|
||||||
|
|
||||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -348,7 +353,8 @@ export default function QueuePanel() {
|
|||||||
</div>
|
</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')}>
|
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
|
||||||
<Shuffle size={13} />
|
<Shuffle size={13} />
|
||||||
</button>
|
</button>
|
||||||
@@ -472,6 +478,28 @@ export default function QueuePanel() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{saveModalOpen && (
|
||||||
<SavePlaylistModal
|
<SavePlaylistModal
|
||||||
|
|||||||
+169
-155
@@ -1,4 +1,5 @@
|
|||||||
import { Check } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
interface ThemeDef {
|
interface ThemeDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,81 +11,85 @@ interface ThemeDef {
|
|||||||
|
|
||||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||||
{
|
{
|
||||||
group: 'Psysonic Themes',
|
group: 'Games',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
|
||||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
|
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
|
||||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
||||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||||
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
|
{ 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: 'Psysonic Themes — Mediaplayer',
|
|
||||||
themes: [
|
|
||||||
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
|
||||||
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
|
||||||
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
|
||||||
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
|
||||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
|
||||||
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
|
||||||
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Operating Systems',
|
|
||||||
themes: [
|
|
||||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
|
||||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
|
||||||
{ id: 'aero-glass', label: 'Aero Glass', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
|
|
||||||
{ id: 'luna-teal', label: 'Luna Teal', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Catppuccin',
|
|
||||||
themes: [
|
|
||||||
{ 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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Nord',
|
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Retro',
|
|
||||||
themes: [
|
|
||||||
{ 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: '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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Tokyo Night',
|
|
||||||
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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Movies',
|
group: 'Movies',
|
||||||
themes: [
|
themes: [
|
||||||
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
|
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||||
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
|
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
|
||||||
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
|
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
|
||||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
|
||||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
{ 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: '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: 'Operating Systems',
|
||||||
|
themes: [
|
||||||
|
{ 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: '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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -95,93 +100,102 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemePicker({ value, onChange }: 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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div className="theme-accordion">
|
||||||
{THEME_GROUPS.map(({ group, themes }) => (
|
{THEME_GROUPS.map(({ group, themes }) => {
|
||||||
<div key={group}>
|
const isOpen = openGroup === group;
|
||||||
<div style={{
|
const hasActive = themes.some(t => t.id === value);
|
||||||
fontSize: '11px',
|
return (
|
||||||
fontWeight: 600,
|
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
|
||||||
textTransform: 'uppercase',
|
<button className="theme-accordion-header" onClick={() => toggle(group)}>
|
||||||
letterSpacing: '0.08em',
|
<span>
|
||||||
color: 'var(--text-muted)',
|
{group}
|
||||||
marginBottom: '10px',
|
{hasActive && !isOpen && (
|
||||||
}}>
|
<span className="theme-accordion-active-dot" />
|
||||||
{group}
|
)}
|
||||||
|
</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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -525,7 +525,10 @@ const enTranslation = {
|
|||||||
repeatOne: 'One',
|
repeatOne: 'One',
|
||||||
progress: 'Song Progress',
|
progress: 'Song Progress',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
toggleQueue: 'Toggle Queue'
|
toggleQueue: 'Toggle Queue',
|
||||||
|
lyrics: 'Lyrics',
|
||||||
|
lyricsLoading: 'Loading lyrics…',
|
||||||
|
lyricsNotFound: 'No lyrics found for this track',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1053,7 +1056,10 @@ const deTranslation = {
|
|||||||
repeatOne: 'Einen',
|
repeatOne: 'Einen',
|
||||||
progress: 'Songfortschritt',
|
progress: 'Songfortschritt',
|
||||||
volume: 'Lautstärke',
|
volume: 'Lautstärke',
|
||||||
toggleQueue: 'Warteschlange umschalten'
|
toggleQueue: 'Warteschlange umschalten',
|
||||||
|
lyrics: 'Lyrics',
|
||||||
|
lyricsLoading: 'Lyrics werden geladen…',
|
||||||
|
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1581,7 +1587,10 @@ const frTranslation = {
|
|||||||
repeatOne: 'Un',
|
repeatOne: 'Un',
|
||||||
progress: 'Progression',
|
progress: 'Progression',
|
||||||
volume: 'Volume',
|
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',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2109,7 +2118,10 @@ const nlTranslation = {
|
|||||||
repeatOne: 'Één',
|
repeatOne: 'Één',
|
||||||
progress: 'Nummervoortgang',
|
progress: 'Nummervoortgang',
|
||||||
volume: 'Volume',
|
volume: 'Volume',
|
||||||
toggleQueue: 'Wachtrij in-/uitschakelen'
|
toggleQueue: 'Wachtrij in-/uitschakelen',
|
||||||
|
lyrics: 'Songtekst',
|
||||||
|
lyricsLoading: 'Songtekst laden…',
|
||||||
|
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Music, Star, ExternalLink } from 'lucide-react';
|
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import {
|
import {
|
||||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||||
getAlbum, getArtistInfo,
|
getAlbum, getArtistInfo,
|
||||||
@@ -123,12 +124,12 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
|
|||||||
|
|
||||||
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
|
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
|
||||||
const h = strHash(name);
|
const h = strHash(name);
|
||||||
const sizePool = [12, 13, 14, 15, 16, 17, 18, 20, 22];
|
const sizePool = [10, 11, 12, 13, 14, 15, 16];
|
||||||
const size = sizePool[(h + idx * 7) % sizePool.length];
|
const size = sizePool[(h + idx * 7) % sizePool.length];
|
||||||
const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400;
|
const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
|
||||||
const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px';
|
const pad = size >= 15 ? '5px 10px' : '4px 8px';
|
||||||
const opacity = 0.6 + ((h % 5) * 0.08);
|
const opacity = 0.6 + ((h % 5) * 0.08);
|
||||||
const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2];
|
const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1];
|
||||||
const ty = verticals[(h + idx * 4) % verticals.length];
|
const ty = verticals[(h + idx * 4) % verticals.length];
|
||||||
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
|
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
|
||||||
};
|
};
|
||||||
@@ -137,7 +138,7 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
|
|||||||
<div className="np-tag-cloud">
|
<div className="np-tag-cloud">
|
||||||
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
|
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
|
||||||
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
|
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
|
||||||
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '26px' } : undefined}>
|
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '14px' } : undefined}>
|
||||||
{row.map((a, i) => (
|
{row.map((a, i) => (
|
||||||
<span
|
<span
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -239,6 +240,10 @@ export default function NowPlaying() {
|
|||||||
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
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 stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||||
|
|
||||||
@@ -356,6 +361,14 @@ export default function NowPlaying() {
|
|||||||
>
|
>
|
||||||
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
}));
|
||||||
@@ -127,6 +127,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
// to the Rust backend before it has finished the previous one.
|
// to the Rust backend before it has finished the previous one.
|
||||||
let togglePlayLock = false;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||||
@@ -204,6 +208,12 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAudioEnded() {
|
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();
|
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||||
@@ -216,6 +226,61 @@ function handleAudioEnded() {
|
|||||||
}, 150);
|
}, 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) {
|
function handleAudioError(message: string) {
|
||||||
console.error('[psysonic] Audio error from backend:', message);
|
console.error('[psysonic] Audio error from backend:', message);
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
@@ -241,6 +306,7 @@ export function initAudioListeners(): () => void {
|
|||||||
),
|
),
|
||||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||||
|
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sync Last.fm loved tracks cache on startup.
|
// Sync Last.fm loved tracks cache on startup.
|
||||||
@@ -364,6 +430,12 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
// ── playTrack ────────────────────────────────────────────────────────────
|
// ── playTrack ────────────────────────────────────────────────────────────
|
||||||
playTrack: (track, queue) => {
|
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;
|
const gen = ++playGeneration;
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade';
|
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 {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|||||||
@@ -935,6 +935,7 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row.track-row-va {
|
.track-row.track-row-va {
|
||||||
@@ -1214,6 +1215,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-bio a {
|
.artist-bio a {
|
||||||
@@ -1301,6 +1303,7 @@
|
|||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-bio-text a {
|
.artist-bio-text a {
|
||||||
@@ -1487,6 +1490,170 @@
|
|||||||
overflow: hidden;
|
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 {
|
.help-item {
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
@@ -2334,6 +2501,7 @@
|
|||||||
padding: 6px var(--space-3);
|
padding: 6px var(--space-3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-row:hover {
|
.playlist-row:hover {
|
||||||
@@ -2814,6 +2982,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: rgba(255, 255, 255, 0.72);
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
user-select: text;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -3328,6 +3497,7 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.np-queue-item:hover,
|
.np-queue-item:hover,
|
||||||
|
|||||||
+1035
-146
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user