Compare commits

...

11 Commits

Author SHA1 Message Date
Psychotoxical 936e548f40 feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:17:09 +01:00
Psychotoxical b67c198227 fix: player bar CSS + Windows media keys HWND (v1.17.2)
- layout: overflow:hidden on .app-shell + min-height:0 on sidebar/main/queue
  prevents player bar from being pushed off-screen when window is resized
  below OS minHeight constraint (ignored by some Linux WMs)
- lib.rs: pass main window HWND to souvlaki PlatformConfig on Windows so
  SMTC hooks into the existing Win32 message loop instead of creating its
  own — fixes media keys on Windows without crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:15:25 +01:00
Psychotoxical 65a828e3fa fix: disable souvlaki on Windows to fix startup crash (v1.17.1)
SMTC init via souvlaki requires a valid HWND + COM message loop, which
are not available in setup(). Guarded behind #[cfg(not(target_os = "windows"))].
mpris_set_metadata / mpris_set_playback no-op on Windows via the None branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:02:09 +01:00
Psychotoxical 6bdd6f3a59 feat: v1.17.0 — Media Keys, 3 New Themes, Perf Fixes, Contrast Audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:30:13 +01:00
Psychotoxical d62bffd082 feat: v1.16.0 — 15 New Themes, W98 Overhaul, Aqua Quartz Polish
New themes: Aqua Quartz (Mac OS X Aqua, skeuomorphic), Spider-Tech,
T-800, B-Runner, Hill Valley 85, TetraStack, Turtle Power, Insta,
ReadIt, The Book (new Social Media group), W3.1, Jayfin (Jellyfin).

W98 rebuilt from scratch: authentic #d4d0c8 warm-gray, full 4-layer
3D bevel on all panels/buttons (raised/sunken on press), title bar
gradient on song name, navy progress fill, 16px styled scrollbar.

Aqua Quartz: all button variants now jelly-styled, blue Source List
sidebar with white pill nav, aluminium pinstripe background.

Theme Picker: groups and themes sorted alphabetically, Mediaplayer
group renamed, Pandora/Order of the Phoenix/Imperial Sith removed.

Fix: AlbumDetail genre propagation, W98 accordion active state,
Aqua Quartz sidebar labels, W98 connection indicator contrast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:54:57 +01:00
Psychotoxical ff706104ab fix: propagate genre in AlbumDetail track constructions
Play All, Enqueue All, and single-song play in AlbumDetail were
building Track objects without genre/starred fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:57:17 +01:00
Psychotoxical 0abef4b266 docs: update README screenshot 2026-03-23 19:52:18 +01:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
Psychotoxical 361e9cfdb3 feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:00:55 +01:00
Psychotoxical 5516d95b52 feat: v1.13.0 — SVG Logo, Marquee, Player UX, Global Shortcuts fix
### Added
- SVG logo with theme-adaptive gradient in sidebar (full wordmark + P-icon for collapsed state)
- Player bar: song title/artist marquee scroll on overflow
- Player bar: live volume percentage tooltip on slider hover

### Changed
- Sidebar collapse button moved to right-edge hover tab
- Player bar: fixed 320px track info width, increased waveform margins
- Settings: Server tab opens by default
- Crossfade: experimental badge removed (stable)
- Help page: Lyrics/Keybindings/Font entries added, theme count corrected

### Fixed
- Global shortcuts double-fire (Rust-side ShortcutMap idempotency fix)
- W98 theme: comprehensive contrast fixes for navy hover backgrounds
- Help page: removed orphaned translation key under Playback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:24:14 +01:00
Psychotoxical d927ef2082 feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:06 +01:00
54 changed files with 10491 additions and 1624 deletions
+221
View File
@@ -5,6 +5,227 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.18.0] - 2026-03-27
### Added
- **Offline Mode *(Beta — tested on CachyOS only)***: Albums can now be cached for offline playback via the new "Cache Offline" button in the album header. Cached albums are accessible in the new **Offline Library** page. On launch without internet, the app automatically navigates there if cached content is available — no blocking overlay. A slim non-blocking banner shows while in offline mode. Offline tracks are removed when clearing the cache.
- **Settings — Cache section improvements**: Live usage display (image cache + offline tracks). Adjustable limit now goes up to 5 GB. When the limit is reached, the oldest image cache entries are evicted automatically (offline albums are not auto-removed). "Clear Cache" button with confirmation removes both image cache and all offline albums.
- **MPRIS — Seek support**: The Plasma (and other MPRIS2-compatible) seekbar now works correctly. Seek and SetPosition events from the OS are forwarded to the audio engine. Position is synced every 500 ms while playing so the OS overlay stays accurate.
- **Lyrics caching**: Fetched lyrics are cached in memory for the session. Switching between Queue and Lyrics tabs no longer re-fetches from lrclib.net.
- **2 New Themes** *(Movies)*:
- **Barb & Ken** — Barbie dreamhouse universe. Deep magenta dark, polka-dot sidebar, glitter shimmer animation on track name, Ken powder blue for artist name and volume slider.
- **Toy Tale** — Toy Story. Dark warm toy-chest brown main, Andy's iconic cloud-wallpaper sky-blue sidebar, Woody sheriff-star gold track name, Buzz Lightyear purple for active queue item and volume slider.
### Changed
- **Hero carousel — background crossfade**: The blurred background no longer flickers when switching albums. The last resolved URL is held until the new one is ready, so the old background stays visible until the new one loads.
- **AlbumDetail — Download hint**: Removed the inline hint text from the album header. The explanation (server zips first — may take a moment) is now in the Help FAQ.
### Fixed
- **Performance — Home page scroll**: `AlbumCard` subscribed to two large Zustand record objects (`tracks`, `albums`) per card — 96+ selector calls across a typical home page. Replaced with a single boolean selector per card. Added `React.memo` to prevent re-renders when parent rows reload.
- **Middle Earth theme — active queue item contrast**: Track title was invisible (dark text on dark background). Fixed to bright gold. Tech info bar text also corrected.
---
## [1.17.2] - 2026-03-26
### Fixed
- **Player bar disappears when window is resized small**: On Linux (and some Windows configurations), the window manager ignores the `minHeight` constraint, allowing the window to be dragged smaller than intended. The CSS grid's `1fr` row has an implicit `min-height: auto`, meaning it refuses to shrink below the min-content height of the sidebar/main/queue children — this pushed the total grid height beyond `100vh` and scrolled the player bar out of view. Fixed by adding `min-height: 0` to `.sidebar`, `.main-content`, and `.queue-panel`, and `overflow: hidden` to `.app-shell` as a safety net.
- **Media keys on Windows (SMTC)**: souvlaki's Windows backend requires a valid Win32 HWND to hook into the existing message loop rather than spinning up its own. Passing `hwnd: None` caused a crash on startup (v1.17.0). Now retrieves the main window's HWND via `app.get_webview_window("main").hwnd()` and passes it to `PlatformConfig`. Falls back to disabled gracefully if the HWND cannot be obtained.
---
## [1.17.1] - 2026-03-25
### Fixed
- **Windows crash on startup**: souvlaki SMTC init in `setup()` requires a valid HWND and a running COM message loop, neither of which exists at that point. Media controls are disabled on Windows until init can be properly deferred post-window. All other functionality unaffected.
---
## [1.17.0] - 2026-03-25
### Added
- **Media Keys & OS Media Controls** *(experimental)*: Initial integration via [souvlaki](https://github.com/Sinono3/souvlaki) — MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows. Track metadata (title, artist, album, cover art) and playback state are pushed to the OS media overlay in real time. On Linux, init is skipped gracefully if no D-Bus session is present. This feature is still under active development and observation — behaviour may vary across desktop environments and OS versions.
- **Random Mix — Artist Blacklist**: Artist names are now included in the keyword blacklist filter. Clickable artist chips in the tracklist let you add an artist to the blacklist with one click — same UX as the existing genre chips.
- **Favorites — Remove Song**: Each song row in Favorites now has an inline X button to remove the track from favorites instantly (optimistic UI, server unstar happens in the background).
- **3 New Themes**:
- *Games*: **Horde** — Durotar blood-red earth, iron-plate sidebar, forge-fire gold glow on track name.
- *Games*: **Alliance** — Stormwind deep navy, cathedral stone columns, paladin holy-light glow, gold sidebar trim and nav accent.
- *Operating Systems*: **W11** — Windows 11 Fluent Design dark mode. Mica-style sidebar, clean neutral palette, taskbar-inspired player bar. No gradients — faithful to the minimal Fluent aesthetic.
### Changed
- **Theme renames**: Cobalt Media → **WinMedPlayer**, Onyx Cinema → **P-DVD**, Navy Jukebox → **MuMa Jukebox**.
- **NowPlayingDropdown**: Username / player name row now uses `--text-secondary` for improved readability across all themes.
### Fixed
- **Performance — App-wide interaction lag**: Removed `[data-theme='X'] * { font-family: ... !important }` universal selectors from several themes (DOS, Unix, and others). The browser places universal selectors in the "universal bucket" and checks them against every DOM node on every style recalculation — measurably sluggish with 5001000+ elements even when the affected theme is not active. `font-family` is now set on the theme root block (inherits to children) with a targeted `button, input, textarea, select` override for elements that don't inherit font.
- **Performance — Scroll jank**: Removed `repeating-linear-gradient` / `repeating-radial-gradient` from `.app-shell` in DOS, Unix, GW1, Morpheus, Aqua Quartz, and others. WebKitGTK with `WEBKIT_DISABLE_COMPOSITING_MODE=1` (always set by the AUR wrapper) has no GPU compositing — fine-pitch repeating patterns on the full-viewport background re-rasterize every scroll frame. Patterns are now applied only to `.sidebar` and `.player-bar`, which never scroll.
- **Contrast — 29 themes**: Audited all themes against WCAG AA. Fixed `--text-muted` and `--text-secondary` values in 29 themes that had insufficient contrast ratios (< 3.5:1). Affects Catppuccin (all four variants), Gruvbox (all six), Nord variants, GW1, Heisenberg, Ice and Fire, Spider-Tech, Morpheus, Hill Valley 85, Dune, and others.
### Removed
- **Theme**: Azerothian Gold removed from the Games group.
---
## [1.16.0] - 2026-03-24
### Added
- **15 New Themes** across multiple categories:
- *Operating Systems*: **Aqua Quartz** — Mac OS X Aqua (skeuomorphic jelly buttons, brushed aluminium player bar, pinstripe background, blue Source List sidebar, authentic `#3876f7` accent)
- *Movies*: **Spider-Tech** (Spider-Man navy/red), **T-800** (Terminator Skynet blue), **B-Runner** (Blade Runner 2049 amber), **Hill Valley 85** (Back to the Future)
- *Games*: **TetraStack** (Tetris 8-bit, cyan, grid background, 0px radii)
- *Series*: **Turtle Power** (TMNT turtle green, brick tile sidebar)
- *Social Media* (new group): **Insta** (Instagram dark pink), **ReadIt** (Reddit dark orange-red), **The Book** (Facebook light, blue sidebar)
- *Operating Systems*: **W3.1** (Windows 3.1, light silver/teal, 0px radii, inset bevels)
- *Mediaplayer*: **Jayfin** (Jellyfin-inspired — deep black, purple `#AA5CC3` primary, cyan `#00A4DC` secondary, brand gradient on player bar and progress fill)
- **Aqua Quartz — Full Skeuomorphic Polish**: All button variants (`.btn-surface`, `.btn-ghost`, `.hero-play-btn`, `.album-card-details-btn`, `.queue-round-btn`) now have the authentic Aqua jelly gradient. Sidebar sports the iconic blue Source List gradient with white icons and a white pill for the active nav link.
### Changed
- **W98 Theme — Complete Overhaul**: Rebuilt from scratch with authentic Windows 98 design language: correct `#d4d0c8` warm-gray button face (not flat `#c0c0c0`), full 4-layer 3D bevel on all panels and buttons (raised default, sunken on press), song title displays in the iconic navy→light-blue title bar gradient, progress bar is a sunken white trough with navy fill, 16px styled scrollbar, all hover/active states consistently navy `#000080` + white text.
- **Theme Picker — Alphabetical Order**: All theme groups and themes within groups are now sorted alphabetically.
- **Theme Picker — Group Rename**: "Psysonic Themes — Mediaplayer" renamed to "Mediaplayer".
- **Sidebar + Queue Toggle Buttons**: Queue toggle button now uses the theme accent color (icon + hover).
### Fixed
- **AlbumDetail — Genre not propagating**: Playing via the album detail Play All / Enqueue All buttons now correctly includes the track genre in the constructed Track objects, making it show up in the Queue strip.
- **W98 — Theme Accordion active state**: Open category headers are now navy with white text instead of black-on-navy.
- **Aqua Quartz — Sidebar section labels**: "Library" / "System" labels now render in white on the blue sidebar.
- **W98 — Connection indicators**: Server name and Last.fm username in the header are now black (`#000000`) on the warm-gray background for full readability.
### Removed
- **Themes**: Removed **Pandora**, **Order of the Phoenix**, and **Imperial Sith** — too similar to other better-executed themes in their respective groups.
---
## [1.15.0] - 2026-03-23
### Added
- **Queue — Genre · Format · Bitrate Strip**: The meta box above the queue now shows a full-width frosted strip with Genre, audio format, and bitrate (e.g. `Electronic · FLAC · 1411 kbps`). Genre is sourced directly from track metadata and is now propagated through all 11 track construction sites across the codebase.
- **Lyrics — Accent Color Highlight**: The active synced lyrics line is now highlighted in the theme accent color instead of bold+larger text. Eliminates layout jumps caused by the font-weight change pushing lines to wrap.
### Fixed
- **Sidebar — Collapse Button**: The collapse button now correctly sits on the right border of the sidebar, straddling the dividing line between sidebar and main content, and is always visible.
### Changed
- **Queue — Tech Info**: Codec/bitrate badge replaced by the new full-width Genre · Format · Bitrate strip at the top of the meta box.
---
## [1.14.0] - 2026-03-22
### Critical Fixes
- **Prebuffer Flood — 300 simultaneous downloads eliminated**: The audio engine was spawning up to 300 concurrent HTTP download requests during prebuffering, causing network saturation of ~200 Mbit/s and significant CPU load. The root cause was unbounded parallel preload logic in the Rust engine. Fixed: the engine now buffers intelligently with a single controlled preload per track. Network usage dropped to under 100 kbit/s during normal playback.
- **Gapless Playback — fully stable**: Gapless transitions now work correctly end-to-end. Previously, edge cases in the sample-accurate handoff between tracks caused audio glitches or silence between songs.
- **Crossfade — fully stable**: The equal-power crossfade (sin/cos envelope) is now reliable across all track transitions. Previous instability was caused by race conditions in the fade-out trigger and Sink lifecycle management.
- **Now Playing Page — performance**: The Now Playing page no longer causes sustained CPU spikes. Heavy re-renders triggered by frequent `audio:progress` events (previously every 500 ms with wall-clock drift) are resolved — progress is now driven by an atomic sample counter at 100 ms intervals with no layout thrashing.
### Fixed
- **Volume — Clipping at 100%**: Audible distortion at maximum volume eliminated. A `MASTER_HEADROOM` constant of 1 dB (`0.891`) is now applied to all volume calculations, preventing inter-sample peaks from 0 dBFS masters and EQ biquad ripple from clipping.
- **Seek — Display Desync**: Seeking while paused could cause the time display to jump to the new position while audio continued from the old one. `CountingSource::try_seek` now only resets the sample counter after confirming the seek succeeded.
- **Gapless + Crossfade — Mutual Exclusion**: Both modes can no longer be active simultaneously. Enabling one auto-disables the other (Queue toolbar + Settings). Running both simultaneously caused a glitch where Song 2, gapless-chained inside the Sink, would play at full volume after Song 1's crossfade completed.
- **Now Playing — About the Artist**: The "About the Artist" card is now hidden when no biography is available. Artist images that fail to load are silently hidden instead of showing a broken image placeholder.
### Added
- **Waveform — Hover Tooltip**: Hovering over the waveform seekbar shows a floating time label above the cursor. Hidden when no track is loaded or the cursor leaves.
- **Hero & Album Detail — Format Badge**: Audio format (FLAC, MP3, OGG, …) now shown alongside Year, Genre, and Track Count in the hero meta row on the Home page and in the Album Detail header.
- **Help — FLAC Seeking**: New FAQ entry explaining that FLAC files without an embedded SEEKTABLE cannot be seeked, with instructions for adding one via `flac` or `metaflac`.
### Changed
- **Queue — Tech Info**: Codec/bitrate badge moved from the frosted-glass cover overlay into the top-right corner of the meta box. Album artwork is no longer obscured.
---
## [1.13.0] - 2026-03-22
### Added
- **SVG Logo**: The Psysonic wordmark is now an inline SVG with a theme-adaptive gradient (`--accent``--ctp-blue`), matching the app's visual identity across all 47 themes. The collapsed sidebar shows a standalone P-icon with the same gradient.
- **Player Bar — Marquee**: Song title and artist name scroll smoothly when the text overflows the fixed-width track info area, pause briefly, then jump back and repeat.
- **Player Bar — Volume Tooltip**: A floating percentage label appears above the volume slider on hover and updates live while dragging.
### Changed
- **Sidebar — Collapse button**: Moved from the brand header to a small circular hover-tab on the right edge of the sidebar. Hidden until you hover over the sidebar, keeping the logo area uncluttered.
- **Player Bar — Layout**: Track info area is now a fixed 320 px width. Waveform section has increased margins on both sides for better visual separation between controls, waveform, and volume.
- **Settings**: Server tab is now the default when opening Settings.
- **Crossfade**: Experimental badge removed — considered stable.
- **Help page**: Added entries for Lyrics, Configurable Keybindings, and Font Picker. Theme count corrected to 47 themes across 7 groups.
### Fixed
- **Global shortcuts — double-fire**: Pressing a global shortcut (e.g. `Ctrl+Alt+→`) was triggering the action twice. Root cause: `on_shortcut()` in `tauri_plugin_global_shortcut` accumulates handlers per shortcut across JS HMR reloads. Fixed with a Rust-side `ShortcutMap` state that makes `register_global_shortcut` idempotent.
- **W98 theme**: Comprehensive contrast fixes across all interactive elements — hover states, buttons, queue items, settings panels, and toggles now use silver-grey (`#e0e0e0`) text on navy (`#000080`) backgrounds.
- **Help page**: Removed orphaned translation key that was rendering as raw text under the Playback section.
### Beta
- **Global Shortcuts** (Settings → Global Shortcuts): System-wide keyboard shortcuts that trigger playback actions while Psysonic is in the background. Functional on all platforms, but edge cases with certain key combinations or OS-level conflicts may still occur.
---
## [1.12.0] - 2026-03-22
### Added
- **Synchronized Lyrics**: Lyrics pane integrated into the Queue sidebar, powered by [LRCLIB](https://lrclib.net) — no API key required. Shows time-synced lyrics with auto-scroll and active-line highlighting; falls back to plain text when synced lyrics are unavailable. Access via the microphone icon in the player bar, fullscreen player, or Now Playing page.
#### 15 New Themes
**Games** (new group — 6 themes):
- **Ascalon**: Dark stone fantasy inspired by Guild Wars 1. Near-black base, gold accent (`#d4af37`).
- **Azerothian Gold**: World of Warcraft inspired. Charcoal base, warm gold accent (`#c19e67`).
- **Grand Theft Audio**: GTA-inspired night city aesthetic. Pure black base, green accent (`#57b05a`).
- **Lambda 17**: Half-Life inspired. Deep blue-black base, amber accent (`#ff9d00`).
- **NightCity 2077**: Cyberpunk 2077 inspired. Near-total black base, neon yellow accent (`#FCEE0A`).
- **V-Tactical**: Battlefield inspired. Gunmetal base, burnt orange accent (`#ff8a00`).
**Series** (new group — 3 themes):
- **A Theme of Ice and Fire**: Game of Thrones inspired. Cold dark navy base, ice blue accent (`#70a1ff`).
- **D'oh-matic**: The Simpsons inspired. Cream/yellow light base, blue accent (`#1F75FE`).
- **Heisenberg**: Breaking Bad inspired. Dark desaturated green base, crystal blue accent (`#3fe0ff`).
**Movies** (2 additions):
- **Imperial Sith**: Star Wars dark side. Pure black base, red accent (`#e60000`).
- **Order of the Phoenix**: Harry Potter inspired. Deep charcoal base, ember-orange accent (`#e63900`).
**Operating Systems** (1 addition):
- **W98**: Windows 98 teal desktop aesthetic. Classic teal background, silver card, navy accent (`#000080`).
### Changed
- **Last.fm integration**: Promoted out of beta — scrobbling, Now Playing, love/unlove, Similar Artists, and top stats are considered stable.
- **Crossfade**: No longer marked experimental. Stable on Windows and Linux; macOS under observation.
- **Gapless playback**: Experimental badge removed — considered stable.
- **Theme picker — groups reorganised**: Catppuccin, Nord, and Retro (Gruvbox) merged into a single **Open Source Classics** group. Streaming themes (Spotless, DZR, Cupertino Beats) moved into **Psysonic Themes — Mediaplayer**. The app now ships **47 themes** across **7 groups**.
- **Tokyo Night themes removed**: `tokyo-night`, `tokyo-night-storm`, and `tokyo-night-light` retired to make room for the new groups.
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
### Fixed
- **Text selection on double-click**: Double-clicking song titles or anywhere in the UI no longer accidentally selects text. `user-select: none` applied globally; re-enabled for bio/description text areas.
- **Middle Earth theme — star buttons**: Active favourite star in the album tracklist and album header was barely visible (gold on parchment, ~1.4:1 contrast). Both active and inactive states now use darker brown tones with proper contrast.
- **Middle Earth theme — play button hover**: Hovering the primary play/pause button no longer makes the icon invisible (gold icon on gold background).
## [1.11.0] - 2026-03-22
### Added
-230
View File
@@ -1,230 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Psysonic
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
## Commands
```bash
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
npm run tauri:dev
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
# Production build
npm run tauri:build
# Frontend-only dev server (no Tauri shell)
npm run dev
# Type-check + bundle frontend
npm run build
```
There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
## Architecture
### Stack
- **Frontend**: React 18 + TypeScript + Vite, served inside a Tauri WebView
- **Backend**: Rust (Tauri v2) — handles tray icon, media key shortcuts, `exit_app` command, and the full audio engine
- **State**: Zustand stores (no Redux)
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
- **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time).
- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch)
### Key files
| File | Role |
|---|---|
| `src/api/subsonic.ts` | All Subsonic REST calls + `buildStreamUrl` / `buildCoverArtUrl` / `buildDownloadUrl` helpers. Also exports `pingWithCredentials()`, `coverArtCacheKey()`, `reportNowPlaying()`. `getRandomSongs` includes a `_t` timestamp param to prevent browser/axios caching. |
| `src/api/lastfm.ts` | Last.fm API: scrobble, updateNowPlaying, love/unlove, getTrackLoved, getSimilarArtists, getTopArtists/Albums/Tracks, getRecentTracks, getUserInfo. Auth via session key stored in `authStore`. |
| `src/utils/imageCache.ts` | IndexedDB image cache (30-day TTL) + in-memory object URL Map. `getCachedUrl(fetchUrl, cacheKey)` is the main entry point. Capped at 150 entries with LRU eviction + `URL.revokeObjectURL`. Max 5 concurrent fetches. |
| `src/components/CachedImage.tsx` | Drop-in `<img>` replacement that resolves via the image cache. Also exports `useCachedUrl(fetchUrl, cacheKey)` hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
| `src/components/TooltipPortal.tsx` | Global tooltip system. Listens for `mouseover`/`mouseout` on `document`, reads `data-tooltip` / `data-tooltip-pos` / `data-tooltip-wrap` attributes, renders via `createPortal` to `document.body` at `z-index: 99999`. Mounted once in `App.tsx`. Use `data-tooltip` instead of native `title=` everywhere — `title=` produces unstyled OS tooltips. |
| `src/components/CustomSelect.tsx` | Styled portal-based dropdown replacing native `<select>`. Accepts `SelectOption[]` with optional `group` and `disabled`. Positioned via `useLayoutEffect`, flips above trigger if near viewport bottom. Use for all select inputs. |
| `src/components/LastfmIcon.tsx` | Shared Last.fm SVG logo component. `<LastfmIcon size={16} />`. |
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
| `src/store/themeStore.ts` | Theme selection (30 themes), applied as `data-theme` on `<html>` |
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `<TooltipPortal />` mount |
| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 110 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. |
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
### Multi-server support
`authStore` holds a `ServerProfile[]` array and an `activeServerId`. The `ServerProfile` shape is:
```typescript
interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
```
Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL.
### Login / connection flow
- Connection is tested with `pingWithCredentials(url, username, password)` from `src/api/subsonic.ts` **before** writing anything to the store.
- Only after a successful ping: `addServer()` + `setActiveServer()` + `setLoggedIn(true)`.
- `RequireAuth` in `App.tsx` redirects to `/login` if `!isLoggedIn || !activeServerId || servers.length === 0`.
- **Do not** call `addServer()` before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
### Auth salt security
`secureRandomSalt()` in `subsonic.ts` uses `crypto.getRandomValues()` (not `Math.random()`) for all token auth salts.
### Image caching
`buildCoverArtUrl()` generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
- `coverArtCacheKey(id, size)` — stable key: `${serverId}:cover:${id}:${size}`
- `CachedImage` component or `useCachedUrl` hook — resolve via IndexedDB, fall back to direct URL
- Use `useCachedUrl` (not `CachedImage`) for CSS `background-image` properties
- **Gotcha**: `useCachedUrl` / hooks from `CachedImage.tsx` must be called unconditionally, before any early `return` in the component. Derive inputs from nullable state (e.g. `album?.album.coverArt`) rather than placing the hook after guard returns.
### Data flow
1. `authStore.getBaseUrl()` returns the active server's URL
2. `subsonic.ts` calls `useAuthStore.getState()` directly (not hooks) to build each request
3. `playerStore.playTrack()` calls `invoke('audio_play', { url, volume, durationHint })`, calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm), listens for `audio:progress` / `audio:ended` events, triggers scrobble at 50% directly via Last.fm API, and debounces server queue sync
4. Tauri events (`media:play-pause`, `tray:play-pause`, etc.) are bridged to store actions in `TauriEventBridge` inside `App.tsx`
5. On cold start (app restart): if `currentTrack` is in localStorage, `resume()` calls `audio_play` + seeks to saved `currentTime`
### Adding a new page
1. Create `src/pages/MyPage.tsx`
2. Add a `<Route>` in `AppShell` in `src/App.tsx`
3. Add a sidebar link in `src/components/Sidebar.tsx`
4. Add i18n keys to both `enTranslation` and `deTranslation` in `src/i18n.ts`
### Adding a new Subsonic API call
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### 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.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
| Theme | Group | Style | Accent |
|---|---|---|---|
| `poison` | Psysonic Themes | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
| `nucleo` | Psysonic Themes | warm brass/cream light | Brass `#7a5218` |
| `psychowave` | Psysonic Themes | deep violet synthwave | Purple `#a06ae0` |
| `vintage-tube-radio` | Psysonic Themes | warm brown, VFD orange | Orange `#FF6F00` |
| `neon-drift` | Psysonic Themes | midnight blue, electric cyan glow | Cyan `#00f2ff` |
| `wnamp` | Psysonic — Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` |
| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `mocha` | Catppuccin | dark | Mauve |
| `macchiato` | Catppuccin | medium-dark | Mauve |
| `frappe` | Catppuccin | medium | Mauve |
| `latte` | Catppuccin | light | Mauve |
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` |
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
### Artists page — initial avatars
Artist images are intentionally **not loaded** on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB). In the grid view, the initial avatar is a **circle** (`border-radius: 50%`, `border: 2px solid` with the accent colour) centred inside the card. Name and album count below are centre-aligned.
### Artist cards
`ArtistCardLocal` uses the same structure as `AlbumCard`: no padding, full-width square cover via `aspect-ratio: 1`, info below. Both use `flex: 0 0 clamp(140px, 15vw, 180px)` inside `.album-grid` so they stay the same size as album cards.
### NowPlayingDropdown — Live button
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
### i18n
All non-English strings live exclusively in `src/i18n.ts` — never hardcode translated text in `.tsx` files. Four languages: `en`, `de`, `fr`, `nl`. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
### Tauri capabilities
Tauri v2 capability configs live in `src-tauri/capabilities/`. Schema is auto-generated into `src-tauri/gen/schemas/`. Modify capabilities there when adding new Tauri plugins or IPC commands.
## Release / CI
Releases are triggered by pushing a `v*` tag. The GitHub Actions workflow (`.github/workflows/release.yml`) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
The workflow is split into three jobs: `create-release` (creates the GitHub Release with changelog body from CHANGELOG.md), `build-macos-windows` (macOS + Windows via tauri-action), and `build-linux` (Ubuntu 24.04, manual, builds only deb + rpm via `--bundles deb,rpm`).
**AppImage is no longer built.** The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
**Never force-push or move a tag after publishing.** GitHub caches release tarballs — moving a tag causes the AUR and other package managers to build stale code. Bump the patch version instead.
### Linux distribution channels
| Distro family | Package |
|---|---|
| Ubuntu / Debian | `.deb` from GitHub Releases |
| Fedora / RHEL | `.rpm` from GitHub Releases |
| Arch / CachyOS | AUR: `yay -S psysonic` or `paru -S psysonic` |
### AUR package (`packages/aur/PKGBUILD`)
- Maintained at `aur.archlinux.org/packages/psysonic` (account: Psychotoxical)
- Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
- Installs a wrapper script at `/usr/bin/psysonic` setting `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`
- **When releasing**: bump `pkgver` in `packages/aur/PKGBUILD`, copy to `~/aur-psysonic/`, run `makepkg --printsrcinfo > .SRCINFO`, commit and push to AUR remote
## Notes
- Media key shortcuts (`MediaPlayPause`, etc.) are **not registered on Linux** (see `#[cfg(not(target_os = "linux"))]` in `lib.rs`). Spacebar is the keyboard shortcut for play/pause instead.
- `playerStore` persists `volume`, `repeatMode`, `currentTrack`, `queue`, `queueIndex`, and `currentTime` to `localStorage` via Zustand `partialize`. The audio engine state is runtime-only (Rust side).
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs`**not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
- **Fullscreen Player ("Ambient Stage")**: Single centered column — no tracklist. Background uses the artist's `largeImageUrl` from `getArtistInfo()` (falls back to cover art). Ken Burns animation: `inset: -30%`, ±8% translate, 90s cycle. No color orbs (removed — too GPU-intensive). Cover has a slow breathing animation (`cover-breathe` keyframe). Long song titles scroll as a marquee (`MarqueeTitle` component — measures overflow via `getBoundingClientRect` + `ResizeObserver`, animates via CSS custom property `--scroll-amount`). `Track.artistId` is populated from `SubsonicSong.artistId` (Navidrome returns this field) across all 18 track-construction sites.
- **Sidebar**: Fixed width via CSS `clamp(200px, 15vw, 220px)` — no drag-to-resize. Collapsed state (72px) persisted in `localStorage`. Update notification uses Tauri Shell plugin `open()` to launch the system browser — `<a target="_blank">` does not work inside a Tauri WebView.
- **Artist page — external links**: Last.fm and Wikipedia buttons open in the system browser via `open()` from `@tauri-apps/plugin-shell`. Button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 s as visual confirmation.
- **Tracklist columns**: Order is `# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format`. Format column uses `120px` (NOT `auto` or `1fr`) — `auto` caused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so the `fr` title column calculated differently in header vs rows, shifting all subsequent columns. `1fr` fixed alignment but made the format column too wide. Fixed `120px` fits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicit `grid-column` numbers (not negative indices).
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
- **Random Mix — Genre Filter**: `excludeAudiobooks` + `customGenreBlacklist` in `authStore` (persisted). Hardcoded `AUDIOBOOK_GENRES` list in `RandomMix.tsx` and `AUDIOBOOK_GENRES_DISPLAY` in `Settings.tsx` must be kept in sync. Filter checks `song.genre`, `song.title`, and `song.album`. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly.
- **Random Mix — Super Genre Mix**: 9 super-genres defined in `SUPER_GENRES` constant. Server genres fetched via `getGenres()` on mount; `availableSuperGenres` filters to those with ≥1 keyword match. `loadGenreMix` uses progressive rendering — `setGenreMixSongs` updated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching. `genreMixComplete` state gates the "Play All" button: button stays `btn-surface` with live `n / 50` counter while loading, switches to `btn-primary` only when all songs are ready.
- **RandomAlbums**: No auto-refresh timer — loads once on mount, manual refresh button only. `loadingRef` guards against concurrent fetches.
- **Queue shuffle**: `shuffleQueue()` in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest.
- **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal.
- **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget.
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.11.0
+8 -10
View File
@@ -22,11 +22,12 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ 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**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
@@ -47,25 +48,22 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Replay Gain (track + album mode)
- [x] Gapless playback
- [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
- [x] Last.fm scrobbling, Now Playing & love/unlove
- [x] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
- [x] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, Psysonic originals, Psysonic Mediaplayer
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
- [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Crossfade** — stability improvements
### 📋 Planned
- [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
- [ ] More languages
---
@@ -73,7 +71,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
## 📥 Installation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.11.0",
"version": "1.18.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.11.0
pkgver=1.18.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="115.549mm"
height="130.30972mm"
viewBox="0 0 115.549 130.30972"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:export-filename="p-small.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"><inkscape:page
x="0"
y="0"
width="115.549"
height="130.30972"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1" /><g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(220.53237,27.789086)"><path
style="fill:#ffffff"
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
id="path1" /></g></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+606 -52
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.11.0"
version = "1.18.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -36,3 +36,4 @@ md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
tauri-plugin-window-state = "2.4.1"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music PLAYER only — no microphone access needed.
This suppresses the macOS microphone permission prompt triggered
by cpal/CoreAudio enumerating input devices during audio init. -->
<key>com.apple.security.device.audio-input</key>
<false/>
</dict>
</plist>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Psysonic is a music player only — it does not record audio.
This description is shown if macOS prompts for microphone access
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
</dict>
</plist>
+399 -73
View File
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
@@ -209,7 +210,7 @@ impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
(t * std::f32::consts::FRAC_PI_2).sin()
};
self.sample_count += 1;
Some(sample * gain)
Some((sample * gain).clamp(-1.0, 1.0))
}
}
@@ -226,6 +227,82 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
}
}
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
//
// Every track source is wrapped with this. It passes through at unity gain
// until `trigger` is set to true, at which point it reads `fade_total_samples`
// and applies a cos(t·π/2) envelope:
// gain(t) = cos(t · π/2), t ∈ [0, 1]
// At t = 0 gain = 1, at t = 1 gain = 0.
// After the fade completes, returns None to exhaust the source.
//
// Combined with EqualPowerFadeIn (sin curve) on Track B, this gives a
// symmetric constant-power crossfade: sin²+cos² = 1.
struct TriggeredFadeOut<S: Source<Item = f32>> {
inner: S,
trigger: Arc<AtomicBool>,
fade_total_samples: Arc<AtomicU64>,
fade_progress: u64,
fading: bool,
cached_total: u64,
}
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
Self {
inner,
trigger,
fade_total_samples,
fade_progress: 0,
fading: false,
cached_total: 0,
}
}
}
impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
// Check trigger on first fade sample only (avoid atomic load per sample).
if !self.fading && self.trigger.load(Ordering::Relaxed) {
self.fading = true;
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
self.fade_progress = 0;
}
if self.fading {
if self.fade_progress >= self.cached_total {
// Fade complete — exhaust the source.
return None;
}
let sample = self.inner.next()?;
let t = self.fade_progress as f32 / self.cached_total as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
self.fade_progress += 1;
Some((sample * gain).clamp(-1.0, 1.0))
} else {
self.inner.next()
}
}
}
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// If we seek back during a fade, cancel the fade.
if self.fading {
self.fading = false;
self.trigger.store(false, Ordering::Relaxed);
}
self.fade_progress = 0;
self.inner.try_seek(pos)
}
}
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
//
// This is the key mechanism for gapless: the progress task polls `done` to know
@@ -269,6 +346,54 @@ 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 only after confirming the inner seek succeeded.
// If we reset first and the seek fails, the counter ends up at the
// new position while the decoder is still at the old one — causing
// a permanent desync between displayed time and actual audio.
let result = self.inner.try_seek(pos);
if result.is_ok() {
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);
}
result
}
}
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
//
// MP3/AAC encoders prepend an "encoder delay" (typically 5762112 silent
@@ -326,12 +451,28 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
}
/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify.
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
struct BuiltSource {
source: CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>,
duration_secs: f64,
output_rate: u32,
output_channels: u16,
/// Trigger for the sample-level crossfade fade-out.
fadeout_trigger: Arc<AtomicBool>,
/// Total samples for the fade-out (set before triggering).
fadeout_samples: Arc<AtomicU64>,
}
/// Build a fully-prepared playback source:
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
///
/// `fade_in_dur`:
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
///
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
fn build_source(
data: Vec<u8>,
duration_hint: f64,
@@ -339,12 +480,15 @@ fn build_source(
eq_enabled: Arc<AtomicBool>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
) -> Result<(NotifyingSource<EqualPowerFadeIn<EqSource<DynSource>>>, f64), String> {
sample_counter: Arc<AtomicU64>,
target_rate: u32,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
// Determine effective duration.
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
@@ -356,7 +500,8 @@ fn build_source(
.unwrap_or(duration_hint)
};
// Apply encoder-delay trim and optional end-padding trim.
// Apply encoder-delay trim and optional end-padding trim,
// then resample to the canonical target rate if needed.
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
let delay_dur = Duration::from_secs_f64(
gapless.delay_samples as f64 / sample_rate as f64
@@ -365,19 +510,47 @@ fn build_source(
if let Some(total) = gapless.total_valid_samples {
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
DynSource::new(base.take_duration(valid_dur))
let trimmed = base.take_duration(valid_dur);
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate))
} else {
DynSource::new(trimmed)
}
} else {
DynSource::new(base)
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(base, channels, target_rate))
} else {
DynSource::new(base)
}
}
} else {
DynSource::new(decoder.convert_samples::<f32>())
let converted = decoder.convert_samples::<f32>();
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
} else {
DynSource::new(converted)
}
};
let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate };
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let notifying = NotifyingSource::new(fade_in, done_flag);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
let counting = CountingSource::new(notifying, sample_counter);
Ok((notifying, effective_dur))
Ok(BuiltSource {
source: counting,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
fadeout_trigger,
fadeout_samples,
})
}
// ─── Engine state ─────────────────────────────────────────────────────────────
@@ -397,6 +570,9 @@ pub(crate) struct ChainedInfo {
base_volume: f32,
/// Set by NotifyingSource when this chained track's source is exhausted.
source_done: Arc<AtomicBool>,
/// Atomic sample counter for this chained source (swapped into
/// samples_played on transition).
sample_counter: Arc<AtomicU64>,
}
pub struct AudioEngine {
@@ -417,6 +593,16 @@ pub struct AudioEngine {
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
/// Atomic sample counter — incremented by CountingSource in the audio thread.
/// Progress task reads this for drift-free position tracking.
pub samples_played: Arc<AtomicU64>,
/// Sample rate of the currently playing source (for samples → seconds).
pub current_sample_rate: Arc<AtomicU32>,
/// Channel count of the currently playing source.
pub current_channels: Arc<AtomicU32>,
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
pub gapless_switch_at: Arc<AtomicU64>,
}
pub struct AudioCurrent {
@@ -427,6 +613,10 @@ pub struct AudioCurrent {
pub paused_at: Option<f64>,
pub replay_gain_linear: f32,
pub base_volume: f32,
/// Crossfade: trigger for sample-level fade-out of the current source.
pub fadeout_trigger: Option<Arc<AtomicBool>>,
/// Crossfade: total fade samples (set before triggering).
pub fadeout_samples: Option<Arc<AtomicU64>>,
}
impl AudioCurrent {
@@ -459,6 +649,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()
.name("psysonic-audio-stream".into())
.spawn(move || match rodio::OutputStream::try_default() {
@@ -482,6 +682,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder()
@@ -496,6 +698,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
};
(engine, thread)
@@ -532,6 +738,12 @@ async fn fetch_data(
return Ok(Some(data));
}
// Offline cache — local file written by download_track_offline.
if let Some(path) = url.strip_prefix("psysonic-local://") {
let data = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
return Ok(Some(data));
}
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
@@ -546,6 +758,12 @@ async fn fetch_data(
Ok(Some(data))
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
const MASTER_HEADROOM: f32 = 0.891_254;
fn compute_gain(
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
@@ -556,7 +774,7 @@ fn compute_gain(
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
let gain_linear = gain_linear.min(1.0 / peak);
let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
(gain_linear, effective)
}
@@ -574,6 +792,24 @@ pub async fn audio_play(
) -> Result<(), String> {
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
// After a gapless auto-advance, the frontend may fire a stale playTrack()
// call via IPC. If we're within 500 ms of the last gapless switch AND the
// requested URL matches the already-playing chained track, reject it.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
// Within the guard window — suppress this ghost command.
return Ok(());
}
}
}
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
@@ -640,16 +876,29 @@ pub async fn audio_play(
Duration::from_millis(5)
};
// Build source: decode → trim → EQ → fade-in → notify.
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
// Store the actual output rate/channels for position calculation.
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
@@ -681,11 +930,12 @@ pub async fn audio_play(
sink.append(source);
// Atomically swap sinks.
let (old_sink, old_vol) = {
// Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
@@ -693,39 +943,33 @@ pub async fn audio_play(
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
(old, old_vol)
cur.fadeout_trigger = Some(built.fadeout_trigger);
cur.fadeout_samples = Some(built.fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
// Handle old sink: equal-power crossfade or immediate stop.
// Handle old sink: symmetric crossfade or immediate stop.
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
// Duration = actual_fade_secs (Track A's measured remaining time),
// so the fade reaches exactly 0 when the source is exhausted.
const STEP_MS: u64 = 10;
let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32;
for i in (0..=total_steps).rev() {
let alive = {
let fo = fo_arc.lock().unwrap();
match fo.as_ref() {
Some(s) => {
// Equal-power cos curve: gain_a = cos(t · π/2)
// t goes 1→0 as i goes total_steps→0
let t = i as f32 / total_steps as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
s.set_volume(old_vol * gain);
true
}
None => false,
}
// MutexGuard dropped here before the await
};
if !alive { return; }
tokio::time::sleep(Duration::from_millis(STEP_MS)).await;
}
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
@@ -747,6 +991,10 @@ pub async fn audio_play(
state.crossfade_secs.clone(),
done_flag,
app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
);
Ok(())
@@ -798,12 +1046,16 @@ pub async fn audio_chain_preload(
if let Some(d) = cached {
d
} else {
let resp = state.http_client.get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = state.http_client.get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
}
};
@@ -815,14 +1067,22 @@ pub async fn audio_chain_preload(
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let done_next = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
@@ -847,6 +1107,7 @@ pub async fn audio_chain_preload(
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
sample_counter: chain_counter,
});
Ok(())
@@ -859,6 +1120,12 @@ pub async fn audio_chain_preload(
/// done flag is set AND `chained_info` has data, it swaps `done` to the
/// chained source's flag and transitions state — all without creating a new
/// task or changing the generation counter.
///
/// Key changes from the previous implementation:
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
/// • Position from atomic sample counter (no wall-clock drift)
/// • Immediate `audio:track_switched` event at decoder boundary
/// • `audio:ended` only fires when no chained successor exists
fn spawn_progress_task(
gen: u64,
gen_counter: Arc<AtomicU64>,
@@ -868,6 +1135,10 @@ fn spawn_progress_task(
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
app: AppHandle,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
) {
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
@@ -875,7 +1146,8 @@ fn spawn_progress_task(
let mut current_done = initial_done;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
tokio::time::sleep(Duration::from_millis(100)).await;
if gen_counter.load(Ordering::SeqCst) != gen {
break;
@@ -884,24 +1156,40 @@ fn spawn_progress_task(
// ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit
// audio:playing for the new track, and continue the loop.
// audio:track_switched for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
current_done = info.source_done;
// Tracking was already updated in audio_play_gapless_chain;
// just update replay gain fields in case they differ.
// Swap the sample counter: the chained source's counter
// is already being incremented by CountingSource. Copy its
// current value into the shared samples_played so the
// progress calculation stays accurate.
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
samples_played.store(chained_samples, Ordering::Relaxed);
// Update tracking state.
{
let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
// Reset play_started to now — the old track physically
// ended, the new one is now actively producing samples.
cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
}
app.emit("audio:playing", info.duration_secs).ok();
// Record the gapless switch timestamp for ghost-command guard.
let switch_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
// Emit the new track_switched event — this is immediate,
// not delayed by 500 ms like the old audio:playing was.
app.emit("audio:track_switched", info.duration_secs).ok();
near_end_ticks = 0;
continue;
}
@@ -910,9 +1198,26 @@ fn spawn_progress_task(
// the near-end logic below.
}
let (pos, dur, is_paused) = {
// ── Position from atomic sample counter ──────────────────────────
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
let ch = channels_arc.load(Ordering::Relaxed) as f64;
let samples = samples_played.load(Ordering::Relaxed) as f64;
let divisor = (rate * ch).max(1.0);
let dur = {
let cur = current_arc.lock().unwrap();
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
cur.duration_secs
};
let is_paused = {
let cur = current_arc.lock().unwrap();
cur.paused_at.is_some()
};
let pos = if is_paused {
let cur = current_arc.lock().unwrap();
cur.paused_at.unwrap_or(0.0)
} else {
(samples / divisor).min(dur.max(0.001))
};
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
@@ -927,7 +1232,8 @@ fn spawn_progress_task(
if dur > end_threshold && pos >= dur - end_threshold {
near_end_ticks += 1;
if near_end_ticks >= 2 {
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
@@ -982,7 +1288,21 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
// Seeking far back invalidates any pending gapless chain.
// 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 back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
@@ -992,15 +1312,17 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
.map_err(|e: rodio::source::SeekError| e.to_string())?;
if cur.paused_at.is_some() {
cur.paused_at = Some(seconds);
} else {
cur.seek_offset = seconds;
cur.play_started = Some(Instant::now());
}
if cur.sink.is_none() { return Ok(()); }
cur.sink.as_ref().unwrap()
.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
.map_err(|e| e.to_string())?;
if cur.paused_at.is_some() {
cur.paused_at = Some(seconds);
} else {
cur.seek_offset = seconds;
cur.play_started = Some(Instant::now());
}
Ok(())
}
@@ -1010,7 +1332,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
}
}
@@ -1034,11 +1356,15 @@ pub async fn audio_preload(
return Ok(());
}
}
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
Ok(())
+310 -21
View File
@@ -3,12 +3,23 @@
mod audio;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
@@ -54,14 +65,14 @@ async fn lastfm_request(
client
.get("https://ws.audioscrobbler.com/2.0/")
.query(&map)
.header("User-Agent", "psysonic/1.6.0")
.header("User-Agent", "psysonic/1.13.0")
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.form(&map)
.header("User-Agent", "psysonic/1.6.0")
.header("User-Agent", "psysonic/1.13.0")
.send()
.await
}.map_err(|e| e.to_string())?;
@@ -76,11 +87,215 @@ async fn lastfm_request(
}
#[tauri::command]
fn register_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
shortcut: String,
action: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let mut map = shortcut_map.lock().unwrap();
// Idempotent: if this exact shortcut+action is already registered, skip.
// This prevents on_shortcut() from accumulating duplicate handlers when
// registerAll() is called again after a JS HMR reload or StrictMode double-effect.
if map.get(&shortcut).map(|a| a == &action).unwrap_or(false) {
return Ok(());
}
// Unregister any existing OS grab for this shortcut before re-registering.
if let Ok(s) = shortcut.parse::<Shortcut>() {
let _ = app.global_shortcut().unregister(s);
}
map.insert(shortcut.clone(), action.clone());
drop(map); // release lock before the blocking OS call
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
app.global_shortcut()
.on_shortcut(parsed, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match action.as_str() {
"play-pause" => "media:play-pause",
"next" => "media:next",
"prev" => "media:prev",
"volume-up" => "media:volume-up",
"volume-down" => "media:volume-down",
_ => return,
};
let _ = app.emit(event_name, ());
}
})
.map_err(|e| e.to_string())
}
#[tauri::command]
fn unregister_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
shortcut: String,
) -> Result<(), String> {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
shortcut_map.lock().unwrap().remove(&shortcut);
let parsed: Shortcut = shortcut.parse().map_err(|_| format!("Invalid shortcut: {shortcut}"))?;
app.global_shortcut().unregister(parsed).map_err(|e| e.to_string())
}
#[tauri::command]
fn mpris_set_metadata(
controls: tauri::State<MprisControls>,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
cover_url: Option<String>,
duration_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::MediaMetadata;
use std::time::Duration;
let duration = duration_secs.map(|s| Duration::from_secs_f64(s));
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
album: album.as_deref(),
cover_url: cover_url.as_deref(),
duration,
})
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
#[tauri::command]
fn mpris_set_playback(
controls: tauri::State<MprisControls>,
playing: bool,
position_secs: Option<f64>,
) -> Result<(), String> {
use souvlaki::{MediaPlayback, MediaPosition};
use std::time::Duration;
let progress = position_secs.map(|s| MediaPosition(Duration::from_secs_f64(s)));
let playback = if playing {
MediaPlayback::Playing { progress }
} else {
MediaPlayback::Paused { progress }
};
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
ctrl.set_playback(playback)
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
}
// ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
async fn download_track_offline(
track_id: String,
server_id: String,
url: String,
suffix: String,
app: tauri::AppHandle,
) -> Result<String, String> {
let cache_dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id);
tokio::fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
// Already cached — skip re-download.
if file_path.exists() {
return Ok(path_str);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory.
#[tauri::command]
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
let offline_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
if !offline_dir.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![offline_dir];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
/// Removes a cached track from the offline cache directory.
#[tauri::command]
async fn delete_offline_track(
track_id: String,
server_id: String,
suffix: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
.join(format!("{}.{}", track_id, suffix));
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
@@ -138,27 +353,94 @@ pub fn run() {
})
.build(app)?;
// Register media key global shortcuts
#[cfg(not(target_os = "linux"))]
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
{
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
let shortcuts = ["MediaPlayPause", "MediaNextTrack", "MediaPreviousTrack"];
for shortcut_str in &shortcuts {
if let Ok(shortcut) = shortcut_str.parse::<Shortcut>() {
let shortcut_clone = shortcut_str.to_string();
let _ = app.global_shortcut().on_shortcut(shortcut, move |app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
let event_name = match shortcut_clone.as_str() {
"MediaPlayPause" => "media:play-pause",
"MediaNextTrack" => "media:next",
"MediaPreviousTrack" => "media:prev",
_ => return,
};
let _ = app.emit(event_name, ());
}
});
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
// Collect pre-conditions and the platform-specific HWND.
// Returns None early (with a log) on any unrecoverable condition
// so app.manage() always executes exactly once at the bottom.
let maybe_controls: Option<MediaControls> = (|| {
// Linux: requires a live D-Bus session.
#[cfg(target_os = "linux")]
{
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !dbus_ok {
eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
return None;
}
}
}
// Windows: souvlaki SMTC must hook into the existing Win32
// message loop rather than spinning up its own. Pass the
// main window's HWND so it can do so. If we can't get one,
// skip init (no crash, just no media overlay).
#[cfg(target_os = "windows")]
let hwnd = {
use tauri::Manager;
let h = app.get_webview_window("main")
.and_then(|w| w.hwnd().ok())
.map(|h| h.0 as *mut std::ffi::c_void);
if h.is_none() {
eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
return None;
}
h
};
#[cfg(not(target_os = "windows"))]
let hwnd: Option<*mut std::ffi::c_void> = None;
let config = PlatformConfig {
dbus_name: "psysonic",
display_name: "Psysonic",
hwnd,
};
match MediaControls::new(config) {
Ok(mut controls) => {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
match event {
MediaControlEvent::Toggle
| MediaControlEvent::Play
| MediaControlEvent::Pause => {
let _ = app_handle.emit("media:play-pause", ());
}
MediaControlEvent::Next => {
let _ = app_handle.emit("media:next", ());
}
MediaControlEvent::Previous => {
let _ = app_handle.emit("media:prev", ());
}
MediaControlEvent::Seek(direction) => {
use souvlaki::SeekDirection;
let delta: f64 = match direction {
SeekDirection::Forward => 5.0,
SeekDirection::Backward => -5.0,
};
let _ = app_handle.emit("media:seek-relative", delta);
}
MediaControlEvent::SetPosition(pos) => {
let secs = pos.0.as_secs_f64();
let _ = app_handle.emit("media:seek-absolute", secs);
}
_ => {}
}
}) {
eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
}
Some(controls)
}
Err(e) => {
eprintln!("[Psysonic] Could not create media controls: {e:?}");
None
}
}
})();
app.manage(MprisControls::new(maybe_controls));
}
Ok(())
@@ -175,6 +457,10 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
greet,
exit_app,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
mpris_set_playback,
audio::audio_play,
audio::audio_pause,
audio::audio_resume,
@@ -187,6 +473,9 @@ pub fn run() {
audio::audio_set_gapless,
audio::audio_chain_preload,
lastfm_request,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+7 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.11.0",
"version": "1.18.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,7 +23,8 @@
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false
"dragDropEnabled": false,
"devtools": false
}
],
"security": {
@@ -54,6 +55,10 @@
"bundleMediaFramework": true
}
},
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15"
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
+96 -25
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -34,13 +34,17 @@ import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn, servers, activeServerId } = useAuthStore();
@@ -58,6 +62,26 @@ function AppShell() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
const navigate = useNavigate();
const location = useLocation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Auto-navigate to offline library when no connection but cached content exists
const prevConnStatus = useRef(connStatus);
useEffect(() => {
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
navigate('/offline', { replace: true });
}
// Return from offline page only when reconnecting (not when user navigates there manually while online)
if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') {
navigate('/', { replace: true });
}
}, [connStatus, hasOfflineContent, location.pathname, navigate]);
useEffect(() => {
initializeFromServerQueue();
@@ -134,9 +158,9 @@ function AppShell() {
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
<main className="main-content">
<header className="content-header">
@@ -146,16 +170,19 @@ function AppShell() {
<LastfmIndicator />
<NowPlayingDropdown />
<button
className="collapse-btn"
className="queue-toggle-btn"
onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={24} /> : <PanelRight size={24} />}
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
{connStatus === 'disconnected' && hasOfflineContent && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && (
{connStatus === 'disconnected' && !hasOfflineContent && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
@@ -179,6 +206,7 @@ function AppShell() {
<Route path="/now-playing" element={<NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
</Routes>
</div>
</main>
@@ -214,6 +242,9 @@ function TauriEventBridge() {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Global shortcuts use modifier combos — skip in-app bindings for those
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
@@ -254,27 +285,63 @@ function TauriEventBridge() {
}, []);
useEffect(() => {
let cancelled = false;
const unlisten: Array<() => void> = [];
listen('media:play-pause', () => togglePlay()).then(u => unlisten.push(u));
listen('media:next', () => next()).then(u => unlisten.push(u));
listen('media:prev', () => previous()).then(u => unlisten.push(u));
listen('tray:play-pause', () => togglePlay()).then(u => unlisten.push(u));
listen('tray:next', () => next()).then(u => unlisten.push(u));
// Handle close → minimize to tray if enabled (Tauri 2 approach)
const win = getCurrentWindow();
win.onCloseRequested(async (event) => {
if (minimizeToTray) {
event.preventDefault();
await win.hide();
} else {
// If not minimizing to tray, we want to exit the app completely
await invoke('exit_app');
const setup = async () => {
const handlers: Array<[string, () => void]> = [
['media:play-pause', () => togglePlay()],
['media:next', () => next()],
['media:prev', () => previous()],
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
['tray:play-pause', () => togglePlay()],
['tray:next', () => next()],
];
for (const [event, handler] of handlers) {
const u = await listen(event, handler);
if (cancelled) { u(); return; }
unlisten.push(u);
}
}).then(u => unlisten.push(u));
return () => unlisten.forEach(u => u());
// Seek events carry a numeric payload (seconds) — seek() expects 0-1 progress
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
{
const u = await listen<number>('media:seek-absolute', e => {
const s = usePlayerStore.getState();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(e.payload / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
}
// Handle close → minimize to tray if enabled (Tauri 2 approach)
const win = getCurrentWindow();
const u = await win.onCloseRequested(async (event) => {
if (minimizeToTray) {
event.preventDefault();
await win.hide();
} else {
await invoke('exit_app');
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
};
setup();
return () => { cancelled = true; unlisten.forEach(u => u()); };
}, [togglePlay, next, previous, minimizeToTray]);
return null;
@@ -296,6 +363,10 @@ export default function App() {
return initAudioListeners();
}, []);
useEffect(() => {
useGlobalShortcutsStore.getState().registerAll();
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
+47
View File
@@ -0,0 +1,47 @@
export interface LrclibLyrics {
syncedLyrics: string | null;
plainLyrics: string | null;
}
export interface LrcLine {
time: number; // seconds
text: string;
}
export async function fetchLyrics(
artist: string,
title: string,
album: string,
duration: number,
): Promise<LrclibLyrics | null> {
const params = new URLSearchParams({
artist_name: artist,
track_name: title,
album_name: album,
duration: Math.round(duration).toString(),
});
try {
const res = await fetch(`https://lrclib.net/api/get?${params}`);
if (!res.ok) return null;
const data = await res.json();
return {
syncedLyrics: data.syncedLyrics ?? null,
plainLyrics: data.plainLyrics ?? null,
};
} catch {
return null;
}
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const line of lrc.split('\n')) {
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
if (!match) continue;
const mins = parseInt(match[1], 10);
const secs = parseFloat(match[2]);
const text = match[3].trim();
lines.push({ time: mins * 60 + secs, text });
}
return lines.sort((a, b) => a.time - b.time);
}
+18 -3
View File
@@ -1,8 +1,10 @@
import React from 'react';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { Play, HardDriveDownload } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
@@ -10,9 +12,15 @@ interface AlbumCardProps {
album: SubsonicAlbum;
}
export default function AlbumCard({ album }: AlbumCardProps) {
function AlbumCard({ album }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
return (
@@ -48,6 +56,11 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</svg>
</div>
)}
{isOffline && (
<div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
@@ -66,3 +79,5 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</div>
);
}
export default memo(AlbumCard);
+36 -6
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
@@ -70,10 +70,14 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onCacheOffline: () => void;
onRemoveOffline: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onBio: () => void;
@@ -88,10 +92,14 @@ export default function AlbumHeader({
resolvedCoverUrl,
isStarred,
downloadProgress,
offlineStatus,
offlineProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onCacheOffline,
onRemoveOffline,
onPlayAll,
onEnqueueAll,
onBio,
@@ -103,6 +111,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
return (
<>
@@ -159,6 +168,7 @@ export default function AlbumHeader({
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{formatLabel && <span>· {formatLabel}</span>}
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
@@ -191,7 +201,7 @@ export default function AlbumHeader({
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
@@ -214,10 +224,30 @@ export default function AlbumHeader({
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-ghost offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -86,7 +86,7 @@ export default function AlbumTrackList({
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred,
starred: song.starred, genre: song.genre,
});
return (
@@ -156,7 +156,7 @@ export default function AlbumTrackList({
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
+2 -2
View File
@@ -69,7 +69,7 @@ export default function ContextMenu() {
const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
playTrack(radioTracks[0], radioTracks);
}
@@ -135,7 +135,7 @@ export default function ContextMenu() {
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
})}>
+15 -1
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
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 coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(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} />}
</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>
+20 -2
View File
@@ -80,11 +80,28 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const album = albums[activeIdx] ?? null;
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
}, [album?.id]);
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
// Resolve cover thumbnail via cache
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
@@ -99,7 +116,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
<HeroBg url={resolvedBgUrl} />
<HeroBg url={stableBgUrl.current} />
<div className="hero-overlay" aria-hidden="true" />
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -120,6 +137,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
@@ -140,7 +158,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
usePlayerStore.getState().enqueue(tracks);
} catch (_) { }
+2 -2
View File
@@ -58,7 +58,7 @@ export default function LiveSearch() {
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre });
setOpen(false); setQuery('');
}}))),
] : [];
@@ -176,7 +176,7 @@ export default function LiveSearch() {
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating });
playTrack({ id: s.id, title: s.title, artist: s.artist, album: s.album, albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre });
setOpen(false); setQuery('');
}}
role="option" aria-selected={activeIndex === i}>
+132
View File
@@ -0,0 +1,132 @@
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;
}
interface CachedLyrics {
syncedLines: LrcLine[] | null;
plainLyrics: string | null;
notFound: boolean;
}
// Session-level cache — survives tab switches (component unmount/remount).
// Cleared implicitly when the app restarts.
const lyricsCache = new Map<string, CachedLyrics>();
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
const [loading, setLoading] = useState(!cached && !!currentTrack);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(cached?.plainLyrics ?? null);
const [notFound, setNotFound] = useState(cached?.notFound ?? false);
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) return;
// Serve from cache if available
const hit = lyricsCache.get(currentTrack.id);
if (hit) {
setSyncedLines(hit.syncedLines);
setPlainLyrics(hit.plainLyrics);
setNotFound(hit.notFound);
setLoading(false);
lineRefs.current = [];
prevActive.current = -1;
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);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setNotFound(true);
return;
}
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
const synced = lines && lines.length > 0 ? lines : null;
lyricsCache.set(currentTrack.id, { syncedLines: synced, plainLyrics: result.plainLyrics, notFound: false });
setSyncedLines(synced);
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setLoading(false);
setNotFound(true);
}
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
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>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
interface Props {
text: string;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
}
export default function MarqueeText({ text, className, style, onClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [text, measure]);
return (
<div
ref={containerRef}
className={`marquee-wrap${className ? ` ${className}` : ''}`}
style={style}
onClick={onClick}
>
<span
ref={textRef}
className={scrollAmount > 0 ? 'marquee-scroll' : ''}
style={scrollAmount > 0 ? { '--marquee-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{text}
</span>
</div>
);
}
+1 -1
View File
@@ -146,7 +146,7 @@ export default function NowPlayingDropdown() {
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
<User size={10} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
onRetry: () => void;
isChecking: boolean;
}
export default function OfflineBanner({ onRetry, isChecking }: Props) {
const { t } = useTranslation();
return (
<div className="offline-banner">
<WifiOff size={14} />
<span>{t('connection.offlineModeBanner')}</span>
<button
className="offline-banner-retry"
onClick={onRetry}
disabled={isChecking}
>
<RefreshCw size={12} className={isChecking ? 'spin' : ''} />
{t('connection.retry')}
</button>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
interface Props {
className?: string;
style?: React.CSSProperties;
}
export default function PSmallLogo({ className, style }: Props) {
return (
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
</linearGradient>
</defs><g
id="layer1"
transform="translate(220.53237,27.789086)"><path
fill="url(#psmallGrad)"
d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z"
id="path1" /></g></svg>
);
}
+45 -22
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -12,6 +12,8 @@ import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -24,11 +26,15 @@ export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
@@ -73,20 +79,18 @@ export default function PlayerBar() {
)}
</div>
<div className="player-track-meta">
<div
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
{currentTrack?.title ?? t('player.noTitle')}
</div>
<div
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
{currentTrack?.artist ?? '—'}
</div>
/>
</div>
{currentTrack && lastfmSessionKey && (
<button
@@ -115,7 +119,7 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
@@ -140,6 +144,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* Lyrics Button */}
<button
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={15} />
</button>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
@@ -160,18 +174,27 @@ export default function PlayerBar() {
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
/>
<div className="player-volume-slider-wrap">
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
</div>
</div>
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
File diff suppressed because one or more lines are too long
+68 -35
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -139,6 +141,9 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
@@ -265,7 +270,7 @@ export default function QueuePanel() {
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
}
@@ -314,41 +319,46 @@ export default function QueuePanel() {
{currentTrack && (
<div className="queue-current-track">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
: currentTrack.suffix?.toUpperCase() ?? ''}
</div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate) && (
<div className="queue-current-tech">
{[
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
].filter(Boolean).join(' · ')}
</div>
)}
<div className="queue-current-track-body">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
{renderStars(currentTrack.userRating)}
</div>
</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')}>
<Shuffle size={13} />
</button>
@@ -364,7 +374,7 @@ export default function QueuePanel() {
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => setGaplessEnabled(!gaplessEnabled)}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
@@ -379,6 +389,7 @@ export default function QueuePanel() {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
@@ -472,6 +483,28 @@ export default function QueuePanel() {
})
)}
</div>
</>) : (
<LyricsPane currentTrack={currentTrack} />
)}
<div className="queue-tab-bar">
<button
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
onClick={() => setTab('queue')}
aria-label={t('queue.title')}
>
<ListMusic size={14} />
{t('queue.title')}
</button>
<button
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
onClick={() => setTab('lyrics')}
aria-label={t('player.lyrics')}
>
<MicVocal size={14} />
{t('player.lyrics')}
</button>
</div>
{saveModalOpen && (
<SavePlaylistModal
@@ -496,7 +529,7 @@ export default function QueuePanel() {
const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
if (tracks.length > 0) {
clearQueue();
+49 -17
View File
@@ -1,17 +1,17 @@
import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload
} from 'lucide-react';
const PsysonicLogo = () => (
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
);
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
const navItems = [
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
@@ -63,7 +63,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
export default function Sidebar({
isCollapsed = false,
toggleCollapse
toggleCollapse,
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
@@ -71,6 +71,11 @@ export default function Sidebar({
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineStore(s => s.jobs);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
@@ -99,19 +104,21 @@ export default function Sidebar({
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<button
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="bottom"
style={{ padding: 0 }}
>
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
</button>
{!isCollapsed && <PsysonicLogo />}
{!isCollapsed && <span className="brand-name">Psysonic</span>}
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
}
</div>
<button
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="right"
>
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Hauptnavigation">
{!isCollapsed && <span className="nav-section-label">{t('sidebar.library')}</span>}
{navItems.map(item => (
@@ -143,6 +150,18 @@ export default function Sidebar({
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
</NavLink>
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `nav-link nav-link-offline ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined}
data-tooltip-pos="bottom"
>
<HardDriveDownload size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.offlineLibrary')}</span>}
</NavLink>
)}
{!isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
@@ -172,6 +191,19 @@ export default function Sidebar({
<Settings size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
</NavLink>
{activeJobs.length > 0 && (
<div
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobs.length }) : undefined}
data-tooltip-pos="right"
>
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
)}
</div>
)}
</nav>
</aside>
);
+189 -155
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react';
import { useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef {
id: string;
@@ -10,81 +11,105 @@ interface ThemeDef {
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Psysonic Themes',
group: 'Games',
themes: [
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
],
},
{
group: 'Operating Systems',
themes: [
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'aero-glass', label: 'Aero Glass', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'luna-teal', label: 'Luna Teal', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Catppuccin',
themes: [
{ 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' },
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'tetrastack', label: 'TetraStack', bg: '#0a0a0a', card: '#151515', accent: '#00f0f0' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
],
},
{
group: 'Movies',
themes: [
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 't-800', label: 'T-800', bg: '#1f242d', card: '#0a0c10', accent: '#00d4ff' },
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
],
},
{
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: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ 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: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
],
},
{
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: 'Mediaplayer',
themes: [
{ id: 'winmedplayer', label: 'WinMedPlayer', 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: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
{ 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: '#100c08', card: '#090c10', accent: '#c41e1e' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
],
},
{
group: 'Social Media',
themes: [
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
],
},
];
@@ -95,93 +120,102 @@ interface Props {
}
export default function ThemePicker({ value, onChange }: Props) {
const initialOpen = THEME_GROUPS.find(g => g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group;
const [openGroup, setOpenGroup] = useState<string | null>(initialOpen);
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{THEME_GROUPS.map(({ group, themes }) => (
<div key={group}>
<div style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-muted)',
marginBottom: '10px',
}}>
{group}
<div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => {
const isOpen = openGroup === group;
const hasActive = themes.some(t => t.id === value);
return (
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
<button className="theme-accordion-header" onClick={() => toggle(group)}>
<span>
{group}
{hasActive && !isOpen && (
<span className="theme-accordion-active-dot" />
)}
</span>
<ChevronDown size={15} className="theme-accordion-chevron" />
</button>
{isOpen && (
<div className="theme-accordion-content">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
{/* main bg */}
<div style={{ background: t.bg, height: '55%' }} />
{/* card tone */}
<div style={{ background: t.card, height: '20%' }} />
{/* accent bar */}
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
))}
);
})}
</div>
);
}
+34 -10
View File
@@ -1,6 +1,11 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
const BAR_COUNT = 500;
function hashStr(str: string): number {
@@ -125,9 +130,12 @@ export default function WaveformSeek({ trackId }: Props) {
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
progressRef.current = progress;
bufferedRef.current = buffered;
@@ -175,14 +183,30 @@ export default function WaveformSeek({ trackId }: Props) {
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
/>
<div style={{ position: 'relative', width: '100%' }}>
{hoverPct !== null && duration > 0 && (
<span
className="player-volume-pct"
style={{ left: `${hoverPct * 100}%` }}
>
{fmt(hoverPct * duration)}
</span>
)}
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
const rect = e.currentTarget.getBoundingClientRect();
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseLeave={() => setHoverPct(null)}
/>
</div>
);
}
+180 -48
View File
@@ -22,7 +22,9 @@ const enTranslation = {
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
updateLink: 'Go to release →',
downloadingTracks: 'Caching {{n}} tracks…',
offlineLibrary: 'Offline Library',
},
home: {
starred: 'Personal Favorites',
@@ -94,8 +96,10 @@ const enTranslation = {
artistBio: 'Artist Bio',
download: 'Download (ZIP)',
downloading: 'Loading…',
downloadHint: 'FLAC/WAV albums are zipped server-side first — large albums may take a moment before the download starts.',
downloadHintShort: 'Server zips first — may take a moment depending on file size',
cacheOffline: 'Make available offline',
offlineCached: 'Available offline',
offlineDownloading: 'Caching… ({{n}}/{{total}})',
removeOffline: 'Remove offline cache',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
favorite: 'Favorite',
@@ -148,6 +152,7 @@ const enTranslation = {
albums: 'Albums',
songs: 'Songs',
enqueueAll: 'Add all to queue',
removeSong: 'Remove from favorites',
},
randomAlbums: {
title: 'Random Albums',
@@ -168,17 +173,20 @@ const enTranslation = {
play: 'Play',
trackGenre: 'Genre',
excludeAudiobooks: 'Exclude audiobooks & radio plays',
excludeAudiobooksDesc: 'Matches keywords against genre, title, and album — e.g. Hörbuch, Audiobook, Spoken Word, …',
excludeAudiobooksDesc: 'Matches keywords against genre, title, album, and artist — e.g. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword blocked',
genreAddedToBlacklist: 'Added to filter list',
genreAlreadyBlocked: 'Already blocked',
artistBlocked: 'Artist blocked',
artistAddedToBlacklist: 'Artist added to filter list',
artistClickHint: 'Click to block this artist',
blacklistToggle: 'Keyword Filter',
genreMixTitle: 'Genre Mix',
genreMixDesc: 'Select a genre to get a curated random mix',
genreMixLoadMore: 'Load 10 more',
genreMixNoGenres: 'No genres found on server.',
filterPanelTitle: 'Filters',
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title & album.',
genreClickHint: 'Click a genre tag to add it\nas a filter keyword.\nMatches genre, title, album & artist.',
},
playlists: {
title: 'Playlists',
@@ -242,6 +250,11 @@ const enTranslation = {
extern: 'Extern',
offlineTitle: 'No server connection',
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
offlineModeBanner: 'Offline Mode — playing from local cache',
offlineLibraryTitle: 'Offline Library',
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
retry: 'Retry',
lastfmConnected: 'Last.fm connected as @{{user}}',
lastfmSessionInvalid: 'Session invalid — click to re-connect',
@@ -321,8 +334,13 @@ const enTranslation = {
behavior: 'App Behavior',
trayTitle: 'Minimize to Tray',
trayDesc: 'Minimize app to the system tray on close (X)',
cacheTitle: 'Max. Cache Size',
cacheDesc: 'For preloaded tracks',
cacheTitle: 'Max. Image Cache Size',
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
cacheClearBtn: 'Clear Cache',
cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything',
cacheClearCancel: 'Cancel',
downloadsTitle: 'Download Folder',
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
@@ -341,7 +359,7 @@ const enTranslation = {
changelog: 'Changelog',
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
randomMixBlacklistPlaceholder: 'Add keyword…',
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom keywords added yet.',
@@ -355,6 +373,8 @@ const enTranslation = {
shortcutsReset: 'Reset to defaults',
shortcutListening: 'Press a key…',
shortcutUnbound: '—',
globalShortcutsTitle: 'Global Shortcuts',
globalShortcutsNote: 'Work system-wide even when Psysonic is in the background. Requires Ctrl, Alt, or Super as a modifier.',
shortcutClear: 'Clear',
shortcutPlayPause: 'Play / Pause',
shortcutNext: 'Next track',
@@ -375,6 +395,8 @@ const enTranslation = {
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Not available while Gapless is active',
notWithCrossfade: 'Not available while Crossfade is active',
gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
experimental: 'Experimental',
@@ -392,7 +414,7 @@ const enTranslation = {
q4: 'How do I play music?',
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
q5: 'What keyboard shortcuts are available?',
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on macOS and Windows. On Linux, use the player bar buttons.',
a5: 'Space = Play / Pause · Escape = Close fullscreen player. Media keys (Play/Pause, Next, Previous) work on all platforms. On Linux, use the player bar buttons for media keys. In-app shortcuts and system-wide global shortcuts can be customized in Settings → Keyboard Shortcuts / Global Shortcuts.',
q6: 'What is the queue?',
a6: 'The queue shows all upcoming tracks. Open it with the panel icon in the top-right header (next to the Now Playing indicator). You can reorder tracks by dragging, shuffle with the shuffle button, and save the queue as a playlist.',
q7: 'How do I open the fullscreen player?',
@@ -401,14 +423,14 @@ const enTranslation = {
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
s3: 'Library',
q9: 'How do I download an album?',
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server zips the album first — this may take a moment for large albums or lossless files (FLAC / WAV). A progress bar shows the download status.',
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server compresses the album into a ZIP file first — for large albums or lossless files (FLAC / WAV) this can take a moment before the download actually starts. This is normal: the progress bar appears once the server finishes zipping and the transfer begins.',
q10: 'How do I star / favorite tracks and albums?',
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
q11: 'What is the hero carousel on the home page?',
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
s4: 'Settings',
q12: 'How do I change the theme?',
a12: 'Settings → Theme. Choose from 20+ themes across 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night, and the exclusive Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
a12: 'Settings → Theme. Choose from 47 themes across 7 groups: Psysonic Themes, Psysonic Mediaplayer, Operating Systems, Games, Movies, Series, and Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'How do I change the language?',
a13: 'Settings → Language. English, German, French, and Dutch are supported.',
q14: 'What does "Minimize to Tray" do?',
@@ -444,6 +466,14 @@ const enTranslation = {
a21: 'This is usually a GPU/EGL driver issue in WebKitGTK. Launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. The AUR package and official .deb/.rpm installers set these automatically.',
q29: 'What are Crossfade and Gapless Playback?',
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (110 s) to taste.',
q30: 'Does Psysonic show lyrics?',
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
q33: 'Can I seek (scrub) within a FLAC file?',
a33: 'Seeking in FLAC files requires a SEEKTABLE metadata block to be embedded in the file. Files that were encoded without one cannot be seeked — clicking the waveform will have no effect. Most modern encoders (e.g. ffmpeg, flac CLI) include a SEEKTABLE by default. You can add one to existing files using: flac --replay-gain *.flac or metaflac --add-seekpoint=10s *.flac',
q31: 'Can I customize keyboard shortcuts?',
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
q32: 'Can I change the font?',
a32: 'Yes. Settings → Font lets you choose from 10 fonts including IBM Plex Mono, Fira Code, JetBrains Mono, Courier Prime, and others. The selected font applies to the entire UI.',
},
queue: {
title: 'Queue',
@@ -525,7 +555,10 @@ const enTranslation = {
repeatOne: 'One',
progress: 'Song Progress',
volume: 'Volume',
toggleQueue: 'Toggle Queue'
toggleQueue: 'Toggle Queue',
lyrics: 'Lyrics',
lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track',
}
};
@@ -550,7 +583,9 @@ const deTranslation = {
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
updateLink: 'Zum Release →',
downloadingTracks: '{{n}} Tracks werden gecacht…',
offlineLibrary: 'Offline-Bibliothek',
},
home: {
starred: 'Persönliche Favoriten',
@@ -622,8 +657,10 @@ const deTranslation = {
artistBio: 'Künstler-Bio',
download: 'Download (ZIP)',
downloading: 'Lade…',
downloadHint: 'FLAC/WAV-Alben werden serverseitig zuerst gezippt — bei großen Alben kann es einen Moment dauern, bevor der Download startet.',
downloadHintShort: 'Server zippt zuerst — je nach Dateigröße kann es etwas dauern bevor der Download startet',
cacheOffline: 'Offline verfügbar machen',
offlineCached: 'Offline verfügbar',
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
removeOffline: 'Offline-Cache löschen',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
favorite: 'Als Favorit',
@@ -676,6 +713,7 @@ const deTranslation = {
albums: 'Alben',
songs: 'Songs',
enqueueAll: 'Alle in die Warteschlange',
removeSong: 'Aus Favoriten entfernen',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -696,17 +734,20 @@ const deTranslation = {
play: 'Abspielen',
trackGenre: 'Genre',
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel und Album — z. B. Hörbuch, Audiobook, Spoken Word, …',
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel, Album und Künstler — z. B. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword gesperrt',
genreAddedToBlacklist: 'Zur Filterliste hinzugefügt',
genreAlreadyBlocked: 'Bereits gesperrt',
artistBlocked: 'Künstler gesperrt',
artistAddedToBlacklist: 'Künstler zur Filterliste hinzugefügt',
artistClickHint: 'Klicken um diesen Künstler zu sperren',
blacklistToggle: 'Keyword-Filter',
genreMixTitle: 'Genre-Mix',
genreMixDesc: 'Genre auswählen für einen passenden Zufallsmix',
genreMixLoadMore: '10 weitere laden',
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
filterPanelTitle: 'Filter',
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel & Album.',
genreClickHint: 'Genre-Tag anklicken,\num es als Filter-Keyword hinzuzufügen.\nPrüft Genre, Titel, Album & Künstler.',
},
playlists: {
title: 'Playlists',
@@ -770,6 +811,11 @@ const deTranslation = {
extern: 'Extern',
offlineTitle: 'Keine Serververbindung',
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
offlineLibraryTitle: 'Offline-Bibliothek',
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
offlineAlbumCount: '{{n}} Album',
offlineAlbumCount_plural: '{{n}} Alben',
retry: 'Erneut versuchen',
lastfmConnected: 'Last.fm verbunden als @{{user}}',
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
@@ -849,8 +895,13 @@ const deTranslation = {
behavior: 'App-Verhalten',
trayTitle: 'In Tray minimieren',
trayDesc: 'App beim Schließen (X) in den System-Tray minimieren',
cacheTitle: 'Max. Cache-Größe',
cacheDesc: 'Für vorgeladene Tracks',
cacheTitle: 'Max. Bild-Cache-Größe',
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
cacheClearBtn: 'Cache leeren',
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
cacheClearConfirm: 'Alles löschen',
cacheClearCancel: 'Abbrechen',
downloadsTitle: 'Download-Ordner',
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
@@ -869,7 +920,7 @@ const deTranslation = {
changelog: 'Changelog',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
randomMixBlacklistPlaceholder: 'Keyword hinzufügen…',
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
@@ -883,6 +934,8 @@ const deTranslation = {
shortcutListening: 'Taste drücken…',
shortcutUnbound: '—',
shortcutClear: 'Löschen',
globalShortcutsTitle: 'Globale Shortcuts',
globalShortcutsNote: 'Funktionieren systemweit, auch wenn Psysonic im Hintergrund läuft. Mindestens Ctrl, Alt oder Super als Modifier erforderlich.',
shortcutPlayPause: 'Wiedergabe / Pause',
shortcutNext: 'Nächster Titel',
shortcutPrev: 'Vorheriger Titel',
@@ -903,6 +956,8 @@ const deTranslation = {
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
experimental: 'Experimentell',
@@ -920,7 +975,7 @@ const deTranslation = {
q4: 'Wie spiele ich Musik ab?',
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
q5: 'Welche Tastenkürzel gibt es?',
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren unter macOS und Windows. Unter Linux bitte die Buttons in der Playerleiste nutzen.',
a5: 'Leertaste = Play / Pause · Escape = Vollbild schließen. Medientasten (Play/Pause, Weiter, Zurück) funktionieren auf allen Plattformen. Unter Linux bitte die Buttons in der Playerleiste nutzen. In-App-Kürzel und systemweite globale Shortcuts können in Einstellungen → Tastenkürzel / Globale Shortcuts angepasst werden.',
q6: 'Was ist die Warteschlange?',
a6: 'Die Warteschlange zeigt alle kommenden Tracks. Öffnen mit dem Panel-Icon oben rechts im Header (neben dem Now-Playing-Indikator). Tracks per Drag & Drop umsortieren, mit dem Shuffle-Button mischen oder als Playlist speichern.',
q7: 'Wie öffne ich den Vollbild-Player?',
@@ -929,14 +984,14 @@ const deTranslation = {
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
s3: 'Bibliothek',
q9: 'Wie lade ich ein Album herunter?',
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server zieht das Album zuerst zusammen — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann das einen Moment dauern. Ein Fortschrittsbalken zeigt den Status.',
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server packt das Album zunächst in eine ZIP-Datei — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann es einen Moment dauern, bevor der Download tatsächlich startet. Das ist normal: Der Fortschrittsbalken erscheint erst, wenn der Server fertig ist und die Übertragung beginnt.',
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
q11: 'Was ist das Karussell auf der Startseite?',
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
s4: 'Einstellungen',
q12: 'Wie ändere ich das Theme?',
a12: 'Einstellungen → Design. Über 20 Designs in 6 Familien: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night und die exklusiven Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
a12: 'Einstellungen → Design. 47 Designs in 7 Gruppen: Psysonic Themes, Psysonic Mediaplayer, Betriebssysteme, Spiele, Filme, Serien und Open-Source-Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Wie ändere ich die Sprache?',
a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.',
q14: 'Was bewirkt "In Tray minimieren"?',
@@ -972,6 +1027,14 @@ const deTranslation = {
a21: 'Meist ein GPU/EGL-Treiberproblem in WebKitGTK. Mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten. Das AUR-Paket und die offiziellen .deb/.rpm-Pakete setzen diese Variablen automatisch.',
q29: 'Was sind Crossfade und nahtlose Wiedergabe?',
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (110 s) einstellbar.',
q30: 'Zeigt Psysonic Songtexte an?',
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
q33: 'Kann ich in FLAC-Dateien seekbar (spulen)?',
a33: 'Das Spulen in FLAC-Dateien erfordert einen SEEKTABLE-Metadatenblock in der Datei. Dateien, die ohne diesen Block kodiert wurden, können nicht geseekt werden — ein Klick auf die Wellenform hat keinen Effekt. Die meisten modernen Encoder (z. B. ffmpeg, flac CLI) fügen standardmäßig eine SEEKTABLE ein. Mit folgendem Befehl kann man sie nachträglich hinzufügen: flac --replay-gain *.flac oder metaflac --add-seekpoint=10s *.flac',
q31: 'Kann ich Tastenkürzel anpassen?',
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
q32: 'Kann ich die Schriftart ändern?',
a32: 'Ja. Einstellungen → Schriftart bietet 10 Schriften zur Auswahl, darunter IBM Plex Mono, Fira Code, JetBrains Mono und Courier Prime. Die gewählte Schrift gilt für die gesamte Oberfläche.',
},
queue: {
title: 'Warteschlange',
@@ -1053,7 +1116,10 @@ const deTranslation = {
repeatOne: 'Einen',
progress: 'Songfortschritt',
volume: 'Lautstärke',
toggleQueue: 'Warteschlange umschalten'
toggleQueue: 'Warteschlange umschalten',
lyrics: 'Lyrics',
lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
}
};
@@ -1078,7 +1144,9 @@ const frTranslation = {
collapse: 'Réduire la barre latérale',
updateAvailable: 'Mise à jour disponible',
updateReady: '{{version}} est prêt',
updateLink: 'Voir la version →'
updateLink: 'Voir la version →',
downloadingTracks: '{{n}} pistes en cache…',
offlineLibrary: 'Bibliothèque hors ligne',
},
home: {
starred: 'Favoris personnels',
@@ -1150,8 +1218,10 @@ const frTranslation = {
artistBio: 'Biographie',
download: 'Télécharger (ZIP)',
downloading: 'Chargement…',
downloadHint: 'Les albums FLAC/WAV sont zippés côté serveur — les grands albums peuvent prendre un moment avant le démarrage du téléchargement.',
downloadHintShort: 'Le serveur zippe d\'abord — peut prendre un moment selon la taille',
cacheOffline: 'Rendre disponible hors ligne',
offlineCached: 'Disponible hors ligne',
offlineDownloading: 'Mise en cache… ({{n}}/{{total}})',
removeOffline: 'Supprimer le cache hors ligne',
favoriteAdd: 'Ajouter aux favoris',
favoriteRemove: 'Retirer des favoris',
favorite: 'Favori',
@@ -1204,6 +1274,7 @@ const frTranslation = {
albums: 'Albums',
songs: 'Morceaux',
enqueueAll: 'Tout ajouter à la file',
removeSong: 'Retirer des favoris',
},
randomAlbums: {
title: 'Albums aléatoires',
@@ -1224,17 +1295,20 @@ const frTranslation = {
play: 'Lire',
trackGenre: 'Genre',
excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques',
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre et l\'album — ex. Hörbuch, Audiobook, Spoken Word, …',
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre, l\'album et l\'artiste — ex. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Mot-clé bloqué',
genreAddedToBlacklist: 'Ajouté à la liste de filtres',
genreAlreadyBlocked: 'Déjà bloqué',
artistBlocked: 'Artiste bloqué',
artistAddedToBlacklist: 'Artiste ajouté à la liste de filtres',
artistClickHint: 'Cliquer pour bloquer cet artiste',
blacklistToggle: 'Filtre par mot-clé',
genreMixTitle: 'Mix par genre',
genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire',
genreMixLoadMore: 'Charger 10 de plus',
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
filterPanelTitle: 'Filtres',
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre et album.',
genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre, album et artiste.',
},
playlists: {
title: 'Listes de lecture',
@@ -1298,6 +1372,11 @@ const frTranslation = {
extern: 'Externe',
offlineTitle: 'Pas de connexion au serveur',
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
offlineLibraryTitle: 'Bibliothèque hors ligne',
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
retry: 'Réessayer',
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
@@ -1377,8 +1456,13 @@ const frTranslation = {
behavior: 'Comportement de l\'application',
trayTitle: 'Réduire dans la barre système',
trayDesc: 'Réduire l\'application dans la barre système à la fermeture (X)',
cacheTitle: 'Taille max. du cache',
cacheDesc: 'Pour les pistes préchargées',
cacheTitle: 'Taille max. du cache images',
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
cacheClearBtn: 'Vider le cache',
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
cacheClearConfirm: 'Tout supprimer',
cacheClearCancel: 'Annuler',
downloadsTitle: 'Dossier de téléchargement',
downloadsDefault: 'Dossier de téléchargement par défaut',
pickFolder: 'Sélectionner',
@@ -1397,7 +1481,7 @@ const frTranslation = {
changelog: 'Journal des modifications',
randomMixTitle: 'Mix aléatoire',
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre ou album (actif quand la case ci-dessus est cochée).',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…',
randomMixBlacklistAdd: 'Ajouter',
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
@@ -1411,6 +1495,8 @@ const frTranslation = {
shortcutListening: 'Appuyez sur une touche…',
shortcutUnbound: '—',
shortcutClear: 'Effacer',
globalShortcutsTitle: 'Raccourcis globaux',
globalShortcutsNote: 'Fonctionnent à l\'échelle du système, même quand Psysonic est en arrière-plan. Nécessite Ctrl, Alt ou Super comme modificateur.',
shortcutPlayPause: 'Lecture / Pause',
shortcutNext: 'Piste suivante',
shortcutPrev: 'Piste précédente',
@@ -1431,6 +1517,8 @@ const frTranslation = {
crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
gapless: 'Lecture sans blanc',
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
experimental: 'Expérimental',
@@ -1448,7 +1536,7 @@ const frTranslation = {
q4: 'Comment lire de la musique ?',
a4: 'Double-cliquez sur une piste pour la lire. Sur les pages d\'album et d\'artiste, utilisez « Tout lire » pour démarrer l\'album entier. Vous pouvez aussi glisser des pistes dans la file d\'attente.',
q5: 'Quels raccourcis clavier sont disponibles ?',
a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur macOS et Windows. Sur Linux, utilisez les boutons de la barre du lecteur.',
a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur toutes les plateformes. Sur Linux, utilisez les boutons de la barre du lecteur. Les raccourcis in-app et les raccourcis système peuvent être personnalisés dans Paramètres → Raccourcis / Raccourcis globaux.',
q6: 'Qu\'est-ce que la file d\'attente ?',
a6: 'La file d\'attente affiche toutes les pistes à venir. Ouvrez-la avec l\'icône de panneau en haut à droite. Vous pouvez réorganiser les pistes par glisser-déposer, les mélanger et enregistrer la file comme liste de lecture.',
q7: 'Comment ouvrir le lecteur plein écran ?',
@@ -1457,14 +1545,14 @@ const frTranslation = {
a8: 'Cliquez sur le bouton répéter pour alterner : Désactivé → Répéter tout → Répéter un.',
s3: 'Bibliothèque',
q9: 'Comment télécharger un album ?',
a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur zippe d\'abord l\'album — cela peut prendre un moment pour les grands albums ou les fichiers sans perte.',
a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur compresse d\'abord l\'album en ZIP — pour les grands albums ou les fichiers sans perte (FLAC / WAV), cela peut prendre un moment avant que le téléchargement ne démarre réellement. La barre de progression n\'apparaît qu\'une fois la compression terminée.',
q10: 'Comment mettre des pistes et albums en favoris ?',
a10: 'Cliquez sur l\'icône étoile sur une piste ou dans l\'en-tête de l\'album. Les éléments favoris apparaissent dans la section Favoris de la barre latérale.',
q11: 'Qu\'est-ce que le carousel hero sur la page d\'accueil ?',
a11: 'La bannière en haut de la page d\'accueil sélectionne aléatoirement des albums de votre bibliothèque et les fait défiler toutes les 10 secondes. Cliquez sur les points pour accéder à un album spécifique.',
s4: 'Paramètres',
q12: 'Comment changer le thème ?',
a12: 'Paramètres → Thème. Plus de 20 thèmes en 6 familles : Catppuccin, Nord, Gruvbox (Retro), Tokyo Night et les thèmes exclusifs Psysonic (Classic Winamp, Poison, Nucleo, Psychowave).',
a12: 'Paramètres → Thème. 47 thèmes en 7 groupes : Psysonic Themes, Psysonic Mediaplayer, Systèmes d\'exploitation, Jeux, Films, Séries et Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Comment changer la langue ?',
a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.',
q14: 'Que fait « Réduire dans la barre système » ?',
@@ -1500,6 +1588,14 @@ const frTranslation = {
a21: 'Cela est généralement dû à des problèmes de pilote GPU/EGL dans WebKitGTK. Lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Les paquets AUR et installateurs .deb/.rpm officiels le configurent automatiquement.',
q29: 'Que sont le fondu enchaîné et la lecture sans blanc ?',
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (110 s).',
q30: 'Psysonic affiche-t-il les paroles ?',
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
q33: 'Puis-je faire une recherche (scrubbing) dans un fichier FLAC ?',
a33: 'La navigation dans les fichiers FLAC nécessite un bloc de métadonnées SEEKTABLE intégré dans le fichier. Les fichiers encodés sans ce bloc ne peuvent pas être recherchés — cliquer sur la forme d\'onde n\'aura aucun effet. La plupart des encodeurs modernes (p. ex. ffmpeg, flac CLI) incluent une SEEKTABLE par défaut. Vous pouvez en ajouter une aux fichiers existants avec : flac --replay-gain *.flac ou metaflac --add-seekpoint=10s *.flac',
q31: 'Puis-je personnaliser les raccourcis clavier ?',
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
q32: 'Puis-je changer la police ?',
a32: 'Oui. Paramètres → Police propose 10 polices dont IBM Plex Mono, Fira Code, JetBrains Mono et Courier Prime. La police choisie s\'applique à toute l\'interface.',
},
queue: {
title: 'File d\'attente',
@@ -1581,7 +1677,10 @@ const frTranslation = {
repeatOne: 'Un',
progress: 'Progression',
volume: 'Volume',
toggleQueue: 'Afficher/masquer la file'
toggleQueue: 'Afficher/masquer la file',
lyrics: 'Paroles',
lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
}
};
@@ -1606,7 +1705,9 @@ const nlTranslation = {
collapse: 'Zijbalk inklappen',
updateAvailable: 'Update beschikbaar',
updateReady: '{{version}} is klaar',
updateLink: 'Naar release →'
updateLink: 'Naar release →',
downloadingTracks: '{{n}} nummers worden gecached…',
offlineLibrary: 'Offline bibliotheek',
},
home: {
starred: 'Persoonlijke favorieten',
@@ -1678,8 +1779,10 @@ const nlTranslation = {
artistBio: 'Artiest biografie',
download: 'Downloaden (ZIP)',
downloading: 'Laden…',
downloadHint: 'FLAC/WAV-albums worden eerst ingepakt door de server — grote albums kunnen even duren voordat de download begint.',
downloadHintShort: 'Server pakt eerst in — kan even duren afhankelijk van bestandsgrootte',
cacheOffline: 'Offline beschikbaar maken',
offlineCached: 'Offline beschikbaar',
offlineDownloading: 'Wordt gecached… ({{n}}/{{total}})',
removeOffline: 'Offline cache verwijderen',
favoriteAdd: 'Aan favorieten toevoegen',
favoriteRemove: 'Uit favorieten verwijderen',
favorite: 'Favoriet',
@@ -1732,6 +1835,7 @@ const nlTranslation = {
albums: 'Albums',
songs: 'Nummers',
enqueueAll: 'Alles aan wachtrij toevoegen',
removeSong: 'Verwijderen uit favorieten',
},
randomAlbums: {
title: 'Willekeurige albums',
@@ -1752,17 +1856,20 @@ const nlTranslation = {
play: 'Afspelen',
trackGenre: 'Genre',
excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten',
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel en album — bijv. Hörbuch, Audiobook, Spoken Word, …',
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel, album en artiest — bijv. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Trefwoord geblokkeerd',
genreAddedToBlacklist: 'Aan filterlijst toegevoegd',
genreAlreadyBlocked: 'Al geblokkeerd',
artistBlocked: 'Artiest geblokkeerd',
artistAddedToBlacklist: 'Artiest aan filterlijst toegevoegd',
artistClickHint: 'Klik om deze artiest te blokkeren',
blacklistToggle: 'Trefwoordfilter',
genreMixTitle: 'Genremix',
genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix',
genreMixLoadMore: '10 meer laden',
genreMixNoGenres: 'Geen genres gevonden op server.',
filterPanelTitle: 'Filters',
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel & album.',
genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel, album & artiest.',
},
playlists: {
title: 'Afspeellijsten',
@@ -1826,6 +1933,11 @@ const nlTranslation = {
extern: 'Extern',
offlineTitle: 'Geen serververbinding',
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
offlineLibraryTitle: 'Offline bibliotheek',
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
retry: 'Opnieuw proberen',
lastfmConnected: 'Last.fm verbonden als @{{user}}',
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
@@ -1905,8 +2017,13 @@ const nlTranslation = {
behavior: 'App-gedrag',
trayTitle: 'Minimaliseren naar systeemvak',
trayDesc: 'App naar het systeemvak minimaliseren bij sluiten (X)',
cacheTitle: 'Max. cachegrootte',
cacheDesc: 'Voor vooraf geladen nummers',
cacheTitle: 'Max. afbeeldingscachegrootte',
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
cacheClearBtn: 'Cache wissen',
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
cacheClearConfirm: 'Alles wissen',
cacheClearCancel: 'Annuleren',
downloadsTitle: 'Downloadmap',
downloadsDefault: 'Standaard downloadmap',
pickFolder: 'Selecteren',
@@ -1925,7 +2042,7 @@ const nlTranslation = {
changelog: 'Wijzigingslog',
randomMixTitle: 'Willekeurige mix',
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel of album (actief wanneer het selectievakje hierboven is aangevinkt).',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…',
randomMixBlacklistAdd: 'Toevoegen',
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
@@ -1939,6 +2056,8 @@ const nlTranslation = {
shortcutListening: 'Druk op een toets…',
shortcutUnbound: '—',
shortcutClear: 'Wissen',
globalShortcutsTitle: 'Globale sneltoetsen',
globalShortcutsNote: 'Werken systeembreed, ook als Psysonic op de achtergrond draait. Vereist Ctrl, Alt of Super als modifier.',
shortcutPlayPause: 'Afspelen / Pauzeren',
shortcutNext: 'Volgend nummer',
shortcutPrev: 'Vorig nummer',
@@ -1959,6 +2078,8 @@ const nlTranslation = {
crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
gapless: 'Naadloos afspelen',
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
experimental: 'Experimenteel',
@@ -1976,7 +2097,7 @@ const nlTranslation = {
q4: 'Hoe speel ik muziek af?',
a4: 'Dubbelklik op een nummer om het af te spelen. Gebruik op album- en artiestpagina\'s "Alles afspelen" om het hele album te starten. Je kunt ook nummers naar de wachtrij slepen.',
q5: 'Welke sneltoetsen zijn beschikbaar?',
a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op macOS en Windows. Gebruik op Linux de knoppen van de spelerbalk.',
a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op alle platforms. Gebruik op Linux de knoppen van de spelerbalk. In-app sneltoetsen en systeembrede globale sneltoetsen zijn aan te passen via Instellingen → Sneltoetsen / Globale sneltoetsen.',
q6: 'Wat is de wachtrij?',
a6: 'De wachtrij toont alle komende nummers. Open deze met het paneelpictogram rechtsboven. Je kunt nummers herordenen door te slepen, shufflen en de wachtrij opslaan als afspeellijst.',
q7: 'Hoe open ik de volledig-schermspeler?',
@@ -1985,14 +2106,14 @@ const nlTranslation = {
a8: 'Klik op de herhalknop om te wisselen: Uit → Alles herhalen → Één herhalen.',
s3: 'Bibliotheek',
q9: 'Hoe download ik een album?',
a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server pakt het album eerst in — dit kan even duren voor grote albums of verliesvrije bestanden.',
a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server comprimeert het album eerst naar een ZIP-bestand — voor grote albums of verliesvrije bestanden (FLAC / WAV) kan het even duren voordat de download daadwerkelijk start. De voortgangsbalk verschijnt pas als de server klaar is met inpakken.',
q10: 'Hoe markeer ik nummers en albums als favoriet?',
a10: 'Klik op het sterpictogram op een nummervak of de albumkoptekst. Gemarkeerde items verschijnen in de sectie Favorieten in de zijbalk.',
q11: 'Wat is de hero-carrousel op de startpagina?',
a11: 'De banner bovenaan de startpagina kiest willekeurig albums uit je bibliotheek en wisselt deze elke 10 seconden af. Klik op de puntjes om naar een specifiek album te springen.',
s4: 'Instellingen',
q12: 'Hoe verander ik het thema?',
a12: 'Instellingen → Thema. Meer dan 20 thema\'s in 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night en de exclusieve Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).',
a12: 'Instellingen → Thema. 47 thema\'s in 7 groepen: Psysonic Themes, Psysonic Mediaplayer, Besturingssystemen, Games, Films, Series en Open Source Classics (Catppuccin, Nord, Gruvbox).',
q13: 'Hoe verander ik de taal?',
a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.',
q14: 'Wat doet "Minimaliseren naar systeemvak"?',
@@ -2028,6 +2149,14 @@ const nlTranslation = {
a21: 'Dit is meestal een GPU/EGL-driverprobleem in WebKitGTK. Start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Het AUR-pakket en officiële .deb/.rpm-installatieprogramma\'s doen dit automatisch.',
q29: 'Wat zijn overgang (crossfade) en naadloos afspelen?',
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (110 s) instelbaar.',
q30: 'Toont Psysonic songteksten?',
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
q33: 'Kan ik door een FLAC-bestand scrubben (zoeken)?',
a33: 'Zoeken in FLAC-bestanden vereist een SEEKTABLE-metadatablok in het bestand. Bestanden die zonder dit blok zijn gecodeerd, kunnen niet worden doorzocht — klikken op de golfvorm heeft geen effect. De meeste moderne encoders (bijv. ffmpeg, flac CLI) bevatten standaard een SEEKTABLE. U kunt er een toevoegen aan bestaande bestanden met: flac --replay-gain *.flac of metaflac --add-seekpoint=10s *.flac',
q31: 'Kan ik sneltoetsen aanpassen?',
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
q32: 'Kan ik het lettertype wijzigen?',
a32: 'Ja. Instellingen → Lettertype biedt 10 lettertypen waaronder IBM Plex Mono, Fira Code, JetBrains Mono en Courier Prime. Het gekozen lettertype geldt voor de gehele interface.',
},
queue: {
title: 'Wachtrij',
@@ -2109,7 +2238,10 @@ const nlTranslation = {
repeatOne: 'Één',
progress: 'Nummervoortgang',
volume: 'Volume',
toggleQueue: 'Wachtrij in-/uitschakelen'
toggleQueue: 'Wachtrij in-/uitschakelen',
lyrics: 'Songtekst',
lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
}
};
+41
View File
@@ -4,6 +4,7 @@ import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverA
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useOfflineStore } from '../store/offlineStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
@@ -44,6 +45,29 @@ export default function AlbumDetail() {
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const { downloadAlbum, deleteAlbum } = useOfflineStore();
const offlineTracks = useOfflineStore(s => s.tracks);
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs);
const serverId = auth.activeServerId ?? '';
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
if (!album) return 'none';
const meta = offlineAlbums[`${serverId}:${album.album.id}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]);
if (isDownloaded) return 'cached';
const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading'));
return isDownloading ? 'downloading' : 'none';
})();
const offlineProgress = (() => {
if (!album) return null;
const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
})();
useEffect(() => {
if (!id) return;
setLoading(true);
@@ -70,6 +94,7 @@ export default function AlbumDetail() {
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
@@ -80,6 +105,7 @@ export default function AlbumDetail() {
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
}));
enqueue(tracks);
};
@@ -89,6 +115,7 @@ export default function AlbumDetail() {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred, genre: song.genre,
};
playTrack(track, [track]);
};
@@ -180,6 +207,16 @@ export default function AlbumDetail() {
}
};
const handleCacheOffline = () => {
if (!album) return;
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
};
const handleRemoveOffline = () => {
if (!album) return;
deleteAlbum(album.album.id, serverId);
};
// Hooks must be called unconditionally — derive from nullable album state
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
@@ -209,6 +246,10 @@ export default function AlbumDetail() {
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={offlineStatus}
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
/>
<AlbumTrackList
+22 -4
View File
@@ -3,9 +3,10 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, ListPlus } from 'lucide-react';
import { ListPlus, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
export default function Favorites() {
const { t } = useTranslation();
@@ -15,6 +16,11 @@ export default function Favorites() {
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setSongs(prev => prev.filter(s => s.id !== id));
}
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
@@ -67,7 +73,7 @@ export default function Favorites() {
const tracks = songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
}}
@@ -77,22 +83,24 @@ export default function Favorites() {
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header tracklist-va">
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
</div>
{songs.map((song, i) => {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre,
};
return (
<div
key={song.id}
className="track-row track-row-va"
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
onDoubleClick={() => playTrack(song, songs)}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
@@ -118,6 +126,16 @@ export default function Favorites() {
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button
className="btn-icon fav-remove-btn"
data-tooltip={t('favorites.removeSong')}
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
aria-label={t('favorites.removeSong')}
>
<X size={14} />
</button>
</div>
</div>
);
})}
+4 -1
View File
@@ -43,9 +43,10 @@ export default function Help() {
{ q: t('help.q7'), a: t('help.a7') },
{ q: t('help.q8'), a: t('help.a8') },
{ q: t('help.q22'), a: t('help.a22') },
{ q: t('help.q23'), a: t('help.a23') },
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
{ q: t('help.q30'), a: t('help.a30') },
{ q: t('help.q33'), a: t('help.a33') },
],
},
{
@@ -66,6 +67,8 @@ export default function Help() {
{ q: t('help.q13'), a: t('help.a13') },
{ q: t('help.q14'), a: t('help.a14') },
{ q: t('help.q15'), a: t('help.a15') },
{ q: t('help.q31'), a: t('help.a31') },
{ q: t('help.q32'), a: t('help.a32') },
],
},
{
+37 -98
View File
@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
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 { useLyricsStore } from '../store/lyricsStore';
import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
getAlbum, getArtistInfo,
@@ -123,12 +124,12 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
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 weight = size >= 19 ? 700 : size >= 16 ? 500 : 400;
const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px';
const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
const pad = size >= 15 ? '5px 10px' : '4px 8px';
const opacity = 0.6 + ((h % 5) * 0.08);
const verticals = [-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];
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-header">{t('artistDetail.similarArtists')}</div>
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '26px' } : undefined}>
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '14px' } : undefined}>
{row.map((a, i) => (
<span
key={a.id}
@@ -155,34 +156,6 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
);
}
// ─── Blurred background ───────────────────────────────────────────────────────
const NpBg = memo(function NpBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const nextId = useRef(1);
useEffect(() => {
if (!url) return;
const id = nextId.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<div className="np-bg-wrap">
{layers.map(l => (
<div key={l.id} className="np-bg-layer"
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
/>
))}
<div className="np-bg-overlay" />
</div>
);
});
// ─── Album Tracklist ──────────────────────────────────────────────────────────
@@ -239,6 +212,10 @@ export default function NowPlaying() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
@@ -281,47 +258,12 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
const [ambilightColors, setAmbilightColors] = useState({
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
ml: '0,0,0', mr: '0,0,0',
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
});
useEffect(() => {
if (!resolvedCover) return;
const img = new Image();
img.onload = () => {
const S = 30;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, S, S);
const data = ctx.getImageData(0, 0, S, S).data;
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
const avg = (x0: number, y0: number, x1: number, y1: number) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = (y * S + x) * 4;
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
}
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
};
setAmbilightColors({
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
});
};
img.src = resolvedCover;
}, [resolvedCover]);
const similarArtists = artistInfo?.similarArtist ?? [];
return (
<div className="np-page">
<NpBg url={resolvedCover ?? ''} />
<div className="np-main">
{currentTrack ? (
@@ -356,29 +298,23 @@ export default function NowPlaying() {
>
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
<button
className="np-star-btn"
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
data-tooltip={t('player.lyrics')}
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : 'white' }}
>
<MicVocal size={17} />
</button>
</div>
</div>
</div>
{/* Center: cover */}
<div className="np-hero-cover-wrap">
<div style={{
position: 'absolute', inset: '-20px', zIndex: 0,
background: `
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
`,
filter: 'blur(28px)',
}} />
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
}
</div>
@@ -391,7 +327,7 @@ export default function NowPlaying() {
</div>
{/* ── About the Artist ── */}
{(artistInfo?.biography || artistInfo?.largeImageUrl) && (
{artistInfo?.biography && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
@@ -403,19 +339,22 @@ export default function NowPlaying() {
</div>
<div className="np-artist-bio-row">
{artistInfo.largeImageUrl && (
<img src={artistInfo.largeImageUrl} alt={currentTrack.artist} className="np-artist-thumb" />
)}
{artistInfo.biography && (
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
<img
src={artistInfo.largeImageUrl}
alt={currentTrack.artist}
className="np-artist-thumb"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
</div>
</div>
)}
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
export default function OfflineLibrary() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineTracks = useOfflineStore(s => s.tracks);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
const buildTracks = (albumId: string) => {
const meta = offlineAlbums[`${serverId}:${albumId}`];
if (!meta) return [];
return meta.trackIds.flatMap(tid => {
const t = offlineTracks[`${serverId}:${tid}`];
if (!t) return [];
return [{
id: t.id, title: t.title, artist: t.artist, album: t.album,
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
coverArt: t.coverArt, track: undefined, year: t.year,
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
}];
});
};
const handlePlay = (albumId: string) => {
const tracks = buildTracks(albumId);
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handleEnqueue = (albumId: string) => {
enqueue(buildTracks(albumId));
};
return (
<div className="offline-library animate-fade-in">
<div className="offline-library-header">
<HardDriveDownload size={24} />
<div>
<h1 className="offline-library-title">{t('connection.offlineLibraryTitle')}</h1>
<p className="offline-library-count">
{t('connection.offlineAlbumCount', { n: albums.length, count: albums.length })}
</p>
</div>
</div>
{albums.length === 0 ? (
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
) : (
<div className="album-grid-wrap">
{albums.map(album => {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
return (
<div key={album.id} className="album-card card offline-library-card">
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
) : (
<div className="album-card-cover-placeholder">
<HardDriveDownload size={32} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={() => handlePlay(album.id)}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
<div className="offline-library-card-meta">
<button
className="offline-library-enqueue"
onClick={() => handleEnqueue(album.id)}
title="Zur Warteschlange hinzufügen"
>
+ Queue
</button>
<span className="offline-library-tracks">{trackCount} tracks</span>
<button
className="offline-library-delete"
onClick={() => deleteAlbum(album.id, serverId)}
data-tooltip={t('albumDetail.removeOffline')}
data-tooltip-pos="top"
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -63,7 +63,7 @@ export default function Playlists() {
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); }
+45 -5
View File
@@ -48,6 +48,7 @@ export default function RandomMix() {
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
// Blacklist panel state
const [blacklistOpen, setBlacklistOpen] = useState(false);
@@ -94,6 +95,7 @@ export default function RandomMix() {
if (song.genre && checkText(song.genre)) return false;
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
return true;
});
@@ -342,10 +344,10 @@ export default function RandomMix() {
{genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }}
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }));
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
@@ -391,7 +393,7 @@ export default function RandomMix() {
draggable
onContextMenu={e => {
e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred };
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
@@ -400,7 +402,7 @@ export default function RandomMix() {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
@@ -419,7 +421,45 @@ export default function RandomMix() {
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
{(() => {
const artist = song.artist;
if (!artist) return <span className="track-artist"></span>;
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedArtist === artist;
return (
<button
className="btn btn-ghost track-artist"
style={{
fontSize: 'inherit',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
textAlign: 'left',
}}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>
{artist}
</button>
);
})()}
</div>
<div className="track-info">
+3 -3
View File
@@ -35,12 +35,12 @@ export default function SearchResults() {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
}, list.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating,
suffix: s.suffix, userRating: s.userRating, genre: s.genre,
})));
};
@@ -99,7 +99,7 @@ export default function SearchResults() {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
+177 -19
View File
@@ -6,7 +6,10 @@ import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
@@ -15,6 +18,7 @@ import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
@@ -82,17 +86,27 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
const fontStore = useFontStore();
const kb = useKeybindingsStore();
const gs = useGlobalShortcutsStore();
const serverId = auth.activeServerId ?? '';
const clearAllOffline = useOfflineStore(s => s.clearAll);
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
const navigate = useNavigate();
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -100,12 +114,36 @@ export default function Settings() {
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
const [lfmError, setLfmError] = useState<string | null>(null);
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
useEffect(() => {
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
useEffect(() => {
if (activeTab !== 'library') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
}, [activeTab]);
const handleClearCache = useCallback(async () => {
setClearing(true);
await clearImageCache();
await clearAllOffline(serverId);
const [imgBytes, offBytes] = await Promise.all([
getImageCacheSize(),
invoke<number>('get_offline_cache_size').catch(() => 0),
]);
setImageCacheBytes(imgBytes);
setOfflineCacheBytes(offBytes);
setShowClearConfirm(false);
setClearing(false);
}, [clearAllOffline, serverId]);
const startLastfmConnect = useCallback(async () => {
setLfmError(null);
let token: string;
@@ -293,22 +331,22 @@ export default function Settings() {
<div className="divider" />
{/* Crossfade */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>
{t('settings.crossfade')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.crossfadeEnabled && (
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
@@ -329,15 +367,18 @@ export default function Settings() {
<div className="divider" />
{/* Gapless */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
<span className="toggle-track" />
</label>
</div>
@@ -357,15 +398,24 @@ export default function Settings() {
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)</div>
</div>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.cacheDesc')}
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
{t('settings.cacheUsed', {
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
})}
</span>
)}
</div>
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
<input
type="range"
min={100}
max={2000}
max={5000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
@@ -373,6 +423,28 @@ export default function Settings() {
id="cache-size-slider"
/>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
onClick={handleClearCache}
disabled={clearing}
>
{t('settings.cacheClearConfirm')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
{t('settings.cacheClearCancel')}
</button>
</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
</div>
</section>
@@ -448,7 +520,7 @@ export default function Settings() {
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} style={{
<span key={genre} className="genre-keyword-badge" style={{
display: 'inline-flex', alignItems: 'center',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
@@ -545,6 +617,7 @@ export default function Settings() {
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
{activeTab === 'shortcuts' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Keyboard size={18} />
@@ -599,6 +672,7 @@ export default function Settings() {
};
window.addEventListener('keydown', handler, true);
}}
className="keybind-badge"
style={{
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
@@ -626,6 +700,90 @@ export default function Settings() {
</div>
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Keyboard size={18} />
<h2>{t('settings.globalShortcutsTitle')}</h2>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
{t('settings.globalShortcutsNote')}
</p>
<div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
{t('settings.shortcutsReset')}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{([
['play-pause', t('settings.shortcutPlayPause')],
['next', t('settings.shortcutNext')],
['prev', t('settings.shortcutPrev')],
['volume-up', t('settings.shortcutVolumeUp')],
['volume-down', t('settings.shortcutVolumeDown')],
] as [GlobalAction, string][]).map(([action, label]) => {
const bound = gs.shortcuts[action] ?? null;
const isListening = listeningForGlobal === action;
return (
<div key={action} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
background: isListening ? 'var(--accent-dim)' : 'transparent',
transition: 'background 0.15s',
}}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => {
if (isListening) { setListeningForGlobal(null); return; }
setListeningForGlobal(action);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
setListeningForGlobal(null);
window.removeEventListener('keydown', handler, true);
return;
}
const shortcut = buildGlobalShortcut(e);
if (shortcut) {
gs.setShortcut(action, shortcut);
setListeningForGlobal(null);
window.removeEventListener('keydown', handler, true);
}
};
window.addEventListener('keydown', handler, true);
}}
className="keybind-badge"
style={{
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
cursor: 'pointer',
}}
>
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
</button>
{bound && !isListening && (
<button
onClick={() => gs.setShortcut(action, null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
data-tooltip={t('settings.shortcutClear')}
>
<X size={12} />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
</section>
</>
)}
{/* ── Server ───────────────────────────────────────────────────────────── */}
+103
View File
@@ -0,0 +1,103 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { formatKeyCode } from './keybindingsStore';
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
const MODIFIER_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
];
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
if (MODIFIER_CODES.includes(e.code)) return null;
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
const mods: string[] = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
if (e.metaKey) mods.push('super');
return [...mods, e.code].join('+');
}
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
export function formatGlobalShortcut(shortcut: string): string {
return shortcut.split('+').map(part => {
if (part === 'ctrl') return 'Ctrl';
if (part === 'alt') return 'Alt';
if (part === 'shift') return 'Shift';
if (part === 'super' || part === 'meta') return 'Super';
return formatKeyCode(part);
}).join('+');
}
// Module-level guard — prevents double-registration from React StrictMode's
// intentional double-invocation of effects in development.
let _registerAllCalled = false;
interface GlobalShortcutsState {
shortcuts: Partial<Record<GlobalAction, string>>;
setShortcut: (action: GlobalAction, shortcut: string | null) => Promise<void>;
registerAll: () => Promise<void>;
resetAll: () => Promise<void>;
}
export const useGlobalShortcutsStore = create<GlobalShortcutsState>()(
persist(
(set, get) => ({
shortcuts: {},
setShortcut: async (action, shortcut) => {
const prev = get().shortcuts[action];
if (prev) {
try { await invoke('unregister_global_shortcut', { shortcut: prev }); } catch {}
}
if (shortcut) {
try {
await invoke('register_global_shortcut', { shortcut, action });
set(s => ({ shortcuts: { ...s.shortcuts, [action]: shortcut } }));
} catch (e) {
console.warn('[GlobalShortcuts] Failed to register:', shortcut, e);
}
} else {
set(s => {
const next = { ...s.shortcuts };
delete next[action];
return { shortcuts: next };
});
}
},
registerAll: async () => {
if (_registerAllCalled) return;
_registerAllCalled = true;
const { shortcuts } = get();
for (const [action, shortcut] of Object.entries(shortcuts)) {
if (shortcut) {
try {
await invoke('register_global_shortcut', { shortcut, action });
} catch (e) {
console.warn('[GlobalShortcuts] Failed to re-register:', shortcut, e);
}
}
}
},
resetAll: async () => {
const { shortcuts } = get();
for (const shortcut of Object.values(shortcuts)) {
if (shortcut) {
try { await invoke('unregister_global_shortcut', { shortcut }); } catch {}
}
}
set({ shortcuts: {} });
},
}),
{ name: 'psysonic_global_shortcuts' }
)
);
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand';
type SidebarTab = 'queue' | 'lyrics';
interface LyricsState {
activeTab: SidebarTab;
setTab: (tab: SidebarTab) => void;
showLyrics: () => void;
showQueue: () => void;
}
export const useLyricsStore = create<LyricsState>()((set) => ({
activeTab: 'queue',
setTab: (tab) => set({ activeTab: tab }),
showLyrics: () => set({ activeTab: 'lyrics' }),
showQueue: () => set({ activeTab: 'queue' }),
}));
+245
View File
@@ -0,0 +1,245 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
export interface OfflineTrackMeta {
id: string;
serverId: string;
localPath: string;
title: string;
artist: string;
album: string;
albumId: string;
artistId?: string;
suffix: string;
duration: number;
bitRate?: number;
coverArt?: string;
year?: number;
genre?: string;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
cachedAt: string;
}
export interface OfflineAlbumMeta {
id: string;
serverId: string;
name: string;
artist: string;
coverArt?: string;
year?: number;
trackIds: string[];
}
export interface DownloadJob {
trackId: string;
albumId: string;
albumName: string;
trackTitle: string;
trackIndex: number;
totalTracks: number;
status: 'queued' | 'downloading' | 'done' | 'error';
}
interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
jobs: DownloadJob[];
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
isAlbumDownloading: (albumId: string) => boolean;
getLocalUrl: (trackId: string, serverId: string) => string | null;
downloadAlbum: (
albumId: string,
albumName: string,
albumArtist: string,
coverArt: string | undefined,
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
) => Promise<void>;
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
clearAll: (serverId: string) => Promise<void>;
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
}
export const useOfflineStore = create<OfflineState>()(
persist(
(set, get) => ({
tracks: {},
albums: {},
jobs: [],
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
isAlbumDownloaded: (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album || album.trackIds.length === 0) return false;
return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]);
},
isAlbumDownloading: (albumId) =>
get().jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
),
getLocalUrl: (trackId, serverId) => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return null;
return `psysonic-local://${meta.localPath}`;
},
clearAll: async (serverId) => {
const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`));
for (const key of albumKeys) {
const albumId = key.slice(`${serverId}:`.length);
await get().deleteAlbum(albumId, serverId);
}
},
getAlbumProgress: (albumId) => {
const albumJobs = get().jobs.filter(j => j.albumId === albumId);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
const CONCURRENCY = 2;
const trackIds = songs.map(s => s.id);
// Register album shell + queue jobs
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
},
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...songs.map((s, i) => ({
trackId: s.id,
albumId,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: songs.length,
status: 'queued' as const,
})),
],
}));
// Download in batches of CONCURRENCY
for (let i = 0; i < songs.length; i += CONCURRENCY) {
const batch = songs.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async song => {
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'downloading' }
: j,
),
}));
const suffix = song.suffix || 'mp3';
const url = buildStreamUrl(song.id);
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
serverId,
url,
suffix,
});
set(state => ({
tracks: {
...state.tracks,
[`${serverId}:${song.id}`]: {
id: song.id,
serverId,
localPath,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
suffix,
duration: song.duration,
bitRate: song.bitRate,
coverArt: song.coverArt,
year: song.year,
genre: song.genre,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
cachedAt: new Date().toISOString(),
},
},
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'done' }
: j,
),
}));
} catch {
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'error' }
: j,
),
}));
}
}),
);
}
// Clear completed jobs after a short delay
setTimeout(() => {
set(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
}));
}, 2500);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
await Promise.all(
album.trackIds.map(async trackId => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
trackId,
serverId,
suffix: meta.suffix,
}).catch(() => {});
}),
);
set(state => {
const tracks = { ...state.tracks };
album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]);
const albums = { ...state.albums };
delete albums[`${serverId}:${albumId}`];
return { tracks, albums };
});
},
}),
{
name: 'psysonic-offline',
storage: createJSONStorage(() => localStorage),
partialize: state => ({ tracks: state.tracks, albums: state.albums }),
},
),
);
+140 -5
View File
@@ -2,9 +2,10 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
export interface Track {
id: string;
@@ -24,6 +25,7 @@ export interface Track {
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
genre?: string;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -45,6 +47,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
genre: song.genre,
};
}
@@ -127,6 +130,15 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
// Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download.
let gaplessPreloadingId: string | null = null;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -173,8 +185,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id);
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is
@@ -204,6 +218,12 @@ function handleAudioProgress(current_time: number, duration: number) {
}
function handleAudioEnded() {
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - lastGaplessSwitchTime < 600) {
return;
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
@@ -216,6 +236,62 @@ function handleAudioEnded() {
}, 150);
}
/**
* Handle gapless auto-advance: the Rust engine has already switched to the
* next source sample-accurately. We just need to update the UI state without
* touching the audio stream (no playTrack() call!).
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
gaplessPreloadingId = null; // allow preloading for the track after this one
isAudioPaused = false;
const store = usePlayerStore.getState();
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queue.length) {
nextTrack = queue[nextIdx];
newIndex = nextIdx;
} else if (repeatMode === 'all' && queue.length > 0) {
nextTrack = queue[0];
newIndex = 0;
}
if (!nextTrack) return;
usePlayerStore.setState({
currentTrack: nextTrack,
queueIndex: newIndex,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
lastfmLoved: false,
});
// Report Now Playing to Navidrome + Last.fm
reportNowPlaying(nextTrack.id);
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(queue, nextTrack, 0);
}
function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false;
@@ -241,6 +317,7 @@ export function initAudioListeners(): () => void {
),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
];
// Sync Last.fm loved tracks cache on startup.
@@ -260,8 +337,57 @@ export function initAudioListeners(): () => void {
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
});
// ── MPRIS / OS media controls sync ───────────────────────────────────────
// Whenever the current track or playback state changes, push updates to the
// Rust souvlaki MediaControls so the OS media overlay stays accurate.
let prevTrackId: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, isPlaying, currentTime } = state;
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
const coverUrl = currentTrack.coverArt
? buildCoverArtUrl(currentTrack.coverArt, 512)
: undefined;
invoke('mpris_set_metadata', {
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
coverUrl,
durationSecs: currentTrack.duration,
}).catch(() => {});
}
// Update playback state on play/pause change
const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) {
prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: isPlaying,
positionSecs: currentTime > 0 ? currentTime : null,
}).catch(() => {});
return;
}
// Keep position in sync while playing — update every ~500 ms so Plasma
// always shows the correct time without interpolation gaps.
if (isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
}
});
return () => {
unsubAuth();
unsubMpris();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -364,8 +490,15 @@ export const usePlayerStore = create<PlayerState>()(
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
return;
}
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const state = get();
@@ -385,8 +518,8 @@ export const usePlayerStore = create<PlayerState>()(
isPlaying: true, // optimistic — reverted on error
});
const url = buildStreamUrl(track.id);
const authState = useAuthStore.getState();
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id);
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
@@ -449,8 +582,10 @@ export const usePlayerStore = create<PlayerState>()(
? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
invoke('audio_play', {
url: buildStreamUrl(currentTrack.id),
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | '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' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | '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' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11';
interface ThemeState {
theme: Theme;
+308 -32
View File
@@ -399,6 +399,21 @@
box-shadow: var(--shadow-sm);
}
.album-card-offline-badge {
position: absolute;
top: 6px;
right: 6px;
background: color-mix(in srgb, var(--accent) 85%, transparent);
color: var(--ctp-crust);
border-radius: var(--radius-sm);
padding: 3px 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
.album-card-play-overlay {
position: absolute;
inset: 0;
@@ -591,6 +606,82 @@
}
/* ─ Album Detail ─ */
/* ─── Offline Library ─── */
.offline-library {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.offline-library-header {
display: flex;
align-items: center;
gap: 14px;
color: var(--accent);
}
.offline-library-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.offline-library-count {
font-size: 13px;
color: var(--text-secondary);
margin: 2px 0 0;
}
.offline-library-card .album-card-info {
gap: 3px;
}
.offline-library-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 2px;
}
.offline-library-enqueue {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10px;
padding: 2px 6px;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.offline-library-enqueue:hover {
color: var(--accent);
border-color: var(--accent);
}
.offline-library-tracks {
font-size: 10px;
color: var(--text-muted);
}
.offline-library-delete {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.offline-library-delete:hover {
color: var(--color-error, #e05050);
}
.album-detail {
display: flex;
flex-direction: column;
@@ -853,6 +944,28 @@
font-variant-numeric: tabular-nums;
}
/* ─ Offline cache button ─ */
.offline-cache-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.offline-cache-btn--cached {
color: var(--color-star-active, var(--accent));
border-color: var(--color-star-active, var(--accent));
}
.offline-cache-btn--progress {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
opacity: 0.75;
padding: 6px 10px;
}
/* ─ Download folder modal ─ */
.download-folder-pick-row {
display: flex;
@@ -935,6 +1048,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.track-row.track-row-va {
@@ -1214,6 +1328,7 @@
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
user-select: text;
}
.artist-bio a {
@@ -1301,6 +1416,7 @@
line-height: 1.75;
color: var(--text-secondary);
max-width: 100%;
user-select: text;
}
.artist-bio-text a {
@@ -1487,6 +1603,168 @@
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;
cursor: default;
}
.lyrics-line.active {
color: var(--accent);
}
.lyrics-plain {
padding: 4px 0 16px;
}
.lyrics-plain-line {
font-size: 13px;
line-height: 1.85;
color: var(--text-secondary);
text-align: center;
margin: 0;
}
/* ── Help ─────────────────────────────────────────────────────────────────── */
.help-item {
border-bottom: 1px solid var(--border-subtle);
}
@@ -2334,6 +2612,7 @@
padding: 6px var(--space-3);
border-radius: var(--radius-md);
transition: background 0.15s;
user-select: none;
}
.playlist-row:hover {
@@ -2390,6 +2669,29 @@
opacity: 1;
}
.fav-remove-btn {
opacity: 0;
color: var(--text-muted);
background: none;
border: none;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: opacity 0.15s, color 0.15s;
}
.fav-remove-btn:hover {
color: var(--ctp-red);
}
.track-row:hover .fav-remove-btn,
.track-row.context-active .fav-remove-btn {
opacity: 1;
}
/* ─ Statistics Page ─ */
.stats-page {
display: flex;
@@ -2633,6 +2935,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
var(--bg-main);
}
/* Main scrollable content */
@@ -2814,6 +3120,7 @@
font-size: 13px;
line-height: 1.65;
color: rgba(255, 255, 255, 0.72);
user-select: text;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
@@ -3006,38 +3313,6 @@
flex-shrink: 0;
}
.np-bg-wrap {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
contain: paint;
}
.np-bg-layer {
position: absolute;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(70px) brightness(0.55) saturate(1.6);
transition: opacity 0.6s ease;
animation: np-ken-burns 40s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes np-ken-burns {
0% { transform: scale(1.00) translate(0%, 0%); }
25% { transform: scale(1.04) translate(-2%, -1.5%); }
50% { transform: scale(1.06) translate(1.5%, 2%); }
75% { transform: scale(1.03) translate(-1%, 1.5%); }
100% { transform: scale(1.05) translate(2%, -1%); }
}
.np-bg-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.38);
}
.np-hero-content {
position: relative;
@@ -3328,6 +3603,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.np-queue-item:hover,
+176 -29
View File
@@ -9,6 +9,11 @@
"sidebar main queue"
"player player player";
height: 100vh;
/* overflow: hidden keeps the player bar pinned at the bottom even when the
window is dragged below the OS minHeight constraint (ignored on some
Linux WMs/compositors). Without this, the 1fr row's implicit auto
min-height can push the grid taller than 100vh, scrolling the player
bar out of view. */
overflow: hidden;
background: var(--bg-app);
}
@@ -38,7 +43,9 @@
flex-direction: column;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle);
overflow: hidden;
position: relative;
z-index: 2;
min-height: 0; /* allow 1fr row to shrink freely */
}
.sidebar-brand {
@@ -70,7 +77,7 @@
.sidebar-nav {
flex: 1;
padding: var(--space-4) var(--space-3);
padding: var(--space-1) var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
@@ -176,7 +183,7 @@
/* Collapsed Sidebar Styles */
.sidebar.collapsed .sidebar-brand {
justify-content: center;
padding: 0;
padding: 0 var(--space-3);
}
.sidebar.collapsed .nav-link {
@@ -185,21 +192,49 @@
}
.collapse-btn {
background: transparent;
border: none;
position: absolute;
top: calc(50% - var(--player-height) / 2);
left: calc(var(--sidebar-width) - 11px);
transform: translateY(-50%);
z-index: 101;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--border-subtle);
background: var(--bg-card);
color: var(--text-muted);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm);
}
.collapse-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
box-shadow: var(--shadow-md);
}
.queue-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.queue-toggle-btn:hover {
background: var(--accent-dim);
color: var(--accent);
}
/* ─── Update Toast ─── */
@@ -266,6 +301,35 @@
cursor: default;
}
/* ─── Sidebar offline download queue ─── */
.sidebar-offline-queue {
margin: 4px var(--space-1) 0;
padding: 6px 10px;
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: var(--accent);
overflow: hidden;
}
.sidebar-offline-queue--collapsed {
justify-content: center;
padding: 6px;
}
@keyframes spin-slow {
to { transform: rotate(360deg); }
}
.spin-slow {
animation: spin-slow 2s linear infinite;
flex-shrink: 0;
}
/* ─── Main Content ─── */
.main-content {
grid-area: main;
@@ -273,6 +337,7 @@
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0; /* allow 1fr row to shrink freely */
background: var(--bg-app);
z-index: 1;
}
@@ -304,6 +369,48 @@
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-6);
contain: paint;
}
/* ─── Offline Banner ─── */
.offline-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-sidebar));
border-bottom: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--accent);
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.offline-banner span {
flex: 1;
}
.offline-banner-retry {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
transition: background var(--transition-fast);
}
.offline-banner-retry:hover {
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.offline-banner-retry:disabled {
opacity: 0.5;
cursor: default;
}
/* ─── Player Bar ─── */
@@ -311,7 +418,7 @@
grid-area: player;
display: flex;
align-items: center;
gap: var(--space-4);
gap: var(--space-6);
padding: 0 var(--space-5);
background: var(--bg-player);
border-top: 1px solid var(--border-subtle);
@@ -324,9 +431,9 @@
display: flex;
align-items: center;
gap: var(--space-3);
flex: 0 0 auto;
max-width: min(40vw, 480px);
flex: 0 0 320px;
min-width: 0;
overflow: hidden;
}
.player-album-art {
@@ -385,6 +492,7 @@
.player-album-art-wrap.clickable:hover .player-art-expand-hint { opacity: 1; }
.player-track-meta {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
@@ -393,20 +501,33 @@
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-track-artist {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
/* ── Marquee (PlayerBar track name / artist) ── */
.marquee-wrap {
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.marquee-scroll {
display: inline-block;
animation: player-marquee 12s linear infinite;
animation-delay: 2s;
}
@keyframes player-marquee {
0%, 12% { transform: translateX(0); }
78%, 88% { transform: translateX(var(--marquee-amount)); }
88.1%, 100% { transform: translateX(0); }
}
.player-buttons {
display: flex;
align-items: center;
@@ -462,6 +583,8 @@
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: 32px;
margin-right: 32px;
}
.player-waveform-wrap {
@@ -486,9 +609,30 @@
width: 155px;
}
.player-volume-slider {
.player-volume-slider-wrap {
flex: 1;
min-width: 0;
position: relative;
}
.player-volume-slider {
width: 100%;
}
.player-volume-pct {
position: absolute;
bottom: calc(100% + 6px);
transform: translateX(-50%);
font-size: 10px;
font-weight: 600;
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
padding: 2px 6px;
border-radius: var(--radius-sm);
pointer-events: none;
white-space: nowrap;
box-shadow: var(--shadow-sm);
}
.player-eq-btn {
@@ -569,6 +713,7 @@
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0; /* allow 1fr row to shrink freely */
}
.queue-header {
@@ -699,13 +844,18 @@
}
.queue-current-track {
display: flex;
flex-direction: column;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-track-body {
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-cover {
@@ -753,21 +903,18 @@
}
.queue-current-tech {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
font-size: 9px;
font-family: monospace;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.85);
padding: 3px 6px;
text-align: center;
background: var(--ctp-surface1);
color: var(--accent);
padding: 3px var(--space-4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
backdrop-filter: blur(8px);
}
.queue-divider {
+6255 -700
View File
File diff suppressed because it is too large Load Diff
+87 -4
View File
@@ -1,3 +1,5 @@
import { useAuthStore } from '../store/authStore';
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -25,7 +27,7 @@ function releaseFetchSlot(): void {
if (next) { activeFetches++; next(); }
}
function evictIfNeeded(): void {
function evictMemoryIfNeeded(): void {
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
const oldestKey = objectUrlCache.keys().next().value;
if (!oldestKey) break;
@@ -73,6 +75,48 @@ async function getBlob(key: string): Promise<Blob | null> {
}
}
/** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
try {
const database = await openDB();
const entries: Array<{ key: string; timestamp: number; size: number }> = await new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
resolve(
(req.result ?? []).map((e: { key: string; timestamp: number; blob: Blob }) => ({
key: e.key,
timestamp: e.timestamp,
size: e.blob?.size ?? 0,
})),
);
};
req.onerror = () => resolve([]);
});
let total = entries.reduce((acc, e) => acc + e.size, 0);
if (total <= maxBytes) return;
// Oldest first
entries.sort((a, b) => a.timestamp - b.timestamp);
const tx = database.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const entry of entries) {
if (total <= maxBytes) break;
store.delete(entry.key);
// Also purge from memory cache
const objUrl = objectUrlCache.get(entry.key);
if (objUrl) {
URL.revokeObjectURL(objUrl);
objectUrlCache.delete(entry.key);
}
total -= entry.size;
}
} catch {
// Ignore
}
}
async function putBlob(key: string, blob: Blob): Promise<void> {
try {
const database = await openDB();
@@ -82,11 +126,50 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
// Enforce disk limit after write (fire-and-forget)
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
evictDiskIfNeeded(maxBytes);
} catch {
// Ignore write errors
}
}
/** Returns the total size in bytes of all blobs stored in IndexedDB. */
export async function getImageCacheSize(): Promise<number> {
try {
const database = await openDB();
return new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
const entries: Array<{ blob: Blob }> = req.result ?? [];
resolve(entries.reduce((acc, e) => acc + (e.blob?.size ?? 0), 0));
};
req.onerror = () => resolve(0);
});
} catch {
return 0;
}
}
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
export async function clearImageCache(): Promise<void> {
for (const url of objectUrlCache.values()) {
URL.revokeObjectURL(url);
}
objectUrlCache.clear();
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore
}
}
/**
* Returns a cached object URL for an image.
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
@@ -104,7 +187,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
evictMemoryIfNeeded();
return objUrl;
}
@@ -114,10 +197,10 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
const newBlob = await resp.blob();
putBlob(cacheKey, newBlob); // fire-and-forget
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
evictMemoryIfNeeded();
return objUrl;
} catch {
return fetchUrl;