feat: v1.18.0 — Offline Mode (Beta), MPRIS Seek, 2 New Themes, Perf Fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-27 17:17:09 +01:00
parent b67c198227
commit 936e548f40
27 changed files with 1650 additions and 360 deletions
+24
View File
@@ -5,6 +5,30 @@ 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
-264
View File
@@ -1,264 +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` (100ms), `audio:ended`, `audio:error` events. `MASTER_HEADROOM` (-1 dB) prevents inter-sample clipping at full volume. |
| `src/store/themeStore.ts` | Theme selection (60 themes across 8 groups), applied as `data-theme` on `<html>` |
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `<TooltipPortal />` mount |
| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
| `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 ≋). **Gapless and Crossfade are mutually exclusive** — enabling one disables the other in both the toolbar and Settings. 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
60 themes across 8 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
| 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` | Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
| `muma-jukebox` | Mediaplayer | silver/blue light | Blue `#0070a0` |
| `winmedplayer` | Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `p-dvd` | Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `spotless` | Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero — ice-blue glass sidebar, near-black frosted taskbar player bar, Aero button gradients | Blue `#1878e8` |
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
| `luna-teal` | Operating Systems | WinXP Luna — warm tan bg, Luna blue task-pane sidebar, XP selection blue hover `#316AC5`, gel buttons | Green `#3c9d29` |
| `w11` | Operating Systems | Windows 11 Fluent Design dark — Mica sidebar, clean neutrals, no gradients | Windows Blue `#0078d4` |
| `gw1` | Games | Guild Wars 1 — aged Tyrian stone, ornate gold borders, Cinzel serif track name, Searing crimson danger | Gold `#c8960c` |
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `tetrastack` | Games | Tetris 8-bit, grid background | Cyan `#00f0f0` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `horde` | Games | WoW Horde — Durotar blood-red earth, forge-fire gold glow, iron-plate sidebar | Blood Red `#cc2200` |
| `alliance` | Games | WoW Alliance — Stormwind deep navy, cathedral stone, paladin holy-light glow, gold sidebar trim | Royal Blue `#3388cc` |
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `dune` | Movies | Arrakis — warm sand main vs cool sietch-blue `#0e0c1a` sidebar, spice cinnamon `#c8780a` accent, desert horizon gradient, sand-grain scrollbar | Spice `#c8780a` |
| `hill-valley-85` | Movies | BTTF — DeLorean time circuit: track name red `#ff2200` Courier New uppercase, artist amber, stainless-steel sidebar lines, fire+lightning radial gradients | Orange `#ff8c00` |
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `middle-earth` | Movies | LOTR — parchment bg, Bag End oak wood-grain sidebar, `ring-inscription-glow` 5s animation on track name, Sammath Naur player bar, seven-stop gold progress bar, Georgia serif | Gold `#d4a820` |
| `morpheus` | Movies | Matrix — CRT scanlines on app-shell, vertical code-column sidebar pattern, terminal buttons (monospace, no fill, green glow border-radius 0), 6px scrollbar | Phosphor Green `#00ff41` |
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `spider-tech` | Movies | Spider-Man — CSS spider web (conic+radial) radiating from sidebar top-left, Into the Spider-Verse halftone dots on app-shell, track name red glow, artist in suit-blue `#4a88ff` | Red `#E62429` |
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `t-800` | Movies | Terminator, Skynet blue | Cyan `#00d4ff` |
| `aqua-quartz` | Operating Systems | Mac OS X Aqua, skeuomorphic jelly buttons | Blue `#3876f7` |
| `dos` | Operating Systems | MS-DOS — ANSI blue `#0000AA` bg, Courier New everywhere, inverted grey selection, blinking block cursor on track name, 16px DOS scrollbar | Yellow `#FFFF55` |
| `unix` | Operating Systems | Unix Shell — pure black, Courier New, `$ ` prompt prefix + blinking `_` cursor on track name, artist in ANSI blue `#5588FF` (ls directory color), thin 6px scrollbar | Green `#22C55E` |
| `w3-1` | Operating Systems | Windows 3.1, light silver/teal | Navy `#000080` |
| `ice-and-fire` | Series | Game of Thrones — temperature duality: volcanic warm main (`#100c08`) vs cold Castle Black sidebar (`#090c10`), `fire-flicker` 5s irregular animation on track name, dragon gold `#c8880a` artist, red-to-gold content header rule, forge fire glow on player bar | Blood Red `#c41e1e` |
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `heisenberg` | Series | Breaking Bad — periodic table grid on app-shell, hazmat tape diagonal on sidebar, `crystal-pulse` 3s glow animation on track name, lab fluorescent blue top border on player bar | Crystal Blue `#35d4f8` |
| `turtle-power` | Series | TMNT, turtle green, brick sidebar | Green `#33cc33` |
| `insta` | Social Media | Instagram dark, pink gradient | Pink `#E1306C` |
| `readit` | Social Media | Reddit dark, orange-red | OrangeRed `#FF4500` |
| `the-book` | Social Media | Facebook light, blue sidebar | Blue `#1877F2` |
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
| `latte` | Open Source Classics | Catppuccin light | Mauve |
| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` |
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
### 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 atomic sample counter (`CountingSource`) — no wall-clock drift. `audio:ended` fires after ~1 s of consecutive near-end ticks at 100 ms intervals. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert. `MASTER_HEADROOM = 0.891_254` (-1 dB) is applied to all volume calculations to prevent inter-sample clipping from modern 0 dBFS masters + EQ biquad ripple.
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` first; if that fails (e.g. FLAC without a SEEKTABLE), the seek silently fails — FLAC files without SEEKTABLE are not seekable. `CountingSource::try_seek` only resets the counter if the inner seek actually succeeded (prevents display desync on failure).
- **Gapless + Crossfade mutual exclusion**: Enabling one auto-disables the other, enforced in both Settings (row opacity/pointer-events + onChange) and QueuePanel toolbar (button onClick). Both features running simultaneously caused a glitch: Crossfade moved the Sink (which had Song 2 gapless-chained) to `fading_out_sink`; after Song 1's fade-out, Song 2 played at full volume from the old Sink.
- **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. **Gapless and Crossfade are mutually exclusive** — clicking one disables the other.
- **WaveformSeek hover tooltip**: Hovering over the waveform canvas shows a floating time label (`.player-volume-pct`) above the cursor position, reusing the same CSS class as the volume % label. Rendered in a relative `<div>` wrapper; positioned via `left: ${hoverPct * 100}%`. Hidden when no track is loaded or mouse has left the canvas.
- **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.16.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.17.2",
"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.17.2
pkgver=1.18.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3424,7 +3424,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.16.0"
version = "1.18.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.16.0"
version = "1.18.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+24 -10
View File
@@ -738,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 {
@@ -1040,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()
}
};
@@ -1346,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(())
+131 -9
View File
@@ -188,6 +188,108 @@ fn mpris_set_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();
@@ -300,15 +402,32 @@ pub fn run() {
Ok(mut controls) => {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
let event_name = match event {
MediaControlEvent::Toggle => "media:play-pause",
MediaControlEvent::Play => "media:play-pause",
MediaControlEvent::Pause => "media:play-pause",
MediaControlEvent::Next => "media:next",
MediaControlEvent::Previous => "media:prev",
_ => return,
};
let _ = app_handle.emit(event_name, ());
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:?}");
}
@@ -354,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");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.17.2",
"version": "1.18.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+52 -3
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,8 +34,11 @@ 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';
@@ -59,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();
@@ -155,8 +178,11 @@ function AppShell() {
{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}
@@ -180,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>
@@ -277,6 +304,28 @@ function TauriEventBridge() {
unlisten.push(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) => {
+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);
+33 -5
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,
@@ -216,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>
+6 -1
View File
@@ -97,6 +97,11 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
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) : '';
@@ -111,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 */}
+40 -17
View File
@@ -8,14 +8,25 @@ 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 [loading, setLoading] = useState(false);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
const 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);
@@ -24,7 +35,20 @@ export default function LyricsPane({ currentTrack }: Props) {
const prevActive = useRef(-1);
useEffect(() => {
if (!currentTrack || currentTrack.id === fetchedFor) return;
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);
@@ -41,27 +65,26 @@ export default function LyricsPane({ currentTrack }: Props) {
).then(result => {
if (cancelled) return;
setLoading(false);
setFetchedFor(currentTrack.id);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
lyricsCache.set(currentTrack.id, { syncedLines: null, plainLyrics: null, notFound: true });
setNotFound(true);
return;
}
if (result.syncedLyrics) {
const lines = parseLrc(result.syncedLyrics);
setSyncedLines(lines.length > 0 ? lines : null);
}
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) { setLoading(false); setNotFound(true); }
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
// Reset when track changes
useEffect(() => {
setFetchedFor(null);
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
+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>
);
}
+33 -1
View File
@@ -1,12 +1,14 @@
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';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -69,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(() => {
@@ -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>
);
+2
View File
@@ -34,6 +34,8 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ 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' },
],
},
{
+80 -24
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',
@@ -246,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',
@@ -325,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',
@@ -409,7 +423,7 @@ 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?',
@@ -569,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',
@@ -641,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',
@@ -793,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',
@@ -872,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',
@@ -956,7 +984,7 @@ 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?',
@@ -1116,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',
@@ -1188,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',
@@ -1340,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',
@@ -1419,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',
@@ -1503,7 +1545,7 @@ 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 ?',
@@ -1663,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',
@@ -1735,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',
@@ -1887,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',
@@ -1966,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',
@@ -2050,7 +2106,7 @@ 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?',
+38
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);
@@ -183,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) : '';
@@ -212,6 +246,10 @@ export default function AlbumDetail() {
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={offlineStatus}
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
/>
<AlbumTrackList
+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>
);
}
+72 -6
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';
@@ -83,12 +86,20 @@ 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();
@@ -103,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;
@@ -363,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))}
@@ -379,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>
+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 }),
},
),
);
+23 -5
View File
@@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event';
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;
@@ -186,7 +187,8 @@ function handleAudioProgress(current_time: number, duration: number) {
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const nextUrl = buildStreamUrl(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
@@ -340,6 +342,7 @@ export function initAudioListeners(): () => void {
// 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;
@@ -359,13 +362,26 @@ export function initAudioListeners(): () => void {
}).catch(() => {});
}
// Update playback state when it changes
if (isPlaying !== prevIsPlaying) {
// 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(() => {});
}
});
@@ -502,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;
@@ -566,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,
+113
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;
+70
View File
@@ -301,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;
@@ -343,6 +372,47 @@
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 ─── */
.player-bar {
grid-area: player;
+413 -3
View File
@@ -5303,12 +5303,12 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* ── Active queue / now-playing: the bearer of the Ring ── */
[data-theme='middle-earth'] .queue-item.active {
background: rgba(212, 168, 32, 0.10);
background: rgba(212, 168, 32, 0.14);
box-shadow: inset 2px 0 0 #d4a820;
}
[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #2a1c0e; }
[data-theme='middle-earth'] .queue-item.active .queue-item-title { color: #f8e060; }
[data-theme='middle-earth'] .queue-item.active .queue-item-artist,
[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #8a6030; }
[data-theme='middle-earth'] .queue-item.active .queue-item-duration { color: #c8a060; }
[data-theme='middle-earth'] .np-queue-item-active {
color: #2a1c0e;
text-shadow: 0 0 6px rgba(212,168,32,0.40);
@@ -5320,6 +5320,11 @@ input[type="range"]:hover::-webkit-slider-thumb {
text-shadow: 0 0 6px rgba(212,168,32,0.38);
}
/* ── Queue tech bar: dark text on light parchment ── */
[data-theme='middle-earth'] .queue-current-tech {
color: #3e2808;
}
/* ── Queue + Lyrics on dark sidebar ── */
[data-theme='middle-earth'] .queue-current-info h3 { color: #f0d880; }
[data-theme='middle-earth'] .queue-current-sub { color: #d4a820; }
@@ -8969,3 +8974,408 @@ input[type="range"]:hover::-webkit-slider-thumb {
/* Connection indicators */
[data-theme='w11'] .connection-type,
[data-theme='w11'] .connection-server { color: #797979; }
/* ── Barb & Ken (Games) ─────────────────────────────────────── */
[data-theme='barb-and-ken'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FF1B8D%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Barbieland palette */
--ctp-crust: #07000b;
--ctp-mantle: #110009;
--ctp-base: #1a000f;
--ctp-surface0: #2e0019;
--ctp-surface1: #450026;
--ctp-surface2: #5c0032;
--ctp-overlay0: #8c1455;
--ctp-overlay1: #b82070;
--ctp-overlay2: #d93d8a;
--ctp-text: #ffe6f3;
--ctp-subtext1: #ffb8da;
--ctp-subtext0: #ff80be;
/* Barbie Pink spectrum */
--ctp-mauve: #FF1B8D;
--ctp-pink: #FF69B4;
--ctp-flamingo: #FF1493;
--ctp-rosewater:#FFD6EC;
--ctp-lavender: #FFB3D9;
--ctp-maroon: #c4005e;
--ctp-red: #FF4466;
/* Ken — powder blue as accent-2 */
--ctp-blue: #89CFF0;
--ctp-sapphire: #5BC8F5;
--ctp-sky: #AAE8FF;
--ctp-teal: #70d8f2;
--ctp-green: #a6e3a1;
--ctp-yellow: #FFE4A8;
--ctp-peach: #FFB8D0;
--bg-app: #1a000f;
--bg-sidebar: #110009;
--bg-card: #2e0019;
--bg-hover: rgba(255, 27, 141, 0.12);
--bg-player: #07000b;
--bg-glass: rgba(26, 0, 15, 0.88);
--accent: #FF1B8D;
--accent-dim: rgba(255, 27, 141, 0.15);
--accent-glow: rgba(255, 27, 141, 0.45);
--volume-accent: #89CFF0;
--text-primary: #ffe6f3;
--text-secondary:#ffb8da;
--text-muted: #7a3055;
--border: rgba(255, 27, 141, 0.28);
--border-subtle: rgba(255, 27, 141, 0.12);
--border-dropdown: rgba(255, 27, 141, 0.3);
--shadow-dropdown: rgba(7, 0, 11, 0.9);
--positive: #a6e3a1;
--warning: #FFE4A8;
--danger: #FF4466;
/* Bubbly, soft rounding — very Barbie */
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
}
/* Polka-dot sidebar — Barbie's dreamhouse wallpaper */
[data-theme='barb-and-ken'] .sidebar {
background:
radial-gradient(circle, rgba(255, 27, 141, 0.18) 2px, transparent 2px),
radial-gradient(circle, rgba(137, 207, 240, 0.10) 2px, transparent 2px),
linear-gradient(180deg, #110009 0%, #1a000f 100%);
background-size: 22px 22px, 22px 22px, 100% 100%;
background-position: 0 0, 11px 11px, 0 0;
border-right: 1px solid rgba(255, 27, 141, 0.35);
}
/* Player bar — deep magenta gradient, hot pink top border */
[data-theme='barb-and-ken'] .player-bar {
background: linear-gradient(180deg, #1a000f 0%, #07000b 100%);
border-top: 2px solid #FF1B8D;
box-shadow: 0 -6px 24px rgba(255, 27, 141, 0.18);
}
/* Track name — glitter shimmer */
@keyframes barbie-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
[data-theme='barb-and-ken'] .player-track-name {
background: linear-gradient(
90deg,
#FF1B8D 0%,
#FF69B4 25%,
#ffffff 50%,
#FF69B4 75%,
#FF1B8D 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: barbie-shimmer 4s linear infinite;
font-weight: 700;
}
/* Artist name — Ken blue */
[data-theme='barb-and-ken'] .player-artist-name {
color: #89CFF0;
}
/* Active nav — pink left border glow */
[data-theme='barb-and-ken'] .nav-link.active {
background: linear-gradient(90deg, rgba(255, 27, 141, 0.22) 0%, transparent 100%);
color: #FF69B4 !important;
border-left: 3px solid #FF1B8D;
text-shadow: 0 0 8px rgba(255, 27, 141, 0.4);
}
[data-theme='barb-and-ken'] .nav-link:hover:not(.active) {
background: rgba(255, 27, 141, 0.08);
}
/* Content header — subtle pink rule */
[data-theme='barb-and-ken'] .content-header {
border-bottom: 1px solid rgba(255, 27, 141, 0.2);
background: rgba(26, 0, 15, 0.6);
backdrop-filter: blur(12px);
}
/* Primary buttons — hot pink gradient */
[data-theme='barb-and-ken'] .btn-primary,
[data-theme='barb-and-ken'] .player-btn-primary,
[data-theme='barb-and-ken'] .hero-play-btn {
background: linear-gradient(135deg, #FF1B8D 0%, #FF69B4 100%);
box-shadow: 0 4px 16px rgba(255, 27, 141, 0.45);
color: #fff !important;
border: none;
}
[data-theme='barb-and-ken'] .btn-primary:hover,
[data-theme='barb-and-ken'] .player-btn-primary:hover,
[data-theme='barb-and-ken'] .hero-play-btn:hover {
background: linear-gradient(135deg, #e5007a 0%, #FF1B8D 100%);
box-shadow: 0 6px 22px rgba(255, 27, 141, 0.60);
}
/* Cards — pink border glow on hover */
[data-theme='barb-and-ken'] .album-card,
[data-theme='barb-and-ken'] .artist-card,
[data-theme='barb-and-ken'] .card,
[data-theme='barb-and-ken'] .settings-card {
border: 1px solid rgba(255, 27, 141, 0.18);
}
[data-theme='barb-and-ken'] .album-card:hover,
[data-theme='barb-and-ken'] .artist-card:hover {
border-color: rgba(255, 27, 141, 0.50);
box-shadow: 0 4px 20px rgba(255, 27, 141, 0.20);
}
/* Queue active item */
[data-theme='barb-and-ken'] .queue-item.active {
background: rgba(255, 27, 141, 0.08);
border-left: 3px solid #FF1B8D;
}
[data-theme='barb-and-ken'] .queue-item.active .queue-item-title {
color: #FF69B4;
}
[data-theme='barb-and-ken'] .queue-item.active .queue-item-artist,
[data-theme='barb-and-ken'] .queue-item.active .queue-item-duration {
color: #89CFF0;
}
/* Track rows */
[data-theme='barb-and-ken'] .track-row:hover,
[data-theme='barb-and-ken'] .queue-item:hover {
background: rgba(255, 27, 141, 0.07);
}
/* Album detail header cover button — Ken blue glow on hover */
[data-theme='barb-and-ken'] .album-detail-cover-btn:hover {
box-shadow: 0 0 24px rgba(137, 207, 240, 0.35);
}
/* Star (favorite) icon — gold kept, but override to hot pink */
[data-theme='barb-and-ken'] .color-star-active {
--color-star-active: #FF69B4;
}
/* Scrollbar — thin hot pink */
[data-theme='barb-and-ken'] ::-webkit-scrollbar { width: 5px; }
[data-theme='barb-and-ken'] ::-webkit-scrollbar-track { background: #07000b; }
[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb {
background: #FF1B8D;
border-radius: 3px;
}
[data-theme='barb-and-ken'] ::-webkit-scrollbar-thumb:hover {
background: #FF69B4;
}
/* Connection indicators */
[data-theme='barb-and-ken'] .connection-type,
[data-theme='barb-and-ken'] .connection-server { color: #7a3055; }
/* ── Toy Tale (Movies) ──────────────────────────────────────── */
[data-theme='toy-tale'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23FFD600%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* Warm toy-box palette */
--ctp-crust: #080603;
--ctp-mantle: #100d08;
--ctp-base: #1a1208;
--ctp-surface0: #2a1c10;
--ctp-surface1: #3a2818;
--ctp-surface2: #4a3422;
--ctp-overlay0: #7a5a35;
--ctp-overlay1: #9a7848;
--ctp-overlay2: #b89060;
--ctp-text: #f0e8d8;
--ctp-subtext1: #d8c8a8;
--ctp-subtext0: #c0a880;
/* Woody gold */
--ctp-mauve: #FFD600;
--ctp-yellow: #FFD600;
--ctp-peach: #FFA000;
--ctp-rosewater:#FFE0A0;
/* Buzz Lightyear purple + green */
--ctp-lavender: #9B72D6;
--ctp-blue: #7B4FD6;
--ctp-sapphire: #5c35a0;
--ctp-sky: #4FC3F7;
--ctp-teal: #4CAF50;
--ctp-green: #4CAF50;
--ctp-pink: #FF6B9D;
--ctp-flamingo: #FF4488;
--ctp-maroon: #b71c1c;
--ctp-red: #e53935;
--bg-app: #1a1208;
--bg-sidebar: #1e3a5f; /* Andy's sky-blue wallpaper */
--bg-card: #2a1c10;
--bg-hover: rgba(255, 214, 0, 0.10);
--bg-player: #0d0a06;
--bg-glass: rgba(26, 18, 8, 0.90);
--accent: #FFD600;
--accent-dim: rgba(255, 214, 0, 0.15);
--accent-glow: rgba(255, 214, 0, 0.40);
--volume-accent: #7B4FD6; /* Buzz purple */
--text-primary: #f0e8d8;
--text-secondary:#c8a878;
--text-muted: #6a5030;
--border: rgba(255, 214, 0, 0.22);
--border-subtle: rgba(255, 214, 0, 0.10);
--border-dropdown: rgba(255, 214, 0, 0.25);
--shadow-dropdown: rgba(8, 6, 3, 0.92);
--positive: #4CAF50;
--warning: #FFA000;
--danger: #e53935;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
}
/* Sidebar — Andy's iconic cloud wallpaper in sky blue */
[data-theme='toy-tale'] .sidebar {
background:
radial-gradient(ellipse 70px 42px at 18% 12%, rgba(255,255,255,0.14) 0%, transparent 100%),
radial-gradient(ellipse 45px 28px at 38% 18%, rgba(255,255,255,0.09) 0%, transparent 100%),
radial-gradient(ellipse 80px 50px at 72% 10%, rgba(255,255,255,0.12) 0%, transparent 100%),
radial-gradient(ellipse 55px 32px at 85% 20%, rgba(255,255,255,0.08) 0%, transparent 100%),
radial-gradient(ellipse 65px 40px at 12% 55%, rgba(255,255,255,0.10) 0%, transparent 100%),
radial-gradient(ellipse 48px 30px at 55% 68%, rgba(255,255,255,0.08) 0%, transparent 100%),
radial-gradient(ellipse 72px 44px at 80% 75%, rgba(255,255,255,0.11) 0%, transparent 100%),
linear-gradient(180deg, #1e3a5f 0%, #274d78 100%);
border-right: 1px solid rgba(255, 214, 0, 0.20);
}
/* Nav links need lighter text on blue sidebar */
[data-theme='toy-tale'] .nav-link {
color: rgba(255, 255, 255, 0.75);
}
[data-theme='toy-tale'] .nav-link:hover:not(.active) {
background: rgba(255, 255, 255, 0.10);
color: #fff;
}
[data-theme='toy-tale'] .nav-link.active {
background: rgba(255, 214, 0, 0.20);
border-left: 3px solid #FFD600;
color: #FFD600 !important;
text-shadow: 0 0 8px rgba(255, 214, 0, 0.4);
}
[data-theme='toy-tale'] .sidebar-logo,
[data-theme='toy-tale'] .sidebar-title {
color: #fff;
}
/* Player bar — dark toy-chest wood */
[data-theme='toy-tale'] .player-bar {
background: linear-gradient(180deg, #1a1208 0%, #0d0a06 100%);
border-top: 2px solid #FFD600;
box-shadow: 0 -6px 20px rgba(255, 214, 0, 0.10);
}
/* Track name — Woody sheriff star gold */
[data-theme='toy-tale'] .player-track-name {
color: #FFD600;
text-shadow: 0 0 10px rgba(255, 214, 0, 0.45);
font-weight: 700;
}
/* Artist name — warm cowboy tan */
[data-theme='toy-tale'] .player-artist-name {
color: #c8a060;
}
/* Content header */
[data-theme='toy-tale'] .content-header {
border-bottom: 1px solid rgba(255, 214, 0, 0.18);
}
/* Primary buttons — Woody yellow */
[data-theme='toy-tale'] .btn-primary,
[data-theme='toy-tale'] .player-btn-primary,
[data-theme='toy-tale'] .hero-play-btn {
background: linear-gradient(135deg, #FFD600 0%, #FFA000 100%);
color: #1a1208 !important;
font-weight: 700;
box-shadow: 0 4px 14px rgba(255, 214, 0, 0.35);
border: none;
}
[data-theme='toy-tale'] .btn-primary:hover,
[data-theme='toy-tale'] .player-btn-primary:hover,
[data-theme='toy-tale'] .hero-play-btn:hover {
background: linear-gradient(135deg, #ffe033 0%, #FFD600 100%);
box-shadow: 0 6px 20px rgba(255, 214, 0, 0.50);
}
/* Cards */
[data-theme='toy-tale'] .album-card,
[data-theme='toy-tale'] .artist-card,
[data-theme='toy-tale'] .card,
[data-theme='toy-tale'] .settings-card {
border: 1px solid rgba(255, 214, 0, 0.16);
}
[data-theme='toy-tale'] .album-card:hover,
[data-theme='toy-tale'] .artist-card:hover {
border-color: rgba(255, 214, 0, 0.42);
box-shadow: 0 4px 18px rgba(255, 214, 0, 0.14);
}
/* Track rows */
[data-theme='toy-tale'] .track-row:hover,
[data-theme='toy-tale'] .queue-item:hover {
background: rgba(255, 214, 0, 0.06);
}
/* Queue active item — Buzz purple accent */
[data-theme='toy-tale'] .queue-item.active {
background: rgba(123, 79, 214, 0.12);
border-left: 3px solid #7B4FD6;
}
[data-theme='toy-tale'] .queue-item.active .queue-item-title {
color: #FFD600;
}
[data-theme='toy-tale'] .queue-item.active .queue-item-artist,
[data-theme='toy-tale'] .queue-item.active .queue-item-duration {
color: #9B72D6;
}
/* Scrollbar — Woody brown */
[data-theme='toy-tale'] ::-webkit-scrollbar { width: 6px; }
[data-theme='toy-tale'] ::-webkit-scrollbar-track { background: #0d0a06; }
[data-theme='toy-tale'] ::-webkit-scrollbar-thumb {
background: #7a5a35;
border-radius: 3px;
}
[data-theme='toy-tale'] ::-webkit-scrollbar-thumb:hover {
background: #FFD600;
}
/* Connection indicators */
[data-theme='toy-tale'] .connection-type,
[data-theme='toy-tale'] .connection-server { color: #6a5030; }
+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;