Compare commits

..

14 Commits

Author SHA1 Message Date
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
Psychotoxical 9be0d8dfa9 feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:34:26 +02:00
Psychotoxical 67f31b0700 fix: include InternetRadio.tsx (referenced in App.tsx router) 2026-04-04 02:09:19 +02:00
Psychotoxical c873880a26 feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:04:14 +02:00
Psychotoxical 463b7483fd fix: wrap next/previous in arrow functions to satisfy MouseEventHandler type 2026-04-03 15:00:36 +02:00
Psychotoxical 3d11ef91a1 chore: bump AUR pkgver to 1.30.0 2026-04-03 14:54:59 +02:00
Psychotoxical c365140870 feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix
- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:53:41 +02:00
Psychotoxical 651b3cb050 Update README.md 2026-04-03 12:02:34 +02:00
Psychotoxical e2ee9247ad chore: attribute contributions from JulianNymark and nisarg-78
JulianNymark contributed OGG/Vorbis support, audio error toasts, and
human-readable error messages (PRs #42, #43, #44). nisarg-78 contributed
QoL and UI improvements (PR #38). Changes were incorporated manually
due to conflicts at the time of merge.

Co-Authored-By: JulianNymark <819074+JulianNymark@users.noreply.github.com>
Co-Authored-By: nisarg-78 <84626554+nisarg-78@users.noreply.github.com>
2026-04-02 22:45:58 +02:00
Psychotoxical 74df7b6b88 Update CHANGELOG.md 2026-04-02 22:36:36 +02:00
Psychotoxical 27a6693c8c Update CHANGELOG.md 2026-04-02 22:28:35 +02:00
53 changed files with 8315 additions and 1388 deletions
+3
View File
@@ -123,6 +123,9 @@ jobs:
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: app-v${{ needs.create-release.outputs.package_version }}
releaseName: Psysonic v${{ needs.create-release.outputs.package_version }}
releaseDraft: true
args: ${{ matrix.settings.args }}
- name: sign and upload Windows NSIS updater bundle
+124 -2
View File
@@ -5,12 +5,134 @@ 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.33.0] - 2026-04-06
### Added
- **Norwegian (Bokmål) translation** *(PR [#101](https://github.com/Psychotoxical/psysonic/pull/101) by [@zz5zz](https://github.com/zz5zz))*: Psysonic is now fully translated into Norwegian Bokmål — selectable in Settings → Appearance.
- **Configurable next-track preload** *(Issue [#102](https://github.com/Psychotoxical/psysonic/issues/102))*: A new setting in Settings → Playback controls when Psysonic starts buffering the next track. Three modes available:
- **Balanced** (default) — begins buffering 30 s before the end of the current track (previous behaviour).
- **Early** — begins buffering after just 5 s of playback, maximising reliability on slow connections.
- **Custom** — set the exact threshold (5 120 s before the end) via a slider.
- **Tray icon visibility toggle**: A new toggle in Settings → App Behavior lets you show or hide the system tray icon. When disabled, the icon is fully removed from the notification area / menu bar.
### Changed
- **Fullscreen Player — complete redesign**: The Ambient Stage has been rebuilt from the ground up.
- **Animated mesh background**: A GPU-only animated dark gradient mesh replaces the static blurred cover art background — smooth, performant, no layout repaints.
- **Artist portrait**: The right half of the screen now shows the artist's image (loaded from the server), crossfading smoothly on every track change. Falls back to the album cover if no artist image is available.
- **Bottom seekbar**: The seekbar is now pinned to the very bottom edge, spanning the full width, with elapsed and remaining timestamps above it.
- **Heart button**: You can now star/unstar the currently playing track directly from the Fullscreen Player without leaving the view.
- Removed the marquee-scrolling title in favour of a large, wrapping typographic layout.
- **Star buttons** — all star/favourite buttons across the app (Player Bar, Album Header, Album Tracklist, Queue Panel) now use the CSS class `.is-starred` instead of inline color overrides, making them trivially themeable.
### Fixed
- **macOS — HTTP audio streams**: Added `NSAppTransportSecurity` / `NSAllowsArbitraryLoads` to `Info.plist`. Without this, App Transport Security silently blocked HTTP radio streams and non-HTTPS Navidrome servers from loading audio in WKWebView on macOS.
---
## [1.32.0] - 2026-04-05 — *The Big Easter Update* 🐣
### Added
- **Custom Offline Storage Directory (#95)**: You can now specify a custom directory for the offline library in Settings → Storage & Downloads. This is perfect for offloading your internal drive to an SD card or external HDD.
- **Robust Volume Handling**: The app now automatically detects if a configured external storage medium is missing and provides a clear "Volume not found" notification instead of failing silently or attempting to download to a non-existent path.
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
- **Settings — Backup & Restore**: Export all your settings (servers, theme, font, keybindings, EQ preset, sidebar order) to a single JSON file and import them on another machine or after a reinstall. Available in Settings → Storage.
- **Albums — Year Range Filter**: A From/To year input now appears in the Albums toolbar alongside the existing genre filter. Filtering by year and by genre can be combined; clearing both inputs returns to the default view.
- **Statistics — Library Insights** *(requested via [#88](https://github.com/Psychotoxical/psysonic/issues/88))*:
- **Total Playtime** card: computed in the background by paginating your full album list (up to 5 000 albums). Shows `≥ Xh Ym` if the library is larger.
- **Genre Insights**: Top 10 genres ranked by song count with proportional progress bars.
- **Format Distribution**: Codec breakdown from a random 500-track sample — shows format name and percentage.
- **Playlist Detail — Cover Upload**: Change or remove a playlist's cover image via the hover menu that appears on the hero artwork — no external tool needed.
- **Tracklist columns — Playlists & Favorites** *(work in progress)*: PlaylistDetail and Favorites now support the same resizable, configurable column system introduced in v1.31.0 for Album tracklists. Column widths and visibility are persisted independently per page. The feature is still being refined.
### Changed
- **Crossfade — fine-grained control**: The crossfade duration slider now ranges from 0.1 s to 10 s in 0.1 s steps (previously 1 s minimum, 0.5 s steps). The current value is shown with one decimal place.
- **Settings — Storage tab redesign**: The "Offline Library" section now has a short description and includes Cache settings. The "Downloads" section is now labelled "ZIP Export & Archiving". Both sections have been visually consolidated.
- **Artists page — Load More button** *(reported via [#90](https://github.com/Psychotoxical/psysonic/issues/90))*: The button is now styled as `btn-primary` with a `ChevronDown` icon and proper spacing. Previously it was an unstyled ghost button with no visual affordance.
- **Tracklist layout consistency**: The Play-button column is now uniformly 60 px and the title column uses `minmax(150px, 1fr)` across all list views — Search Results, Artist Detail, Random Mix, and Advanced Search now match the Album tracklist layout.
- **Internet Radio — HTML5 playback**: Radio now streams via the browser's native `<audio>` element instead of a custom Rust pipeline. This improves compatibility with AAC/MP3/HLS streams.
- **AppUpdater — error visibility** *(experimental, still in progress)*: Update failures are now shown inside the update card rather than silently logged. Auto-update remains experimental — a direct GitHub Releases link is always shown as a fallback.
- **Queue panel — radio drag**: Dragging a radio station card onto the queue is now silently rejected instead of causing an error.
### Fixed
- **PlayerBar stuck on Radio info**: Switching from an Internet Radio station to a regular track no longer leaves the station name and cover in the player bar. `playTrack` now clears `currentRadio` state and stops the audio element immediately.
- **Radio favourite icon**: The heart icon is now correctly used for favourite radio stations on both the Internet Radio page and the Favourites page. It was incorrectly showing a star.
- **Offline track deletion — orphaned directories**: Deleting a cached track now removes empty parent directories up to the configured base directory. Uses `std::fs::remove_dir` (safe — only removes empty directories) to avoid accidental data loss.
---
## [1.31.0] - 2026-04-04
> **Note:** This is likely the last update for the coming week — taking a short break. See you on the other side. ☀️
### Added
- **AutoEQ — 10-Band Parametric Equalizer**: Full parametric EQ with 10 adjustable bands, bypass toggle, and pre-gain control. AutoEQ presets are loaded directly from the AutoEQ GitHub repository — search for your headphone model and apply a community-measured correction curve with one click.
- **Internet Radio — infrastructure** *(work in progress, not yet released)*: The full backend for Internet Radio playback is in place — a dedicated Rust `RadioBuffer` streaming pipeline in the audio engine, Subsonic API integration (`getInternetRadioStations`, create/update/delete), and a `playRadio` action in the player store. The UI page exists but the feature is **not yet accessible** from the sidebar — it will be enabled once the experience is polished.
- **Tracklist columns — resizable & configurable** *(experimental)*: Album tracklist columns can now be resized by dragging the dividers between header cells, similar to a spreadsheet. A column visibility picker (chevron button at the top right) lets you show or hide individual columns. The `#` column is fixed-width. Column widths and visibility are persisted in localStorage. The feature works but is still being refined.
- **Genre column in album tracklist**: Albums that have genre tags per track now show a Genre column in the tracklist.
- **Sidebar auto-migration**: New sidebar items (e.g. Internet Radio) are automatically appended to existing persisted sidebar configurations on first launch — no more missing entries after updates.
### Changed
- **Discord Rich Presence**: Activity type is now `Listening` instead of the default `Playing`. The artist field no longer has the "by " prefix — Discord's layout makes the context clear without it. Album name is shown as a tooltip on the cover icon.
- **Clickable artist names everywhere**: Artist names in Album Cards, Favorites, Random Mix, Playlist Detail, and Artist Detail tracklists are now clickable links that navigate to the artist page.
- **Duration format supports hours**: Tracks and albums longer than 60 minutes are now displayed as `H:MM:SS` instead of overflowing minutes (e.g. `75:03``1:15:03`).
- **Format column**: Codec label no longer includes the "kbps" suffix or the `·` separator — cleaner and fits the narrower column better (e.g. `FLAC 1411` instead of `FLAC · 1411 kbps`).
- **Now Playing sidebar link**: No longer permanently styled as an active menu item. It now only shows the accent background when you are actually on the Now Playing page; at all other times it is distinguished only by its accent text colour.
- **Paused-state indicator in tracklist**: When the currently active track is paused, a dimmed play icon is shown in the `#` column instead of a blank space — making it clear which track is loaded even when playback is stopped.
- **Text selection disabled**: Text can no longer be accidentally selected anywhere in the player by click-dragging or pressing Ctrl+A. Standard input fields are unaffected.
- **Settings — button styles**: "Test connection", "Add server", and "Pick download folder" buttons are now `btn-surface` (with a subtle border) instead of the borderless `btn-ghost` — clearer affordance.
- **Settings — Behavior section icon**: Replaced the generic `Sliders` icon with `AppWindow` for the Behavior section header.
- **`btn-surface` border**: The surface button variant now has a 1 px border that brightens on hover — consistent with the card and input visual language.
- **Queue panel minimum width**: Increased from 250 px to 310 px to prevent layout overflow when the codec/bitrate overlay is visible.
- **Server compatibility hint**: A short note below the Servers section header in Settings clarifies which Subsonic-compatible servers are supported.
### Fixed
- **Tracklist `#` column header alignment**: The "Select all" checkbox and the `#` symbol in the header now use the same internal layout as the row cells — ensuring alignment with individual checkboxes and track numbers at all window sizes.
- **Column resize dividers**: The visible 2 px divider line is now placed in the gap between columns rather than inside the cell, so header labels appear visually centred between their dividers.
- **Internet Radio sidebar link hidden**: The navigation entry is temporarily removed until the feature is ready for release. The underlying code remains in place and will be re-enabled without any migration required.
---
## [1.30.0] - 2026-04-03
### Added
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
### Fixed
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
### Changed
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
---
## [1.29.0] - 2026-04-02
### Added
- **Radio: instant start + background enrichment** *(requested by [@netherguy4](https://github.com/netherguy4))*: Artist Radio now starts immediately from fast local `getTopSongs` results. `getSimilarSongs2` (Last.fm-dependent, slow) continues in the background and silently enriches the queue once it resolves — no waiting before the first song.
- **Proactive Radio queue loading** *(requested by [@netherguy4](https://github.com/netherguy4))*: When ≤2 Radio tracks remain, Psysonic automatically fetches more similar songs and appends them — independent of the Infinite Queue setting. Radio sessions now continue indefinitely.
- **OGG/Vorbis playback** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #42](https://github.com/Psychotoxical/psysonic/pull/42))*: Added `symphonia-format-ogg``.ogg` files now play natively without server-side transcoding.
- **Click-to-seek in synced lyrics** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Clicking any line in the synced lyrics pane seeks directly to that timestamp.
- **Volume scroll wheel** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Scrolling the mouse wheel over the volume slider adjusts volume in ±5 % steps.
@@ -19,7 +141,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Hopefully fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
- **Radio queue stacking**: Clicking "Start Radio" multiple times no longer appends unlimited duplicate batches — each click replaces the pending Radio section cleanly.
- **Start Radio keeps current song playing**: Triggering Radio while a song is playing no longer stops and restarts the current track.
- **Radio proactive loading with songs missing `artistId`**: `getSimilarSongs2` results frequently lack `artistId`. A `currentRadioArtistId` module variable now persists the original artist ID as fallback, so proactive loading always fires correctly.
+35 -7
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
@@ -80,13 +80,41 @@ Designed specifically for users hosting their own music via Navidrome or other S
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` (NSIS installer)
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
### 🐧 Linux
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 Installation (Arch Linux / AUR)
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
| Package | Type | Description |
| :--- | :--- | :--- |
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
> [!TIP]
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
## 🚀 Getting Started
+30 -2
View File
@@ -1,13 +1,14 @@
{
"name": "psysonic",
"version": "1.26.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.26.1",
"version": "1.30.0",
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -1226,6 +1227,33 @@
"win32"
]
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.29.0",
"version": "1.33.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -11,6 +11,7 @@
"tauri:build": "tauri build"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.29.0
pkgver=1.33.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+57 -6
View File
@@ -565,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -986,6 +986,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"uuid 0.8.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -2564,6 +2577,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3470,11 +3493,14 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.28.0"
version = "1.33.0"
dependencies = [
"biquad",
"discord-rich-presence",
"futures-util",
"md5",
"reqwest 0.12.28",
"ringbuf",
"rodio",
"serde",
"serde_json",
@@ -3717,6 +3743,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -3815,6 +3842,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ringbuf"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rodio"
version = "0.19.0"
@@ -3996,7 +4032,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -4906,7 +4942,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5191,7 +5227,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5643,6 +5679,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -5704,6 +5746,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "uuid"
version = "1.22.0"
@@ -6898,7 +6949,7 @@ dependencies = [
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"uuid 1.22.0",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros 5.14.0",
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.28.0"
version = "1.33.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -32,11 +32,14 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
+9
View File
@@ -7,5 +7,14 @@
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+3
View File
@@ -17,10 +17,13 @@
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
"window-state:allow-restore-state",
"core:window:allow-set-title",
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
+812 -27
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
/// Discord Rich Presence integration.
///
/// To enable this feature:
/// 1. Go to https://discord.com/developers/applications and create an application.
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
/// (use the app icon from public/logo.png).
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use std::sync::Mutex;
const DISCORD_APP_ID: &str = "1489544859718258779";
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
impl DiscordState {
pub fn new() -> Self {
DiscordState(Mutex::new(None))
}
}
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
client.connect().ok()?;
Some(client)
}
/// Update the Discord Rich Presence activity.
///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer.
#[tauri::command]
pub fn discord_update_presence(
state: tauri::State<DiscordState>,
title: String,
artist: String,
album: Option<String>,
elapsed_secs: Option<f64>,
) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() {
match try_connect() {
Some(client) => *guard = Some(client),
None => return Ok(()), // Discord not running — silently skip
}
}
let client = guard.as_mut().unwrap();
// Discord RPC only exposes two visible text rows (details + state).
// The application name "Psysonic" is shown automatically by Discord as the
// header line. Album goes into large_text — visible as a hover tooltip on
// the cover art icon.
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new()
.large_image("psysonic")
.large_text(large_text);
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&title)
.state(&artist)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
// it so the displayed elapsed time matches the actual playback position.
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
}
if client.set_activity(activity).is_err() {
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
// call re-connects.
*guard = None;
}
Ok(())
}
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
}
}
Ok(())
}
+424 -99
View File
@@ -2,13 +2,15 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
@@ -16,6 +18,10 @@ use tauri::{
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
@@ -74,6 +80,197 @@ fn relaunch_after_update(app: tauri::AppHandle) {
app.exit(0);
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
#[tauri::command]
async fn upload_playlist_cover(
server_url: String,
playlist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_artist_image(
server_url: String,
artist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn delete_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command]
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/search")
.header("User-Agent", "psysonic/1.0")
.query(&[
("name", query.as_str()),
("hidebroken", "true"),
("limit", limit_s.as_str()),
("offset", offset_s.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command]
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/topvote")
.header("User-Agent", "psysonic/1.0")
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("User-Agent", "psysonic/1.0")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.split(';')
.next()
.unwrap_or("image/jpeg")
.trim()
.to_string();
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
Ok((bytes.to_vec(), content_type))
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
@@ -243,14 +440,24 @@ async fn download_track_offline(
server_id: String,
url: String,
suffix: String,
custom_dir: Option<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);
// Determine base cache directory.
let cache_dir = if let Some(ref cd) = custom_dir {
let base = std::path::PathBuf::from(cd);
// Check that the volume/directory is still accessible.
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
base.join(&server_id)
} else {
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
@@ -281,65 +488,211 @@ async fn download_track_offline(
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory.
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
let offline_dir = match app.path().app_data_dir() {
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() { return 0; }
let mut total: u64 = 0;
let mut stack = vec![root];
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
}
let default_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();
}
let mut total = dir_size(default_dir);
if let Some(cd) = custom_dir {
let custom = std::path::PathBuf::from(cd);
if custom != std::path::PathBuf::from("") {
total += dir_size(custom);
}
}
total
}
/// Removes a cached track from the offline cache directory.
/// Removes a cached track from the offline cache. Accepts the full local path
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
async fn delete_offline_track(
track_id: String,
server_id: String,
suffix: String,
local_path: String,
base_dir: Option<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));
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
// Determine the safe boundary — never delete at or above this directory.
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
std::path::PathBuf::from(bd)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
};
// Walk upward, pruning directories that have become empty.
// Stops as soon as a non-empty directory or the boundary is reached.
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break; // Directory still has contents — stop pruning.
}
if std::fs::remove_dir(&dir).is_err() {
break; // Could not remove (e.g. permissions) — stop.
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { stop_audio_engine(app); app.exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)
}
/// Show (`true`) or fully remove (`false`) the system-tray icon.
///
/// The command is strictly idempotent:
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
/// - `show=false` when the icon is already absent → no-op.
///
/// For removal, `set_visible(false)` is called explicitly before the handle is
/// dropped because some platforms (Windows notification area, certain Linux DEs)
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
show: bool,
) -> Result<(), String> {
let mut guard = tray_state.lock().unwrap();
if show {
// Early-return when already shown — never build a second icon.
if guard.is_some() {
return Ok(());
}
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
} else if let Some(tray) = guard.take() {
// Hide synchronously before dropping so the OS processes the removal
// before any subsequent show=true call can create a new icon.
let _ = tray.set_visible(false);
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
}
Ok(())
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
fn stop_audio_engine(app: &tauri::AppHandle) {
let engine = app.state::<audio::AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(TrayState::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -357,65 +710,11 @@ pub fn run() {
.setup(|app| {
// ── System tray ───────────────────────────────────────────────
// Always build on startup; the frontend calls toggle_tray_icon(false)
// immediately after load if the user has disabled the tray icon.
{
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { std::process::exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Left-click: toggle window visibility
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)?;
let tray = build_tray_icon(app.handle())?;
*app.state::<TrayState>().lock().unwrap() = Some(tray);
}
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
@@ -513,9 +812,22 @@ pub fn run() {
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
// Let JS decide: minimize to tray or exit, based on user setting.
api.prevent_close();
let _ = window.emit("window:close-requested", ());
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Stop the audio engine first so sound cuts immediately.
let app = window.app_handle();
stop_audio_engine(app);
app.exit(0);
}
#[cfg(not(target_os = "macos"))]
{
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
}
}
}
})
@@ -534,15 +846,28 @@ pub fn run() {
audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq,
audio::autoeq_entries,
audio::autoeq_fetch_profile,
audio::audio_preload,
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
upload_playlist_cover,
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
relaunch_after_update,
toggle_tray_icon,
])
.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.29.0",
"version": "1.33.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+32 -1
View File
@@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -141,7 +142,7 @@ function AppShell() {
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingQueue]);
@@ -185,14 +186,36 @@ function AppShell() {
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
const blockSelectAll = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
// Allow Ctrl+A inside actual text inputs and textareas
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
}
};
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
const blockSelectStart = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if ((target as HTMLElement).closest('[data-selectable]')) return;
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
@@ -258,6 +281,7 @@ function AppShell() {
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
</Routes>
</div>
</main>
@@ -290,6 +314,13 @@ function TauriEventBridge() {
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+176 -4
View File
@@ -1,5 +1,6 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
@@ -88,6 +89,22 @@ export interface SubsonicSong {
};
}
export interface InternetRadioStation {
id: string;
name: string;
streamUrl: string;
homepageUrl?: string;
coverArt?: string; // Navidrome v0.61.0+
}
export interface RadioBrowserStation {
stationuuid: string;
name: string;
url: string;
favicon: string;
tags: string;
}
export interface SubsonicPlaylist {
id: string;
name: string;
@@ -97,6 +114,7 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
comment?: string;
coverArt?: string;
}
@@ -337,7 +355,6 @@ export function buildStreamUrl(id: string): string {
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
@@ -396,9 +413,61 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
@@ -434,3 +503,106 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
return [];
}
}
// ─── Internet Radio ───────────────────────────────────────────
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export const RADIO_PAGE_SIZE = 25;
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
+5 -1
View File
@@ -81,7 +81,11 @@ function AlbumCard({ album }: AlbumCardProps) {
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
+7 -6
View File
@@ -7,8 +7,10 @@ import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
@@ -143,8 +145,8 @@ export default function AlbumHeader({
<button
className="album-detail-cover-btn"
onClick={() => setLightboxOpen(true)}
data-tooltip="Vergrößern"
aria-label={`${info.name} Cover vergrößern`}
data-tooltip={t('albumDetail.enlargeCover')}
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
>
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
</button>
@@ -197,11 +199,10 @@ export default function AlbumHeader({
</div>
<button
className="btn btn-ghost"
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+220 -69
View File
@@ -1,22 +1,26 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Play, Heart, ListPlus, X } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
if (song.bitRate) parts.push(`${song.bitRate}`);
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
@@ -42,6 +46,29 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
);
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where cell content should be centred (both header and rows)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
@@ -68,15 +95,23 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
@@ -99,7 +134,6 @@ export default function AlbumTrackList({
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Close playlist picker on outside click
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
@@ -110,8 +144,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -123,8 +155,146 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
// direction=1: drag right → this column grows, title (1fr) shrinks
const isResizable = !isLastCol;
return (
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (key) {
case 'num':
return (
<div
key="num"
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
{currentTrack?.id === song.id && isPlaying && (
<span className="track-num-eq">
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
</span>
)}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{song.track ?? '—'}</span>
</div>
);
case 'title':
return (
<div key="title" className="track-info">
<span className="track-title">{song.title}</span>
</div>
);
case 'artist':
return (
<div key="artist" className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>
{song.artist}
</span>
</div>
);
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
<button
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
default:
return null;
}
};
return (
<div className="tracklist">
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
@@ -158,20 +328,46 @@ export default function AlbumTrackList({
</div>
)}
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">
{inSelectMode
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
: '#'}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
</span>
{label}
</button>
);
})}
</div>
)}
</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
@@ -186,6 +382,7 @@ export default function AlbumTrackList({
<div
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
@@ -216,59 +413,13 @@ export default function AlbumTrackList({
document.addEventListener('mouseup', onUp);
}}
>
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
);
})}
</div>
))}
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+18 -7
View File
@@ -20,7 +20,7 @@ function isNewer(a: string, b: string): boolean {
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null }
| { phase: 'available'; version: string; update: Update | null; error?: string }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
@@ -82,9 +82,11 @@ export default function AppUpdater() {
}
});
await invoke('relaunch_after_update');
} catch (e) {
console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Update failed:', msg);
// Surface the error so the user (and developer) can see what went wrong
setState({ phase: 'available', version: savedVersion, update, error: msg });
}
};
@@ -122,10 +124,19 @@ export default function AppUpdater() {
{state.phase === 'available' && (
<div className="app-updater-actions">
{state.error && (
<div className="app-updater-error">{state.error}</div>
)}
{canInstall && (
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<button className="app-updater-btn-secondary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
</>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
+24 -6
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -16,16 +16,33 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
const [resolved, setResolved] = useState('');
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
let cancelled = false;
const controller = new AbortController();
setResolved('');
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
if (!controller.signal.aborted) setResolved(url);
});
return () => { controller.abort(); };
}, [fetchUrl, cacheKey]);
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
const [inView, setInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
const [loaded, setLoaded] = useState(false);
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
@@ -36,7 +53,8 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
return (
<img
src={resolvedSrc}
ref={imgRef}
src={resolvedSrc || undefined}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props}
+186 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
@@ -147,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
@@ -182,20 +183,57 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
);
}
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
/** Parses AutoEQ FixedBandEQ.txt format.
* Expected lines:
* Preamp: -5.5 dB
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
* ...
* Returns all 10 band gains as exact floats and the preamp value.
*/
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
allFilters.slice(0, 10).forEach((m, i) => {
gains[i] = parseFloat(m[1]);
});
return { gains, preamp };
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const preGain = useEqStore(s => s.preGain);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// AutoEQ state
const [autoEqOpen, setAutoEqOpen] = useState(false);
const [autoEqQuery, setAutoEqQuery] = useState('');
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
const [autoEqLoading, setAutoEqLoading] = useState(false);
const [autoEqError, setAutoEqError] = useState<string | null>(null);
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
const [entriesLoading, setEntriesLoading] = useState(false);
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
const theme = useThemeStore(s => s.theme);
const redraw = useCallback(() => {
@@ -216,6 +254,62 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
// AutoEQ: load entries index lazily when section opens, then filter client-side
async function ensureEntries() {
if (entriesCacheRef.current) return;
setEntriesLoading(true);
setAutoEqError(null);
try {
const json = await invoke<string>('autoeq_entries');
entriesCacheRef.current = JSON.parse(json);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
} finally {
setEntriesLoading(false);
}
}
useEffect(() => {
const q = autoEqQuery.trim().toLowerCase();
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
const flat: AutoEqResult[] = [];
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
if (!name.toLowerCase().includes(q)) continue;
for (const v of variants) {
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
if (flat.length >= 20) break;
}
if (flat.length >= 20) break;
}
setAutoEqResults(flat);
// entriesLoading in deps: re-runs after entries finish loading so a query typed
// during loading produces results immediately without needing a re-type.
}, [autoEqQuery, entriesLoading]);
async function applyAutoEqResult(result: AutoEqResult) {
setAutoEqLoading(true);
setAutoEqError(null);
try {
const text = await invoke<string>('autoeq_fetch_profile', {
name: result.name,
source: result.source,
rig: result.rig ?? null,
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
setTimeout(() => setAutoEqApplied(null), 3000);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
} finally {
setAutoEqLoading(false);
}
}
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
@@ -279,6 +373,74 @@ export default function Equalizer() {
</div>
)}
{/* AutoEQ section */}
<div className="eq-autoeq-section">
<button
className="eq-autoeq-toggle"
onClick={() => {
const opening = !autoEqOpen;
setAutoEqOpen(opening);
setAutoEqQuery('');
setAutoEqResults([]);
setAutoEqError(null);
if (opening) ensureEntries();
}}
>
<Search size={13} />
<span>{t('settings.eqAutoEqTitle')}</span>
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{autoEqOpen && (
<div className="eq-autoeq-body">
<div className="eq-autoeq-search-row">
<input
className="input"
placeholder={t('settings.eqAutoEqPlaceholder')}
value={autoEqQuery}
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
autoFocus
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
/>
{autoEqQuery && (
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
<X size={13} />
</button>
)}
</div>
{(entriesLoading || autoEqLoading) && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
)}
{autoEqError && (
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
)}
{autoEqApplied && (
<div className="eq-autoeq-status eq-autoeq-applied"> {autoEqApplied}</div>
)}
{autoEqResults.length > 0 && (
<div className="eq-autoeq-results">
{autoEqResults.map((r, i) => (
<button
key={`${r.name}|${r.source}|${i}`}
className="eq-autoeq-result-btn"
onClick={() => applyAutoEqResult(r)}
>
<span>{r.name}</span>
<span className="eq-autoeq-result-source">{r.source}</span>
</button>
))}
</div>
)}
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
)}
</div>
)}
</div>
{/* EQ panel */}
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
{/* Frequency response */}
@@ -313,6 +475,27 @@ export default function Equalizer() {
</div>
))}
</div>
{/* Pre-gain row */}
<div className="eq-pregain-row">
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
<input
type="range"
className="eq-pregain-slider"
min={-30} max={6} step={0.1}
value={preGain}
disabled={!enabled}
onChange={e => setPreGain(parseFloat(e.target.value))}
/>
<span className="eq-pregain-val">
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
</span>
{preGain !== 0 && (
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
<RotateCcw size={11} />
</button>
)}
</div>
</div>
</div>
);
+121 -122
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
ChevronDown, Repeat, Repeat1, Square, Music, Heart
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -16,45 +15,8 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{title}
</span>
</div>
);
}
// ─── Crossfading blurred background ───────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
// ─── Artist portrait — right half, crossfades on track change ─────────────────
const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
@@ -64,8 +26,6 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
if (!url) return;
let cancelled = false;
const id = counterRef.current++;
// Preload the image before starting the crossfade — prevents a blank flash
// between the old and new layer while the browser decodes the image.
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
@@ -75,29 +35,34 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
setTimeout(() => {
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
}, 800);
}, 1000);
});
};
img.src = url;
return () => { cancelled = true; };
}, [url]);
if (layers.length === 0) return null;
return (
<>
<div className="fs-portrait-wrap" aria-hidden="true">
{layers.map(layer => (
<div
<img
key={layer.id}
className="fs-bg"
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
aria-hidden="true"
src={layer.url}
className="fs-portrait"
style={{ opacity: layer.visible ? 1 : 0 }}
decoding="async"
loading="eager"
alt=""
/>
))}
</>
</div>
);
});
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
@@ -112,21 +77,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
aria-label="seek"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
@@ -150,28 +116,40 @@ interface FullscreenPlayerProps {
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// buildCoverArtUrl generates a new salt on every call — must be memoized
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// No fetchUrl fallback for the background — we only want stable blob URLs
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
// buildCoverArtUrl generates a new salt on every call — must be memoized.
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Fetch artist image for background — fall back to cover art if unavailable
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
@@ -184,7 +162,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => { cancelled = true; };
}, [currentTrack?.artistId]);
const bgUrl = artistBgUrl || resolvedCoverUrl;
const portraitUrl = artistBgUrl || resolvedCoverUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
@@ -192,84 +170,105 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const metaParts = [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 0animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
<FsPortrait url={portraitUrl} />
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<div className="fs-cover-wrap">
{/* Album art */}
<div className="fs-art-wrap">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverKey}
alt={`${currentTrack?.album} Cover`}
className="fs-cover"
className="fs-art"
/>
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
)}
</div>
<div className="fs-track-info">
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
].filter(Boolean).join(' · ')}
</span>
)}
</div>
{/* Artist — massive statement */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
<FsProgress duration={duration} />
{/* Track title — accent, light weight */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
<Square size={14} fill="currentColor" />
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={20} />
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={20} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={14} />
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+60 -25
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -31,7 +31,7 @@ export default function PlayerBar() {
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
@@ -40,6 +40,8 @@ export default function PlayerBar() {
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -57,6 +59,14 @@ export default function PlayerBar() {
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art.
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
const radioCoverSrc = useMemo(
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
[currentRadio?.coverArt, currentRadio?.id]
);
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
@@ -80,11 +90,24 @@ export default function PlayerBar() {
{/* Track Info */}
<div className="player-track-info">
<div
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
onClick={() => currentTrack && toggleFullscreen()}
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
{isRadio ? (
currentRadio?.coverArt ? (
<CachedImage
className="player-album-art"
src={radioCoverSrc}
cacheKey={radioCoverKey}
alt={currentRadio.name}
/>
) : (
<div className="player-album-art-placeholder">
<Cast size={20} />
</div>
)
) : currentTrack?.coverArt ? (
<CachedImage
className="player-album-art"
src={coverSrc}
@@ -96,7 +119,7 @@ export default function PlayerBar() {
<Music size={22} />
</div>
)}
{currentTrack && (
{currentTrack && !isRadio && (
<div className="player-art-expand-hint" aria-hidden="true">
<Maximize2 size={16} />
</div>
@@ -104,30 +127,30 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
</div>
{currentTrack && (
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
style={{ flexShrink: 0 }}
>
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
{currentTrack && lastfmSessionKey && (
{currentTrack && !isRadio && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
@@ -145,7 +168,7 @@ export default function PlayerBar() {
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
@@ -156,7 +179,7 @@ export default function PlayerBar() {
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
<button
@@ -170,13 +193,25 @@ export default function PlayerBar() {
</button>
</div>
{/* Waveform Seekbar */}
{/* Waveform Seekbar / Radio live bar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</>
)}
</div>
{/* Lyrics Button */}
+17 -14
View File
@@ -216,7 +216,10 @@ export default function QueuePanel() {
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
@@ -240,6 +243,9 @@ export default function QueuePanel() {
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
// Radio streams are not tracks — reject silently
if (parsedData.type === 'radio') return;
const dropTarget = externalDropTargetRef.current;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
@@ -304,9 +310,9 @@ export default function QueuePanel() {
return (
<aside
ref={asideRef}
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
@@ -371,10 +377,9 @@ export default function QueuePanel() {
{currentTrack && (
<div className="queue-current-track">
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
<div className="queue-current-tech">
{[
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
(() => {
@@ -399,13 +404,11 @@ export default function QueuePanel() {
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
@@ -470,22 +473,22 @@ export default function QueuePanel() {
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(Number(e.target.value));
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>1s</span><span>10s</span>
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
+2 -1
View File
@@ -7,7 +7,7 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -24,6 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+3 -29
View File
@@ -164,29 +164,10 @@ export default function ThemePicker({ value, onChange }: Props) {
return (
<button
key={t.id}
className="theme-card-btn"
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
@@ -208,14 +189,7 @@ export default function ThemePicker({ value, onChange }: Props) {
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{t.label}
</span>
</button>
+1395 -35
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -169,7 +169,7 @@ export default function AdvancedSearch() {
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 180 }}>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
@@ -188,7 +188,7 @@ export default function AdvancedSearch() {
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 76 }}
style={{ width: 96 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
@@ -199,7 +199,7 @@ export default function AdvancedSearch() {
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 76 }}
style={{ width: 96 }}
/>
</div>
@@ -273,7 +273,7 @@ export default function AdvancedSearch() {
)}
</h2>
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}>
<span />
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
@@ -287,7 +287,7 @@ export default function AdvancedSearch() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
role="row"
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
@@ -319,7 +319,7 @@ export default function AdvancedSearch() {
</div>
<div className="track-artist-cell">
<span
className="track-artist"
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>
+3
View File
@@ -32,6 +32,7 @@ export default function AlbumDetail() {
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -201,12 +202,14 @@ const handleEnqueueAll = () => {
const next = new Set(starredSongs);
if (wasStarred) next.delete(song.id); else next.add(song.id);
setStarredSongs(next);
setStarredOverride(song.id, !wasStarred);
try {
if (wasStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, wasStarred);
}
};
+75 -12
View File
@@ -3,10 +3,12 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30;
const CURRENT_YEAR = new Date().getFullYear();
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
@@ -22,13 +24,26 @@ export default function Albums() {
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
const genreFiltered = selectedGenres.length > 0;
const fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10);
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const load = useCallback(async (
sortType: SortType,
offset: number,
append = false,
yearFilter?: { from: number; to: number },
) => {
setLoading(true);
try {
const data = await getAlbumList(sortType, PAGE_SIZE, offset);
const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {};
const type = yearFilter ? 'byYear' : sortType;
const data = await getAlbumList(type, PAGE_SIZE, offset, extra);
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
@@ -54,16 +69,23 @@ export default function Albums() {
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres, sort);
else { setPage(0); load(sort, 0); }
}, [sort, filtered, selectedGenres, load, loadFiltered]);
setPage(0);
if (genreFiltered) {
loadFiltered(selectedGenres, sort);
} else if (yearActive) {
load(sort, 0, false, { from: fromNum, to: toNum });
} else {
load(sort, 0);
}
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore || filtered) return;
if (loading || !hasMore || genreFiltered) return;
const next = page + 1;
setPage(next);
load(sort, next * PAGE_SIZE, true);
}, [loading, hasMore, page, sort, load, filtered]);
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
load(sort, next * PAGE_SIZE, true, yf);
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
useEffect(() => {
const observer = new IntersectionObserver(
@@ -74,8 +96,10 @@ export default function Albums() {
return () => observer.disconnect();
}, [loadMore]);
const clearYear = () => { setYearFrom(''); setYearTo(''); };
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
@@ -84,7 +108,7 @@ export default function Albums() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
@@ -94,6 +118,45 @@ export default function Albums() {
{o.label}
</button>
))}
{/* Year range filter */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
</button>
)}
</div>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
</div>
@@ -107,7 +170,7 @@ export default function Albums() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
{!filtered && (
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
+33 -9
View File
@@ -4,9 +4,11 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio } from 'lucide-react';
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
@@ -60,6 +62,8 @@ export default function ArtistDetail() {
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const currentTrack = usePlayerStore(state => state.currentTrack);
const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
useEffect(() => {
if (!id) return;
@@ -341,6 +345,28 @@ export default function ArtistDetail() {
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
</button>
{albums.length > 0 && (() => {
const progress = id ? bulkProgress[id] : undefined;
const isDone = progress && progress.done === progress.total;
const isDownloading = progress && !isDone;
return (
<button
className="btn btn-surface"
disabled={!!isDownloading}
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
data-tooltip={isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
{isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
</button>
);
})()}
</div>
</div>
</div>
@@ -362,7 +388,7 @@ export default function ArtistDetail() {
{t('artistDetail.topTracks')}
</h2>
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('artistDetail.trackTitle')}</div>
<div>{t('artistDetail.trackAlbum')}</div>
@@ -374,7 +400,7 @@ export default function ArtistDetail() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, topSongs.map(songToTrack));
@@ -384,12 +410,10 @@ export default function ArtistDetail() {
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && (
+101 -58
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist } from '../api/subsonic';
import { LayoutGrid, List } from 'lucide-react';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
const ALL_SENTINEL = 'ALL';
@@ -23,12 +25,52 @@ function nameColor(name: string): string {
}
function nameInitial(name: string): string {
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
const letter = name.match(/\p{L}/u)?.[0];
if (letter) return letter.toUpperCase();
// Fallback: first alphanumeric (e.g. "1349")
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
return alnum?.toUpperCase() ?? '?';
const alnum = name.match(/[0-9]/)?.[0];
return alnum ?? '?';
}
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
const color = nameColor(artist.name);
if (showImages && artist.coverArt) {
return (
<div className="artist-card-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 300)}
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
alt={artist.name}
/>
</div>
);
}
return (
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
);
}
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
const color = nameColor(artist.name);
if (showImages && artist.coverArt) {
return (
<div className="artist-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 64)}
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
/>
</div>
);
}
return (
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
);
}
export default function Artists() {
@@ -42,6 +84,8 @@ export default function Artists() {
const [visibleCount, setVisibleCount] = useState(50);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
@@ -101,6 +145,15 @@ export default function Artists() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
@@ -146,30 +199,25 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<div className="album-grid-wrap">
{visible.map(artist => {
const color = nameColor(artist.name);
return (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
{visible.map(artist => (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
);
})}
</div>
))}
</div>
)}
@@ -179,31 +227,26 @@ export default function Artists() {
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => {
const color = nameColor(artist.name);
return (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
id={`artist-${artist.id}`}
>
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
);
})}
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
id={`artist-${artist.id}`}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
))}
</div>
</div>
))}
@@ -211,9 +254,9 @@ export default function Artists() {
)}
{!loading && hasMore && (
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-ghost" onClick={loadMore}>
{t('artists.loadMore')}
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={loadMore}>
<ChevronDown size={16} /> {t('artists.loadMore')}
</button>
</div>
)}
+279 -56
View File
@@ -1,42 +1,92 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
import {
getStarred, getInternetRadioStations,
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
buildCoverArtUrl, coverArtCacheKey,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { ListPlus, Play, X } from 'lucide-react';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
export default function Favorites() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
// ── Column resize/visibility (must be before early return) ───────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const psyDrag = useDragDrop();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setStarredOverride(id, false);
setSongs(prev => prev.filter(s => s.id !== id));
}
function unfavoriteStation(id: string) {
setRadioStations(prev => prev.filter(s => s.id !== id));
try {
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
next.delete(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
} catch { /* ignore */ }
}
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
useEffect(() => {
getStarred()
.then(res => {
setAlbums(res.albums);
setArtists(res.artists);
setSongs(res.songs);
setLoading(false);
})
.catch(() => setLoading(false));
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
getStarred(),
]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
}
// Radio favorites: read IDs from localStorage, fetch all stations, filter
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size > 0) {
const all = await getInternetRadioStations();
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
setLoading(false);
};
loadAll();
}, []);
if (loading) {
@@ -47,7 +97,8 @@ export default function Favorites() {
);
}
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
@@ -67,14 +118,28 @@ export default function Favorites() {
<AlbumRow title={t('favorites.albums')} albums={albums} />
)}
{songs.length > 0 && (
{radioStations.length > 0 && (
<RadioStationRow
title={t('favorites.stations')}
stations={radioStations}
currentRadio={currentRadio}
isPlaying={isPlaying}
onPlay={s => {
if (currentRadio?.id === s.id && isPlaying) stop();
else playRadio(s);
}}
onUnfavorite={unfavoriteStation}
/>
)}
{visibleSongs.length > 0 && (
<section className="album-row-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-primary"
onClick={() => {
const tracks = songs.map(songToTrack);
const tracks = visibleSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
@@ -84,7 +149,7 @@ export default function Favorites() {
<button
className="btn btn-surface"
onClick={() => {
const tracks = songs.map(songToTrack);
const tracks = visibleSongs.map(songToTrack);
enqueue(tracks);
}}
>
@@ -92,24 +157,68 @@ export default function Favorites() {
{t('favorites.enqueueAll')}
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration';
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
);
})}
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{FAV_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
{songs.map((song, i) => {
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
return (
<div
key={song.id}
className="track-row track-row-va"
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, songs.map(songToTrack));
playTrack(track, visibleSongs.map(songToTrack));
}}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
@@ -129,36 +238,36 @@ export default function Favorites() {
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>{song.artist}</span>
</div>
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button
className="btn-icon fav-remove-btn"
data-tooltip={t('favorites.removeSong')}
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
aria-label={t('favorites.removeSong')}
>
<X size={14} />
</button>
</div>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
</div>
);
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
</div>
);
case 'duration': return (
<div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
);
case 'remove': return (
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
})}
@@ -170,3 +279,117 @@ export default function Favorites() {
</div>
);
}
// ── Radio Station Row ─────────────────────────────────────────────────────────
interface RadioStationRowProps {
title: string;
stations: InternetRadioStation[];
currentRadio: InternetRadioStation | null;
isPlaying: boolean;
onPlay: (s: InternetRadioStation) => void;
onUnfavorite: (id: string) => void;
}
function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{stations.map(s => (
<RadioFavCard
key={s.id}
station={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
onPlay={() => onPlay(s)}
onUnfavorite={() => onUnfavorite(s.id)}
/>
))}
</div>
</div>
</section>
);
}
// ── Radio Favorite Card ───────────────────────────────────────────────────────
interface RadioFavCardProps {
station: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
onPlay: () => void;
onUnfavorite: () => void;
}
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
const { t } = useTranslation();
return (
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover">
{s.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
alt={s.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<Cast size={48} strokeWidth={1.2} />
</div>
)}
{isActive && isPlaying && (
<div className="radio-live-overlay">
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
<button
className="radio-favorite-btn active"
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
onClick={onUnfavorite}
data-tooltip={t('radio.unfavorite')}
>
<Heart size={12} fill="currentColor" />
</button>
</div>
</div>
</div>
);
}
+892
View File
@@ -0,0 +1,892 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Cast, Plus, Trash2, X, Globe, Camera, Loader2, Search, Heart, Check } from 'lucide-react';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import {
getInternetRadioStations, createInternetRadioStation,
updateInternetRadioStation, deleteInternetRadioStation,
uploadRadioCoverArt, deleteRadioCoverArt,
uploadRadioCoverArtBytes, searchRadioBrowser, getTopRadioStations, fetchUrlBytes,
InternetRadioStation, RadioBrowserStation, buildCoverArtUrl, coverArtCacheKey, RADIO_PAGE_SIZE,
} from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from '../components/CachedImage';
import CustomSelect from '../components/CustomSelect';
import { useTranslation } from 'react-i18next';
import { open } from '@tauri-apps/plugin-shell';
import { showToast } from '../utils/toast';
export default function InternetRadio() {
const { t } = useTranslation();
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
const [stations, setStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// null = closed, 'new' = create modal, InternetRadioStation = edit modal
const [modalStation, setModalStation] = useState<InternetRadioStation | 'new' | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [sortBy, setSortBy] = useState<'manual' | 'az' | 'za' | 'newest'>('manual');
const [activeFilter, setActiveFilter] = useState('all');
const [activeLetter, setActiveLetter] = useState<string | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); }
catch { return new Set<string>(); }
});
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
useEffect(() => {
getInternetRadioStations()
.then(setStations)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const reload = async () => {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
setStations(list);
};
// Merge saved manual order with current stations when stations change
useEffect(() => {
if (!stations.length) return;
const saved: string[] = (() => {
try { return JSON.parse(localStorage.getItem('psysonic_radio_order') ?? '[]'); }
catch { return []; }
})();
const currentIds = new Set(stations.map(s => s.id));
const merged = saved.filter((id: string) => currentIds.has(id));
stations.forEach(s => { if (!merged.includes(s.id)) merged.push(s.id); });
setManualOrder(merged);
}, [stations]);
const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
return next;
});
}, []);
const handleReorder = useCallback((srcId: string, tgtId: string, side: 'before' | 'after') => {
setManualOrder(prev => {
const order = [...prev];
const si = order.indexOf(srcId);
if (si === -1) return prev;
order.splice(si, 1); // remove from original position
const ti = order.indexOf(tgtId); // recalculate after removal
if (ti === -1) return prev;
const insertAt = side === 'before' ? ti : ti + 1;
order.splice(insertAt, 0, srcId);
localStorage.setItem('psysonic_radio_order', JSON.stringify(order));
return order;
});
}, []);
// After chip-filter + sort, but before alphabet filter — used to compute available letters
const sortedFilteredStations = useMemo(() => {
let list = [...stations];
if (activeFilter === 'favorites') list = list.filter(s => favorites.has(s.id));
if (sortBy === 'az') list.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === 'za') list.sort((a, b) => b.name.localeCompare(a.name));
else if (sortBy === 'newest') list.reverse();
else {
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
list.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
}
return list;
}, [stations, activeFilter, favorites, sortBy, manualOrder]);
const availableLetters = useMemo(() => {
const set = new Set<string>();
for (const s of sortedFilteredStations) {
const ch = s.name.trim()[0]?.toUpperCase() ?? '';
if (ch >= 'A' && ch <= 'Z') set.add(ch);
else if (ch) set.add('#');
}
return set;
}, [sortedFilteredStations]);
const displayedStations = useMemo(() => {
if (!activeLetter) return sortedFilteredStations;
return sortedFilteredStations.filter(s => {
const ch = s.name.trim()[0]?.toUpperCase() ?? '';
if (activeLetter === '#') return !(ch >= 'A' && ch <= 'Z');
return ch === activeLetter;
});
}, [sortedFilteredStations, activeLetter]);
const handleSave = async (opts: {
name: string;
streamUrl: string;
homepageUrl: string;
coverFile: File | null;
coverRemoved: boolean;
}) => {
if (modalStation === 'new') {
await createInternetRadioStation(
opts.name.trim(),
opts.streamUrl.trim(),
opts.homepageUrl.trim() || undefined
);
if (opts.coverFile) {
// Reload first to get the new station's ID, then upload cover
const updated = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const created = updated.find(
s => s.name === opts.name.trim() && s.streamUrl === opts.streamUrl.trim()
);
if (created) {
try {
await uploadRadioCoverArt(created.id, opts.coverFile);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
// Reload again so coverArt field is picked up
await reload();
} else {
setStations(updated);
}
} else {
await reload();
}
} else {
const id = (modalStation as InternetRadioStation).id;
await updateInternetRadioStation(
id,
opts.name.trim(),
opts.streamUrl.trim(),
opts.homepageUrl.trim() || undefined
);
if (opts.coverFile) {
try {
await uploadRadioCoverArt(id, opts.coverFile);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
} else if (opts.coverRemoved) {
await deleteRadioCoverArt(id).catch(() => {});
}
await reload();
}
setModalStation(null);
};
const handleDelete = async (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (deleteConfirmId !== s.id) {
setDeleteConfirmId(s.id);
return;
}
if (currentRadio?.id === s.id) stop();
try {
await deleteInternetRadioStation(s.id);
setStations(prev => prev.filter(st => st.id !== s.id));
} catch {}
setDeleteConfirmId(null);
};
const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (currentRadio?.id === s.id && isPlaying) {
stop();
} else {
playRadio(s);
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
return (
<div className="content-body animate-fade-in">
{/* ── Header ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)}>
<Search size={14} /> {t('radio.browseDirectory')}
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')}>
<Plus size={15} /> {t('radio.addStation')}
</button>
</div>
</div>
{/* ── Toolbar + Grid ── */}
{stations.length === 0 ? (
<div className="empty-state">{t('radio.empty')}</div>
) : (
<>
<RadioToolbar
sortBy={sortBy}
activeFilter={activeFilter}
onSortChange={setSortBy}
onFilterChange={f => { setActiveFilter(f); setActiveLetter(null); }}
/>
<AlphabetFilterBar
activeLetter={activeLetter}
availableLetters={availableLetters}
onSelect={l => setActiveLetter(prev => prev === l ? null : l)}
/>
{displayedStations.length === 0 ? (
<div className="empty-state">{t('radio.noFavorites')}</div>
) : (
<div className="album-grid-wrap">
{displayedStations.map(s => (
<RadioCard
key={s.id}
s={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
deleteConfirmId={deleteConfirmId}
isFavorite={favorites.has(s.id)}
isManual={sortBy === 'manual'}
dropIndicator={dragOver?.id === s.id ? dragOver.side : null}
onPlay={e => handlePlay(e, s)}
onDelete={e => handleDelete(e, s)}
onEdit={() => setModalStation(s)}
onFavoriteToggle={() => toggleFavorite(s.id)}
onDragEnter={side => setDragOver({ id: s.id, side })}
onDragLeave={() => setDragOver(prev => prev?.id === s.id ? null : prev)}
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
/>
))}
</div>
)}
</>
)}
{/* ── Edit/Create Modal ── */}
{modalStation !== null && (
<RadioEditModal
station={modalStation === 'new' ? null : modalStation}
onClose={() => setModalStation(null)}
onSave={handleSave}
/>
)}
{/* ── Directory Modal ── */}
{browseOpen && (
<RadioDirectoryModal
onClose={() => setBrowseOpen(false)}
onAdded={reload}
/>
)}
</div>
);
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
interface RadioToolbarProps {
sortBy: 'manual' | 'az' | 'za' | 'newest';
activeFilter: string;
onSortChange: (s: 'manual' | 'az' | 'za' | 'newest') => void;
onFilterChange: (f: string) => void;
}
function RadioToolbar({ sortBy, activeFilter, onSortChange, onFilterChange }: RadioToolbarProps) {
const { t } = useTranslation();
const sortOptions = [
{ value: 'manual', label: t('radio.sortManual') },
{ value: 'az', label: t('radio.sortAZ') },
{ value: 'za', label: t('radio.sortZA') },
{ value: 'newest', label: t('radio.sortNewest') },
];
return (
<div className="radio-toolbar">
<div className="radio-toolbar-chips">
{(['all', 'favorites'] as const).map(f => (
<button
key={f}
className={`radio-filter-chip${activeFilter === f ? ' active' : ''}`}
onClick={() => onFilterChange(f)}
>
{f === 'all' ? t('radio.filterAll') : t('radio.filterFavorites')}
</button>
))}
</div>
<CustomSelect
value={sortBy}
options={sortOptions}
onChange={v => onSortChange(v as RadioToolbarProps['sortBy'])}
style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }}
/>
</div>
);
}
// ── Alphabet Filter Bar ────────────────────────────────────────────────────────
const ALPHABET_KEYS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
interface AlphabetFilterBarProps {
activeLetter: string | null;
availableLetters: Set<string>;
onSelect: (l: string) => void;
}
function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) {
return (
<div className="alphabet-filter-bar">
{ALPHABET_KEYS.map(l => {
const available = availableLetters.has(l);
const active = activeLetter === l;
return (
<button
key={l}
className={`alphabet-filter-btn${active ? ' active' : ''}${!available ? ' empty' : ''}`}
onClick={() => { if (available) onSelect(l); }}
tabIndex={available ? 0 : -1}
>
{l}
</button>
);
})}
</div>
);
}
// ── Radio Card ────────────────────────────────────────────────────────────────
interface RadioCardProps {
s: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
deleteConfirmId: string | null;
isFavorite: boolean;
isManual: boolean;
dropIndicator: 'before' | 'after' | null;
onPlay: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
onEdit: () => void;
onFavoriteToggle: () => void;
onDragEnter: (side: 'before' | 'after') => void;
onDragLeave: () => void;
onDropOnto: (srcId: string, side: 'before' | 'after') => void;
onCardMouseLeave: () => void;
}
function RadioCard({
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, dropIndicator,
onPlay, onDelete, onEdit, onFavoriteToggle, onDragEnter, onDragLeave,
onDropOnto, onCardMouseLeave,
}: RadioCardProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
const lastSideRef = useRef<'before' | 'after'>('after');
const { isDragging, payload } = useDragDrop();
const isBeingDragged = isDragging && !!payload && (() => {
try { return JSON.parse(payload.data).id === s.id; } catch { return false; }
})();
const dragHandlers = useDragSource(() => ({
data: JSON.stringify({ type: 'radio', id: s.id }),
label: s.name,
}));
// Calculate which half of the card the cursor is on
const getSide = (e: React.MouseEvent): 'before' | 'after' => {
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return 'after';
return e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
};
// psy-drop listener: fires when a drag is released over this card
useEffect(() => {
if (!isManual) return;
const el = cardRef.current;
if (!el) return;
const handler = (e: Event) => {
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (data.type === 'radio' && data.id !== s.id) onDropOnto(data.id, lastSideRef.current);
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [isManual, s.id, onDropOnto]);
return (
<div
ref={cardRef}
className={[
'album-card',
isActive ? 'radio-card-active' : '',
dropIndicator === 'before' ? 'radio-card-drop-before' : '',
dropIndicator === 'after' ? 'radio-card-drop-after' : '',
].filter(Boolean).join(' ')}
style={{ cursor: isManual ? 'grab' : 'default', opacity: isBeingDragged ? 0.4 : 1 }}
{...(isManual ? dragHandlers : {})}
onMouseMove={e => {
if (!isDragging || !isManual) return;
const side = getSide(e);
lastSideRef.current = side;
onDragEnter(side);
}}
onMouseLeave={() => { onDragLeave(); onCardMouseLeave(); }}
>
{/* Cover */}
<div className="album-card-cover">
{s.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
alt={s.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<Cast size={48} strokeWidth={1.2} />
</div>
)}
{isActive && isPlaying && (
<div className="radio-live-overlay">
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
<button
className={`playlist-card-delete ${deleteConfirmId === s.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={onDelete}
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
{/* Info */}
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button className="radio-card-chip" onClick={onEdit}>
{t('radio.editStation')}
</button>
<button
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}
data-tooltip={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
>
<Heart size={11} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
{s.homepageUrl && (
<button
className="player-btn player-btn-sm"
style={{ opacity: 0.6 }}
onClick={() => open(s.homepageUrl!)}
data-tooltip={t('radio.openHomepage')}
>
<Globe size={11} />
</button>
)}
</div>
</div>
</div>
);
}
// ── Radio Edit Modal ──────────────────────────────────────────────────────────
interface RadioEditModalProps {
station: InternetRadioStation | null; // null = create new
onClose: () => void;
onSave: (opts: {
name: string;
streamUrl: string;
homepageUrl: string;
coverFile: File | null;
coverRemoved: boolean;
}) => Promise<void>;
}
function RadioEditModal({ station, onClose, onSave }: RadioEditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(station?.name ?? '');
const [streamUrl, setStreamUrl] = useState(station?.streamUrl ?? '');
const [homepageUrl, setHomepageUrl] = useState(station?.homepageUrl ?? '');
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || station?.coverArt);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
if (!name.trim() || !streamUrl.trim()) return;
setSaving(true);
try {
await onSave({ name, streamUrl, homepageUrl, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }} onClick={handleOverlayClick}>
<div
className="modal-content"
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20 }}>
{station ? t('radio.editStation') : t('radio.addStation')}
</h2>
{/* Cover + fields side by side */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginBottom: 20 }}>
{/* Cover */}
<div
className="playlist-edit-cover-wrap"
style={{ width: 140, height: 140 }}
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && station?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon" style={{ width: '100%', height: '100%', borderRadius: 0 }}>
<Cast size={36} strokeWidth={1.2} />
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={13} />
{t('radio.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('radio.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Fields */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
className="input"
style={{ fontSize: 15, fontWeight: 600 }}
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('radio.stationName')}
autoFocus
/>
<input
className="input"
value={streamUrl}
onChange={e => setStreamUrl(e.target.value)}
placeholder={t('radio.streamUrl')}
/>
<input
className="input"
value={homepageUrl}
onChange={e => setHomepageUrl(e.target.value)}
placeholder={t('radio.homepageUrl')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<div />
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving || !name.trim() || !streamUrl.trim()}
>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('radio.save')}
</button>
</div>
</div>
</div>
);
}
// ── Radio Directory Modal ─────────────────────────────────────────────────────
interface RadioDirectoryModalProps {
onClose: () => void;
onAdded: () => void;
}
function RadioDirectoryModal({ onClose, onAdded }: RadioDirectoryModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<RadioBrowserStation[]>([]);
const [searching, setSearching] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [addingId, setAddingId] = useState<string | null>(null);
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const queryRef = useRef(query);
useEffect(() => { queryRef.current = query; }, [query]);
const fetchPage = useCallback(async (q: string, off: number, append: boolean) => {
if (append) setLoadingMore(true); else setSearching(true);
try {
const page = q.trim()
? await searchRadioBrowser(q.trim(), off)
: await getTopRadioStations(off);
if (append) setResults(prev => [...prev, ...page]);
else setResults(page);
setHasMore(page.length >= RADIO_PAGE_SIZE);
setOffset(off + page.length);
} catch {
if (!append) setResults([]);
setHasMore(false);
} finally {
if (append) setLoadingMore(false); else setSearching(false);
}
}, []);
// Load top stations on open
useEffect(() => {
fetchPage('', 0, false);
}, [fetchPage]);
// Debounced search; reset pagination on new query
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setOffset(0);
setHasMore(true);
fetchPage(query, 0, false);
}, query.trim() ? 400 : 0);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, fetchPage]);
// Callback ref: re-creates the IntersectionObserver whenever hasMore/loadingMore/offset change,
// so the closure always captures current state — no stale refs needed.
const sentinelRef = useCallback((node: HTMLDivElement | null) => {
if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; }
if (!node) return;
const root = scrollContainerRef.current ?? null;
observerRef.current = new IntersectionObserver(entries => {
const entry = entries[0];
console.log('[RadioDir] Observer fired:', entry.isIntersecting, '| hasMore:', hasMore, '| loading:', loadingMore);
if (entry.isIntersecting && hasMore && !loadingMore) {
fetchPage(queryRef.current, offset, true);
}
}, { root, rootMargin: '200px', threshold: 0 });
observerRef.current.observe(node);
}, [hasMore, loadingMore, offset, fetchPage]);
const handleAdd = async (s: RadioBrowserStation) => {
if (addedIds.has(s.stationuuid) || addingId !== null) return;
setAddingId(s.stationuuid);
try {
await createInternetRadioStation(s.name, s.url);
if (s.favicon) {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const created = list.find(r => r.streamUrl === s.url);
if (created) {
try {
const [fileBytes, mimeType] = await fetchUrlBytes(s.favicon);
await uploadRadioCoverArtBytes(created.id, fileBytes, mimeType);
} catch { /* favicon optional */ }
}
}
onAdded();
setAddedIds(prev => new Set(prev).add(s.stationuuid));
showToast(`${t('radio.stationAdded')}: ${s.name}`, 3000);
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg.toLowerCase().includes('unique constraint') || msg.toLowerCase().includes('radio.name')) {
showToast('Ein Sender mit diesem Namen existiert bereits.', 4000, 'error');
} else {
showToast(msg || 'Failed', 3000, 'error');
}
} finally {
setAddingId(null);
}
};
return createPortal(
// ── 1. Backdrop ──────────────────────────────────────────────
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(17,17,27,0.85)',
backdropFilter: 'blur(8px)',
}}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
{/* ── 2. Content Box ─────────────────────────────────────── */}
<div
style={{
width: '80vw',
maxWidth: 800,
height: '80vh',
background: 'var(--ctp-surface0)',
border: '1px solid var(--border)',
borderRadius: 12,
boxShadow: 'var(--shadow-lg)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
{/* ── 3. Header ──────────────────────────────────────────── */}
<div
style={{
flexShrink: 0,
padding: 20,
background: 'var(--ctp-surface0)',
zIndex: 10,
position: 'relative',
borderBottom: '1px solid var(--border)',
}}
>
<button
className="btn btn-ghost"
style={{ position: 'absolute', top: 16, right: 16, color: 'var(--text-muted)' }}
onClick={onClose}
>
<X size={18} />
</button>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
{t('radio.browseDirectory')}
</h2>
<input
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('radio.directoryPlaceholder')}
autoFocus
style={{ width: '100%' }}
/>
</div>
{/* ── 4. Body / Results ──────────────────────────────────── */}
<div ref={scrollContainerRef} style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 20px 20px' }}>
{searching ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : results.length === 0 ? (
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('radio.noResults')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingTop: 8 }}>
{results.map(s => {
const isAdded = addedIds.has(s.stationuuid);
const isLoading = addingId === s.stationuuid;
const isDisabled = isAdded || addingId !== null;
return (
<div
key={s.stationuuid}
className={`radio-browser-result${isAdded ? ' added' : ''}${isDisabled ? '' : ' clickable'}`}
onClick={() => handleAdd(s)}
>
{s.favicon ? (
<img
src={s.favicon}
alt=""
className="radio-browser-favicon"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<div className="radio-browser-favicon radio-browser-favicon--placeholder">
<Cast size={16} strokeWidth={1.5} />
</div>
)}
<div className="radio-browser-info">
<div className="radio-browser-name">{s.name}</div>
{s.tags && (
<div className="radio-browser-tags">
{s.tags.split(',').slice(0, 4).map(tag => tag.trim()).filter(Boolean).join(' · ')}
</div>
)}
</div>
<div className="radio-browser-action" aria-hidden>
{isLoading
? <Loader2 size={14} className="spin-slow" style={{ color: 'var(--accent)' }} />
: isAdded
? <Check size={14} style={{ color: 'var(--accent)' }} />
: <Plus size={14} style={{ color: 'var(--text-muted)' }} />}
</div>
</div>
);
})}
{/* Sentinel for IntersectionObserver */}
<div ref={sentinelRef} style={{ height: 20, width: '100%', flexShrink: 0 }} />
{loadingMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
<div className="spinner" style={{ width: 20, height: 20 }} />
</div>
)}
</div>
)}
</div>
</div>
</div>,
document.body
);
}
+129 -69
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '../store/offlineStore';
@@ -7,6 +7,8 @@ import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
export default function OfflineLibrary() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -15,26 +17,36 @@ export default function OfflineLibrary() {
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [filter, setFilter] = useState<FilterType>('all');
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,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
});
};
const countByType = (type: FilterType) => {
if (type === 'all') return albums.length;
return albums.filter(a => (a.type ?? 'album') === type).length;
};
const filtered = filter === 'all'
? albums
: albums.filter(a => (a.type ?? 'album') === filter);
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,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
});
};
const handlePlay = (albumId: string) => {
const tracks = buildTracks(albumId);
@@ -45,6 +57,84 @@ const buildTracks = (albumId: string) => {
enqueue(buildTracks(albumId));
};
const renderCard = (album: typeof albums[0]) => {
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.serverId}:${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)}
data-tooltip={t('queue.addToQueue')}
data-tooltip-pos="top"
>
+ 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>
);
};
// For artist filter: group by artist name
const renderArtistGroups = () => {
const groups: Record<string, typeof albums> = {};
for (const album of filtered) {
const key = album.artist || '—';
if (!groups[key]) groups[key] = [];
groups[key].push(album);
}
const sortedArtists = Object.keys(groups).sort((a, b) => a.localeCompare(b));
return sortedArtists.map(artistName => (
<div key={artistName} className="offline-artist-group">
<h2 className="offline-artist-group-heading">{artistName}</h2>
<div className="album-grid-wrap">
{groups[artistName].map(renderCard)}
</div>
</div>
));
};
const TABS: { id: FilterType; labelKey: string }[] = [
{ id: 'all', labelKey: 'connection.offlineFilterAll' },
{ id: 'album', labelKey: 'connection.offlineFilterAlbums' },
{ id: 'playlist', labelKey: 'connection.offlineFilterPlaylists' },
{ id: 'artist', labelKey: 'connection.offlineFilterArtists' },
];
return (
<div className="offline-library animate-fade-in">
<div className="offline-library-header">
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
</div>
</div>
{albums.length === 0 ? (
<div className="offline-filter-tabs">
{TABS.map(tab => {
const count = countByType(tab.id);
if (tab.id !== 'all' && count === 0) return null;
return (
<button
key={tab.id}
className={`offline-filter-tab${filter === tab.id ? ' active' : ''}`}
onClick={() => setFilter(tab.id)}
>
{t(tab.labelKey)}
<span className="offline-filter-tab-count">{count}</span>
</button>
);
})}
</div>
{filtered.length === 0 ? (
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
) : filter === 'artist' ? (
renderArtistGroups()
) : (
<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>
);
})}
{filtered.map(renderCard)}
</div>
)}
</div>
+477 -140
View File
@@ -1,17 +1,23 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart } from 'lucide-react';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
search, setRating, star, unstar,
getRandomSongs, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -50,19 +56,47 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
);
}
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
openContextMenu: s.openContextMenu,
currentTrack: s.currentTrack,
isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -92,9 +126,10 @@ export default function PlaylistDetail() {
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
const bulkRemove = () => {
const prevCount = songs.length;
const next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next);
savePlaylist(next, prevCount);
setSelectedIds(new Set());
};
@@ -133,10 +168,20 @@ export default function PlaylistDetail() {
}),
[coverQuad]);
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
const customCoverFetchUrl = useMemo(
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
[customCoverId],
);
const customCoverCacheKey = useMemo(
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
[customCoverId],
);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -148,8 +193,14 @@ export default function PlaylistDetail() {
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// ── Column resize/visibility ──────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
// DnD
const tracklistRef = useRef<HTMLDivElement>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
useEffect(() => {
@@ -164,6 +215,7 @@ export default function PlaylistDetail() {
.then(({ playlist, songs }) => {
setPlaylist(playlist);
setSongs(songs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
@@ -204,21 +256,50 @@ export default function PlaylistDetail() {
}, [playlist?.id]);
// ── Save ──────────────────────────────────────────────────────
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id));
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
if (id) touchPlaylist(id);
} catch {}
setSaving(false);
}, [id, touchPlaylist]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
name: string; comment: string; isPublic: boolean;
coverFile: File | null; coverRemoved: boolean;
}) => {
if (!id || !playlist) return;
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
setPlaylist(p => p
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
: p
);
if (opts.coverFile) {
try {
await uploadPlaylistCoverArt(id, opts.coverFile);
const { playlist: refreshed } = await getPlaylist(id);
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
showToast(t('playlists.coverUpdated'));
} catch (err) {
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
}
} else if (opts.coverRemoved) {
setCustomCoverId(null);
}
showToast(t('playlists.metaSaved'));
setEditingMeta(false);
};
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const prevCount = songs.length;
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next);
savePlaylist(next, prevCount);
};
// ── Add ───────────────────────────────────────────────────────
@@ -239,12 +320,13 @@ export default function PlaylistDetail() {
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = starredSongs.has(song.id);
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
setStarredOverride(song.id, !isStarred);
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
};
@@ -372,21 +454,61 @@ export default function PlaylistDetail() {
</button>
<div className="album-detail-hero">
{/* 2×2 cover grid */}
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
{/* Cover — click to open edit modal */}
<div
className="playlist-hero-cover"
onClick={() => setEditingMeta(true)}
>
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
</div>
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
<h1 className="album-detail-title">{playlist.name}</h1>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
data-tooltip={t('playlists.editMeta')}
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
>
<Pencil size={14} />
</button>
</div>
{playlist.comment && (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
)}
</>
<div className="album-detail-info">
<span>{t('playlists.songs', { n: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
· {playlist.public
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
</span>
)}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions">
@@ -424,6 +546,25 @@ export default function PlaylistDetail() {
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
{songs.length > 0 && id && (() => {
const isDownloading = isAlbumDownloading(id);
const isCached = isAlbumDownloaded(id, activeServerId);
const progress = isDownloading ? getAlbumProgress(id) : null;
return (
<button
className="btn btn-ghost"
disabled={isDownloading}
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
data-tooltip={isDownloading
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
</button>
);
})()}
</div>
</div>
</div>
@@ -508,19 +649,75 @@ export default function PlaylistDetail() {
)}
{/* Header */}
<div className="tracklist-header tracklist-va tracklist-playlist">
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
{selectedIds.size > 0
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
: '#'}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{PL_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
</div>
)}
</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
</div>
{songs.length === 0 && (
@@ -535,14 +732,13 @@ export default function PlaylistDetail() {
{songs.map((song, idx) => (
<React.Fragment key={song.id + idx}>
{/* Drop indicator above row */}
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<div
data-track-idx={idx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => handleRowMouseEnter(idx, e)}
onMouseDown={e => handleRowMouseDown(e, idx)}
onClick={e => {
@@ -559,77 +755,50 @@ export default function PlaylistDetail() {
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{/* # — checkbox in select mode, always-visible play button otherwise */}
{(() => {
{visibleCols.map(colDef => {
const inSelectMode = selectedIds.size > 0;
return (
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
playTrack(tracks[idx], tracks);
}}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
);
})()}
{/* Title */}
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{/* Artist */}
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
{/* Favorite */}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => handleToggleStar(song, e)}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
{/* Rating */}
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
{/* Duration */}
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
{/* Format */}
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
{/* Delete */}
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
onClick={e => { e.stopPropagation(); removeSong(idx); }}
data-tooltip={t('playlists.removeSong')}
data-tooltip-pos="left"
>
<Trash2 size={13} />
</button>
</div>
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(tracks[idx], tracks); }}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
);
case 'title': return (
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(idx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
{/* Drop indicator below last row or between rows */}
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
@@ -638,9 +807,12 @@ export default function PlaylistDetail() {
{/* Total row */}
{songs.length > 0 && (
<div className="tracklist-total tracklist-va tracklist-playlist">
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
<div className="tracklist-total" style={gridStyle}>
{visibleCols.map(c => {
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
return <span key={c.key} />;
})}
</div>
)}
</div>
@@ -666,21 +838,24 @@ export default function PlaylistDetail() {
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
<>
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div />
<div />
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="col-center">#</div>;
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
})}
</div>
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onClick={e => {
@@ -693,38 +868,200 @@ export default function PlaylistDetail() {
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
{idx + 1}
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
{/* no star/rating for suggestions */}
<div />
<div />
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
onClick={e => { e.stopPropagation(); addSong(song); }}
data-tooltip={t('playlists.addSong')}
data-tooltip-pos="left"
>
<Plus size={13} />
</button>
</div>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'favorite': return <div key="favorite" />;
case 'rating': return <div key="rating" />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
<Plus size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
))}
</>
)}
</div>
{editingMeta && playlist && (
<PlaylistEditModal
playlist={playlist}
customCoverId={customCoverId}
customCoverFetchUrl={customCoverFetchUrl ?? null}
customCoverCacheKey={customCoverCacheKey ?? null}
coverQuadUrls={coverQuadUrls}
onClose={() => setEditingMeta(false)}
onSave={handleSaveMeta}
/>
)}
</div>
);
}
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
customCoverFetchUrl: string | null;
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
coverQuadUrls, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
);
}
+143 -142
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
@@ -28,6 +28,10 @@ export default function RandomMix() {
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
@@ -105,11 +109,12 @@ export default function RandomMix() {
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const currentlyStarred = starredSongs.has(song.id);
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
const nextStarred = new Set(starredSongs);
if (currentlyStarred) nextStarred.delete(song.id);
else nextStarred.add(song.id);
setStarredSongs(nextStarred);
setStarredOverride(song.id, !currentlyStarred);
try {
if (currentlyStarred) await unstar(song.id, 'song');
@@ -117,6 +122,7 @@ export default function RandomMix() {
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, currentlyStarred);
}
};
@@ -319,19 +325,28 @@ export default function RandomMix() {
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
<div>{t('randomMix.trackAlbum')}</div>
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{genreMixSongs.map(song => {
{genreMixSongs.map((song, idx) => {
const track = songToTrack(song);
const queueSongs = genreMixSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
const artist = song.artist;
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const isArtistJustAdded = addedArtist === artist;
return (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
<div
key={song.id}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
onMouseDown={e => {
if (e.button !== 0) return;
@@ -349,21 +364,49 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
)}
</div>
)}
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell">
{artist ? (
<button
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isArtistBlocked) return;
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>{artist}</button>
) : <span className="track-artist"></span>}
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => toggleSongStar(song, e)}
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
<div className="track-duration">{formatDuration(song.duration)}</div>
</div>
);
})}
</div>
)}
</div>
)}
{!selectedGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -371,26 +414,37 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
<div>{t('randomMix.trackAlbum')}</div>
<div data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}></span>
</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{filteredSongs.map((song) => {
{filteredSongs.map((song, idx) => {
const track = songToTrack(song);
const queueSongs = filteredSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
const artist = song.artist;
const genre = song.genre;
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const isArtistJustAdded = addedArtist === artist;
const isGenreBlocked = !!genre && (
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
);
const isGenreJustAdded = addedGenre === genre;
return (
<div
key={song.id}
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => {
e.preventDefault();
@@ -413,124 +467,71 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
</button>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
{(() => {
const artist = song.artist;
if (!artist) return <span className="track-artist"></span>;
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedArtist === artist;
return (
<div className="track-artist-cell">
{artist ? (
<button
className="btn btn-ghost track-artist"
style={{
fontSize: 'inherit',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
textAlign: 'left',
}}
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
if (isArtistBlocked) return;
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>
{artist}
</button>
);
})()}
</div>
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>{artist}</button>
) : <span className="track-artist"></span>}
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
</div>
{(() => {
const genre = song.genre;
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}></div>;
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedGenre === genre;
return (
<div>
{genre ? (
<button
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isGenreBlocked) return;
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
setAddedGenre(genre);
setTimeout(() => setAddedGenre(null), 1500);
}
}}
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
>{genre}</button>
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>}
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
}}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
setAddedGenre(genre);
setTimeout(() => setAddedGenre(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
className="btn btn-ghost track-star-btn"
onClick={e => toggleSongStar(song, e)}
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
{genre}
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
);
})()}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={(e) => toggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
<div className="track-duration">{formatDuration(song.duration)}</div>
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
);
})}
</div>
))}
);
})}
</div>
))}
</div>
);
+2 -2
View File
@@ -69,7 +69,7 @@ export default function SearchResults() {
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}>
<div />
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
@@ -81,7 +81,7 @@ export default function SearchResults() {
<div
key={song.id}
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
onMouseDown={e => {
+357 -131
View File
@@ -5,8 +5,10 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
@@ -73,6 +75,13 @@ const CONTRIBUTORS = [
'Human-readable audio error messages (PR #44)',
],
},
{
github: 'zz5zz',
since: '1.32.0',
contributions: [
'Norwegian (Bokmål) translation (PR #101)',
],
},
] as const;
const SPECIAL_THANKS = [
@@ -82,7 +91,7 @@ const SPECIAL_THANKS = [
},
] as const;
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
@@ -162,7 +171,7 @@ export default function Settings() {
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'general');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -182,9 +191,9 @@ export default function Settings() {
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
useEffect(() => {
if (activeTab !== 'library') return;
if (activeTab !== 'storage') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
}, [activeTab]);
const handleClearCache = useCallback(async () => {
@@ -193,7 +202,7 @@ export default function Settings() {
await clearAllOffline(serverId);
const [imgBytes, offBytes] = await Promise.all([
getImageCacheSize(),
invoke<number>('get_offline_cache_size').catch(() => 0),
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
]);
setImageCacheBytes(imgBytes);
setOfflineCacheBytes(offBytes);
@@ -299,6 +308,13 @@ export default function Settings() {
navigate('/login');
};
const pickOfflineDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
if (selected && typeof selected === 'string') {
auth.setOfflineDownloadDir(selected);
}
};
const pickDownloadFolder = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
if (selected && typeof selected === 'string') {
@@ -307,12 +323,13 @@ export default function Settings() {
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
];
return (
@@ -333,8 +350,8 @@ export default function Settings() {
))}
</nav>
{/* ── Playback ─────────────────────────────────────────────────────────── */}
{activeTab === 'playback' && (
{/* ── Audio ────────────────────────────────────────────────────────────── */}
{activeTab === 'audio' && (
<>
{/* Equalizer */}
<section className="settings-section">
@@ -407,16 +424,16 @@ export default function Settings() {
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={auth.crossfadeSecs}
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
style={{ width: 120 }}
id="crossfade-secs-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
</span>
</div>
)}
@@ -440,6 +457,41 @@ export default function Settings() {
</label>
</div>
<div className="divider" />
{/* Preload mode */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
</div>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ width: 120 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</div>
</section>
@@ -447,63 +499,70 @@ export default function Settings() {
</>
)}
{/* ── Library ──────────────────────────────────────────────────────────── */}
{activeTab === 'library' && (
{/* ── General ──────────────────────────────────────────────────────────── */}
{activeTab === 'general' && (
<>
{/* Cache */}
{/* App behaviour */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<AppWindow size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<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={5000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
style={{ width: 120 }}
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 className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
@@ -595,6 +654,143 @@ export default function Settings() {
</>
)}
{/* ── Storage & Downloads ───────────────────────────────────────────────── */}
{activeTab === 'storage' && (
<>
{/* Offline Library (In-App) — includes cache settings */}
<section className="settings-section">
<div className="settings-section-header">
<Download size={18} />
<h2>{t('settings.offlineDirTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.offlineDirDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.offlineDownloadDir && (
<button
className="btn btn-ghost"
onClick={() => auth.setOfflineDownloadDir('')}
data-tooltip={t('settings.offlineDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
</button>
</div>
{auth.offlineDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.offlineDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
<input
className="input"
type="number"
min={100}
max={50000}
step={100}
value={auth.maxCacheMb}
onChange={e => {
const v = Number(e.target.value);
if (v >= 100) auth.setMaxCacheMb(v);
}}
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
id="cache-size-input"
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
</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>
{/* ZIP Export & Archiving */}
<section className="settings-section">
<div className="settings-section-header">
<FolderOpen size={18} />
<h2>{t('settings.downloadsTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.downloadsFolderDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.downloadFolder || t('settings.downloadsDefault')}
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.downloadFolder && (
<button
className="btn btn-ghost"
onClick={() => auth.setDownloadFolder('')}
aria-label={t('settings.clearFolder')}
data-tooltip={t('settings.clearFolder')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</div>
</section>
</>
)}
{/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === 'appearance' && (
<>
@@ -614,6 +810,7 @@ export default function Settings() {
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
{ value: 'nb', label: t('settings.languageNb') },
]}
/>
</div>
@@ -668,13 +865,13 @@ export default function Settings() {
</>
)}
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
{activeTab === 'shortcuts' && (
{/* ── Input ────────────────────────────────────────────────────────────── */}
{activeTab === 'input' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Keyboard size={18} />
<h2>{t('settings.tabShortcuts')}</h2>
<h2>{t('settings.tabInput')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
@@ -847,6 +1044,9 @@ export default function Settings() {
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.serverCompatible')}
</div>
{auth.servers.length === 0 && !showAddForm ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
@@ -876,7 +1076,7 @@ export default function Settings() {
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
<button
className="btn btn-ghost"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
@@ -915,7 +1115,7 @@ export default function Settings() {
{showAddForm ? (
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
) : (
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<button className="btn btn-surface" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
@@ -990,67 +1190,13 @@ export default function Settings() {
</div>
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
{auth.downloadFolder || t('settings.downloadsDefault')}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{auth.downloadFolder && (
<button
className="btn btn-ghost"
onClick={() => auth.setDownloadFolder('')}
aria-label={t('settings.clearFolder')}
data-tooltip={t('settings.clearFolder')}
style={{ color: 'var(--text-muted)' }}
>
<X size={16} />
</button>
)}
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn">
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</div>
</div>
</section>
</>
)}
{/* ── About ────────────────────────────────────────────────────────────── */}
{activeTab === 'about' && (
{/* ── System ───────────────────────────────────────────────────────────── */}
{activeTab === 'system' && (
<>
<BackupSection />
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
@@ -1392,6 +1538,86 @@ function SidebarCustomizer() {
);
}
function BackupSection() {
const { t } = useTranslation();
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const handleExport = async () => {
setExporting(true);
try {
const path = await exportBackup();
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
} catch (e) {
console.error('Export failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
} finally {
setExporting(false);
}
};
const handleImport = async () => {
if (!window.confirm(t('settings.backupImportConfirm'))) return;
setImporting(true);
try {
await importBackup();
// importBackup reloads the page — this toast will briefly show before reload
showToast(t('settings.backupImportSuccess'), 3000, 'info');
} catch (e) {
console.error('Import failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
setImporting(false);
}
};
return (
<section className="settings-section">
<div className="settings-section-header">
<HardDrive size={18} />
<h2>{t('settings.backupTitle')}</h2>
</div>
{/* Export */}
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
</div>
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
style={{ flexShrink: 0 }}
>
<Upload size={14} />
{exporting ? '…' : t('settings.backupExport')}
</button>
</div>
</div>
{/* Import */}
<div className="settings-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
</div>
<button
className="btn btn-surface"
onClick={handleImport}
disabled={importing}
style={{ flexShrink: 0 }}
>
<Download size={14} />
{importing ? '…' : t('settings.backupImport')}
</button>
</div>
</div>
</section>
);
}
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
+151 -7
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
@@ -14,6 +14,13 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
return `${m}m`;
}
const PERIODS: { key: LastfmPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
@@ -32,8 +39,14 @@ export default function Statistics() {
const [artistCount, setArtistCount] = useState<number | null>(null);
const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
const [playtimeCapped, setPlaytimeCapped] = useState(false);
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
const [formatSampleSize, setFormatSampleSize] = useState(0);
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
@@ -54,12 +67,62 @@ export default function Statistics() {
setFrequent(fr);
setHighest(hi);
setArtistCount(a.length);
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
setGenres(sorted);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
useEffect(() => {
let cancelled = false;
(async () => {
let total = 0;
let offset = 0;
const pageSize = 500;
const maxPages = 10;
let capped = false;
for (let page = 0; page < maxPages; page++) {
try {
const albums = await getAlbumList('newest', pageSize, offset);
if (cancelled) return;
for (const a of albums) total += (a.duration ?? 0);
if (albums.length < pageSize) break;
if (page === maxPages - 1) capped = true;
offset += pageSize;
} catch {
break;
}
}
if (!cancelled) {
setTotalPlaytime(total);
setPlaytimeCapped(capped);
}
})();
return () => { cancelled = true; };
}, []);
// Background fetch: format distribution (sample of 500 random songs)
useEffect(() => {
let cancelled = false;
getRandomSongs(500).then(songs => {
if (cancelled) return;
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const sorted = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
setFormatData(sorted);
setFormatSampleSize(songs.length);
}).catch(() => {});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmRecentLoading(true);
@@ -97,12 +160,20 @@ export default function Statistics() {
}
};
const playtimeDisplay = totalPlaytime === null
? t('statistics.computing')
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
const stats = [
{ label: t('statistics.statArtists'), value: artistCount },
{ label: t('statistics.statAlbums'), value: totalAlbums },
{ label: t('statistics.statSongs'), value: totalSongs },
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
];
const topGenres = genres.slice(0, 10);
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('statistics.title')}</h1>
@@ -115,12 +186,85 @@ export default function Statistics() {
<div className="stats-overview">
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-label">{s.label}</span>
</div>
))}
</div>
{/* Genre Insights + Format Distribution */}
{(topGenres.length > 0 || formatData) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
{topGenres.length > 0 && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
{t('statistics.genreInsights')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
<div key={g.value}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{g.value}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
{g.songCount.toLocaleString()}
</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${(g.songCount / maxGenreSongs) * 100}%`,
background: 'var(--accent)',
opacity: 0.7,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
))}
</div>
</div>
)}
{formatData && (
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
{t('statistics.formatDistribution')}
</h3>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{formatData.map(f => {
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
return (
<div key={f.format}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${pct}%`,
background: 'var(--accent)',
opacity: 0.6,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{recent.length > 0 && (
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
)}
+24
View File
@@ -26,6 +26,7 @@ interface AuthState {
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
offlineDownloadDir: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
@@ -33,8 +34,13 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
preloadMode: 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
discordRichPresence: boolean;
nowPlayingEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -60,6 +66,7 @@ interface AuthState {
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setOfflineDownloadDir: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
@@ -67,8 +74,13 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
@@ -95,6 +107,7 @@ export const useAuthStore = create<AuthState>()(
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
offlineDownloadDir: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
@@ -102,8 +115,13 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
discordRichPresence: false,
nowPlayingEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
@@ -156,6 +174,7 @@ export const useAuthStore = create<AuthState>()(
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => {
@@ -169,8 +188,13 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+25 -7
View File
@@ -37,19 +37,22 @@ export const BUILTIN_PRESETS: EqPreset[] = [
interface EqState {
gains: number[]; // 10 values, -12 to +12 dB
enabled: boolean;
preGain: number; // pre-amplification in dB (-30 to +6), applied before bands
activePreset: string | null;
customPresets: EqPreset[];
setBandGain: (index: number, gain: number) => void;
setEnabled: (v: boolean) => void;
setPreGain: (v: number) => void;
applyPreset: (name: string) => void;
applyAutoEq: (name: string, gains: number[], preGain: number) => void;
saveCustomPreset: (name: string) => void;
deleteCustomPreset: (name: string) => void;
syncToRust: () => void;
}
function syncEq(gains: number[], enabled: boolean) {
invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {});
function syncEq(gains: number[], enabled: boolean, preGain: number) {
invoke('audio_set_eq', { gains: gains.map(g => g), enabled, preGain }).catch(() => {});
}
export const useEqStore = create<EqState>()(
@@ -57,6 +60,7 @@ export const useEqStore = create<EqState>()(
(set, get) => ({
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: false,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
@@ -65,12 +69,25 @@ export const useEqStore = create<EqState>()(
const gains = [...get().gains];
gains[index] = clamped;
set({ gains, activePreset: null });
syncEq(gains, get().enabled);
syncEq(gains, get().enabled, get().preGain);
},
setEnabled: (v) => {
set({ enabled: v });
syncEq(get().gains, v);
syncEq(get().gains, v, get().preGain);
},
setPreGain: (v) => {
const clamped = Math.max(-30, Math.min(6, v));
set({ preGain: clamped, activePreset: null });
syncEq(get().gains, get().enabled, clamped);
},
applyAutoEq: (name, gains, preGain) => {
const clampedPreGain = Math.max(-30, Math.min(6, preGain));
const clampedGains = gains.map(g => Math.max(-12, Math.min(12, g)));
set({ gains: clampedGains, preGain: clampedPreGain, activePreset: name });
syncEq(clampedGains, get().enabled, clampedPreGain);
},
applyPreset: (name) => {
@@ -78,7 +95,7 @@ export const useEqStore = create<EqState>()(
const preset = all.find(p => p.name === name);
if (!preset) return;
set({ gains: [...preset.gains], activePreset: name });
syncEq(preset.gains, get().enabled);
syncEq(preset.gains, get().enabled, get().preGain);
},
saveCustomPreset: (name) => {
@@ -95,8 +112,8 @@ export const useEqStore = create<EqState>()(
},
syncToRust: () => {
const { gains, enabled } = get();
syncEq(gains, enabled);
const { gains, enabled, preGain } = get();
syncEq(gains, enabled, preGain);
},
}),
{
@@ -105,6 +122,7 @@ export const useEqStore = create<EqState>()(
partialize: (s) => ({
gains: s.gains,
enabled: s.enabled,
preGain: s.preGain,
activePreset: s.activePreset,
customPresets: s.customPresets,
}),
+57 -7
View File
@@ -1,8 +1,10 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast';
export interface OfflineTrackMeta {
id: string;
@@ -33,6 +35,7 @@ export interface OfflineAlbumMeta {
coverArt?: string;
year?: number;
trackIds: string[];
type?: 'album' | 'playlist' | 'artist';
}
export interface DownloadJob {
@@ -49,6 +52,8 @@ interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
jobs: DownloadJob[];
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
bulkProgress: Record<string, { done: number; total: number }>;
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
@@ -62,7 +67,10 @@ interface OfflineState {
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
type?: 'album' | 'playlist' | 'artist',
) => Promise<void>;
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
clearAll: (serverId: string) => Promise<void>;
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
@@ -74,6 +82,7 @@ export const useOfflineStore = create<OfflineState>()(
tracks: {},
albums: {},
jobs: [],
bulkProgress: {},
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
@@ -110,7 +119,7 @@ export const useOfflineStore = create<OfflineState>()(
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
const CONCURRENCY = 2;
const trackIds = songs.map(s => s.id);
@@ -118,7 +127,7 @@ export const useOfflineStore = create<OfflineState>()(
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
},
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
@@ -149,6 +158,7 @@ export const useOfflineStore = create<OfflineState>()(
const suffix = song.suffix || 'mp3';
const url = buildStreamUrl(song.id);
const customDir = useAuthStore.getState().offlineDownloadDir || null;
try {
const localPath = await invoke<string>('download_track_offline', {
@@ -156,6 +166,7 @@ export const useOfflineStore = create<OfflineState>()(
serverId,
url,
suffix,
customDir,
});
set(state => ({
@@ -188,7 +199,11 @@ export const useOfflineStore = create<OfflineState>()(
: j,
),
}));
} catch {
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND') {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
@@ -211,6 +226,42 @@ export const useOfflineStore = create<OfflineState>()(
}, 2500);
},
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
// Deduplicate songs (a track can appear multiple times in a playlist).
const seen = new Set<string>();
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
// Store the entire playlist as one virtual album entry so the Offline Library
// shows a single card for the playlist rather than one card per album.
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
},
downloadArtist: async (artistId, artistName, serverId) => {
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
try {
const res = await getArtist(artistId);
albums = res.albums;
} catch { return; }
set(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
}));
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
try {
const { songs } = await getAlbum(album.id);
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
} catch { /* skip failed album */ }
set(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
}));
}
setTimeout(() => {
set(state => {
const { [artistId]: _removed, ...rest } = state.bulkProgress;
return { bulkProgress: rest };
});
}, 3000);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
@@ -220,9 +271,8 @@ export const useOfflineStore = create<OfflineState>()(
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
trackId,
serverId,
suffix: meta.suffix,
localPath: meta.localPath,
baseDir: useAuthStore.getState().offlineDownloadDir || null,
}).catch(() => {});
}),
);
+186 -20
View File
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
@@ -60,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track {
interface PlayerState {
currentTrack: Track | null;
currentRadio: InternetRadioStation | null;
queue: Track[];
queueIndex: number;
isPlaying: boolean;
@@ -73,12 +74,13 @@ interface PlayerState {
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
playTrack: (track: Track, queue?: Track[]) => void;
playRadio: (station: InternetRadioStation) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
pause: () => void;
resume: () => void;
stop: () => void;
togglePlay: () => void;
next: () => void;
next: (manual?: boolean) => void;
previous: () => void;
seek: (progress: number) => void;
setVolume: (v: number) => void;
@@ -156,6 +158,53 @@ let seekTarget: number | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
// codec support (MP3, AAC, HE-AAC, OGG) and stable ICY stream handling for
// free, without touching the regular playback pipeline at all.
const radioAudio = new Audio();
radioAudio.preload = 'none';
let radioStopping = false;
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
function clearRadioReconnectTimer() {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
}
radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer();
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
});
radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; return; }
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error');
});
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
// Re-assign src to force a fresh connection, then resume playback.
const src = radioAudio.src;
radioAudio.src = src;
radioAudio.play().catch(console.error);
}, 4000);
});
// Waiting: browser is rebuffering — normal for live streams, no action needed.
radioAudio.addEventListener('waiting', () => {
console.debug('[psysonic] radio: buffering');
});
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
@@ -215,9 +264,15 @@ function handleAudioProgress(current_time: number, duration: number) {
}
}
// Pre-buffer / pre-chain next track when 30 s remain.
const { gaplessEnabled } = useAuthStore.getState();
if (dur - current_time < 30 && dur - current_time > 0) {
// Pre-buffer / pre-chain next track based on preload mode.
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
const remaining = dur - current_time;
const shouldPreload = preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0; // balanced (default)
if (shouldPreload) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
@@ -262,14 +317,21 @@ function handleAudioEnded() {
return;
}
// Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) {
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
return;
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue);
usePlayerStore.getState().playTrack(currentTrack, queue, false);
} else {
usePlayerStore.getState().next();
usePlayerStore.getState().next(false);
}
}, 150);
}
@@ -341,7 +403,7 @@ function handleAudioError(message: string) {
usePlayerStore.setState({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
usePlayerStore.getState().next();
usePlayerStore.getState().next(false);
}, 1500);
}
@@ -427,9 +489,50 @@ export function initAudioListeners(): () => void {
}
});
// ── Discord Rich Presence sync ────────────────────────────────────────────
// Updates on track change or play/pause toggle. No per-tick updates needed —
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
let discordPrevTrackId: string | null = null;
let discordPrevIsPlaying: boolean | null = null;
function syncDiscord() {
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
const { discordRichPresence } = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
if (discordPrevTrackId !== null) {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
invoke('discord_clear_presence').catch(() => {});
}
return;
}
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
if (!trackChanged && !playingChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
// Pass elapsed when playing so Discord shows a live running timer.
// Pass null when paused — Discord shows the song/artist without a timer.
elapsedSecs: isPlaying ? currentTime : null,
}).catch(() => {});
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
return () => {
unsubAuth();
unsubMpris();
unsubDiscordPlayer();
unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -440,6 +543,7 @@ export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => ({
currentTrack: null,
currentRadio: null,
queue: [],
queueIndex: 0,
isPlaying: false,
@@ -528,14 +632,52 @@ export const usePlayerStore = create<PlayerState>()(
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
invoke('audio_stop').catch(console.error);
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
} else {
invoke('audio_stop').catch(console.error);
}
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
},
// ── playRadio ────────────────────────────────────────────────────────────
playRadio: (station) => {
const { volume } = get();
++playGeneration;
isAudioPaused = false;
clearRadioReconnectTimer();
gaplessPreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
radioAudio.src = station.streamUrl;
radioAudio.volume = volume;
radioAudio.play().catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
currentRadio: station,
currentTrack: null,
queue: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: true, // no scrobbling for radio
});
},
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
playTrack: (track, queue, manual = true) => {
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
@@ -547,13 +689,24 @@ export const usePlayerStore = create<PlayerState>()(
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// If a radio stream is active, stop it before the new track starts so
// the PlayerBar clears radio mode immediately and the stream is released.
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
}
const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
// Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
set({
currentTrack: track,
currentRadio: null,
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: 0,
@@ -576,13 +729,14 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: track.duration,
replayGainDb,
replayGainPeak,
manual,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
get().next();
get().next(false);
}, 500);
});
@@ -604,12 +758,21 @@ export const usePlayerStore = create<PlayerState>()(
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
if (get().currentRadio) {
radioAudio.pause();
} else {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
}
set({ isPlaying: false });
},
resume: () => {
if (get().currentRadio) {
radioAudio.play().catch(console.error);
set({ isPlaying: true });
return;
}
const { currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
@@ -641,6 +804,7 @@ export const usePlayerStore = create<PlayerState>()(
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
manual: false,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
@@ -668,6 +832,7 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
manual: false,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
@@ -687,11 +852,11 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── next / previous ──────────────────────────────────────────────────────
next: () => {
next: (manual = true) => {
const { queue, queueIndex, repeatMode, currentTrack } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
get().playTrack(queue[nextIdx], queue, manual);
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause.
const { infiniteQueueEnabled } = useAuthStore.getState();
@@ -735,7 +900,7 @@ export const usePlayerStore = create<PlayerState>()(
}
}
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
get().playTrack(queue[0], queue, manual);
} else {
// Queue exhausted. Check radio first (independent of infinite queue setting),
// then infinite queue, then stop.
@@ -755,7 +920,7 @@ export const usePlayerStore = create<PlayerState>()(
if (fresh.length > 0) {
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...fresh];
get().playTrack(fresh[0], newQueue);
get().playTrack(fresh[0], newQueue, false);
} else {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
@@ -786,7 +951,7 @@ export const usePlayerStore = create<PlayerState>()(
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue);
get().playTrack(newTracks[0], newQueue, false);
}).catch(() => {
infiniteQueueFetching = false;
invoke('audio_stop').catch(console.error);
@@ -834,6 +999,7 @@ export const usePlayerStore = create<PlayerState>()(
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
radioAudio.volume = clamped;
set({ volume: clamped });
},
+12 -1
View File
@@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomMix', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
{ id: 'radio', visible: true },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
@@ -42,6 +43,16 @@ export const useSidebarStore = create<SidebarStore>()(
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
}),
{ name: 'psysonic_sidebar' }
{
name: 'psysonic_sidebar',
onRehydrateStorage: () => (state) => {
if (!state) return;
const known = new Set(state.items.map(i => i.id));
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
if (missing.length > 0) {
state.items = [...state.items, ...missing];
}
},
}
)
);
+1069 -228
View File
File diff suppressed because it is too large Load Diff
+67 -5
View File
@@ -1,5 +1,14 @@
/* ─── Psysonic App Shell ─── */
*, *::before, *::after {
user-select: none;
}
/* Allow text selection only where the user needs to copy content */
.artist-bio, [data-selectable] {
user-select: text;
}
.app-shell {
position: relative;
display: grid;
@@ -141,18 +150,17 @@
opacity: 1;
}
/* Now Playing nav link */
/* Now Playing nav link — subtly accented but not permanently "active"-looking */
.nav-link-nowplaying {
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.nav-link-nowplaying:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
background: var(--bg-hover);
color: var(--accent);
}
.nav-link-nowplaying.active {
background: color-mix(in srgb, var(--accent) 22%, transparent);
background: var(--accent-dim);
color: var(--accent);
}
.nav-link-nowplaying svg {
@@ -293,6 +301,24 @@
.app-updater-actions {
padding-left: 19px;
margin-top: 2px;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-updater-hint {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
margin: 0 0 2px;
}
.app-updater-error {
font-size: 10px;
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-radius: 4px;
padding: 4px 6px;
margin-bottom: 4px;
word-break: break-all;
}
.app-updater-btn-primary {
display: inline-flex;
@@ -309,6 +335,21 @@
transition: opacity var(--transition-fast);
}
.app-updater-btn-primary:hover { opacity: 1; }
.app-updater-btn-secondary {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
padding: 0;
opacity: 0.75;
transition: opacity var(--transition-fast);
}
.app-updater-btn-secondary:hover { opacity: 1; color: var(--text-primary); }
.app-updater-progress-wrap {
display: flex;
align-items: center;
@@ -694,6 +735,13 @@
/* Star + Last.fm heart — visually separated from track title */
.player-star-btn {
margin: var(--space-1);
color: var(--text-muted);
transition: color var(--transition-fast);
}
.player-star-btn:hover,
.player-star-btn.is-starred {
color: var(--color-star-active, var(--accent));
}
.player-btn-primary {
@@ -984,7 +1032,7 @@
}
.queue-round-btn:hover:not(:disabled) {
background: var(--border);
color: var(--text-primary);
color: var(--accent);
}
.queue-round-btn:disabled {
opacity: 0.35;
@@ -1052,6 +1100,14 @@
.queue-current-sub {
font-size: 12px;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.queue-current-sub.is-link {
cursor: pointer;
}
.queue-current-sub.is-link:hover {
color: var(--accent);
}
.queue-current-tech {
@@ -1127,6 +1183,12 @@
.queue-item-artist {
font-size: 11px;
color: var(--text-muted);
transition: color var(--transition-fast);
}
.queue-item:hover .queue-item-artist,
.queue-item.context-active .queue-item-artist {
color: var(--text-secondary);
}
.queue-item-duration {
+37 -6
View File
@@ -3670,10 +3670,12 @@ body.psy-dragging * {
.btn-surface {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--ctp-surface1);
}
.btn-surface:hover {
background: var(--bg-hover);
border-color: var(--ctp-overlay0);
}
/* ─── Input ─── */
@@ -8384,11 +8386,11 @@ input[type="range"]:hover::-webkit-slider-thumb {
--ctp-surface1: #D8DADF;
--ctp-surface2: #CED0D4;
--ctp-overlay0: #B0B3B8;
--ctp-overlay1: #65676B;
--ctp-overlay2: #4B4C4F;
--ctp-overlay1: #4B4C4F;
--ctp-overlay2: #3A3B3D;
--ctp-text: #050505;
--ctp-subtext1: #65676B;
--ctp-subtext0: #8A8D91;
--ctp-subtext1: #44464A;
--ctp-subtext0: #65676B;
--ctp-mauve: #1877F2;
--ctp-lavender: #2D88FF;
@@ -8418,8 +8420,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
--volume-accent: #1877F2;
--text-primary: #050505;
--text-secondary: #65676B;
--text-muted: #606369;
--text-secondary: #44464A;
--text-muted: #65676B;
--border: #CED0D4;
--border-subtle: #E4E6EB;
@@ -8533,6 +8535,35 @@ input[type="range"]:hover::-webkit-slider-thumb {
border: 1px solid var(--border-subtle);
}
/* Album chip — opaque Facebook blue pill */
[data-theme='the-book'] .badge,
[data-theme='the-book'] .album-detail-badge {
background: #1877F2;
color: #ffffff;
}
/* Back button — opaque Facebook-style pill */
[data-theme='the-book'] .album-detail-back {
background: #E7F3FF;
color: #1877F2;
}
[data-theme='the-book'] .album-detail-back:hover {
background: #D8EAFD;
color: #0d6ae0;
}
/* Queue/Lyrics tab bar — white text on blue sidebar */
[data-theme='the-book'] .queue-tab-btn {
color: rgba(255, 255, 255, 0.65);
}
[data-theme='the-book'] .queue-tab-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
[data-theme='the-book'] .queue-tab-btn.active {
color: #ffffff;
}
/* ── ReadIt (Social Media) ──────────────────────────────────── */
[data-theme='readit'] {
color-scheme: dark;
+73
View File
@@ -0,0 +1,73 @@
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
import { version as appVersion } from '../../package.json';
const BACKUP_VERSION = 1;
const BACKUP_KEYS = [
'psysonic-auth',
'psysonic_theme',
'psysonic_font',
'psysonic_language',
'psysonic_keybindings',
'psysonic_sidebar',
'psysonic-eq',
'psysonic_global_shortcuts',
'psysonic-player',
];
export async function exportBackup(): Promise<string | null> {
const stores: Record<string, unknown> = {};
for (const key of BACKUP_KEYS) {
const val = localStorage.getItem(key);
if (val !== null) {
try {
stores[key] = JSON.parse(val);
} catch {
stores[key] = val;
}
}
}
const manifest = {
version: BACKUP_VERSION,
app_version: appVersion,
created_at: new Date().toISOString(),
stores,
};
const today = new Date().toISOString().slice(0, 10);
const path = await save({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
defaultPath: `psysonic-backup-${today}.psybkp`,
});
if (!path) return null;
const content = JSON.stringify(manifest, null, 2);
await writeFile(path, new TextEncoder().encode(content));
return path;
}
export async function importBackup(): Promise<void> {
const path = await openDialog({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
multiple: false,
title: 'Import Psysonic Backup',
});
if (!path || typeof path !== 'string') return;
const raw = await readTextFile(path);
const manifest = JSON.parse(raw);
if (typeof manifest.version !== 'number' || !manifest.stores || typeof manifest.stores !== 'object') {
throw new Error('invalid_backup');
}
for (const [key, value] of Object.entries(manifest.stores)) {
localStorage.setItem(key, JSON.stringify(value));
}
window.location.reload();
}
+60 -10
View File
@@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5;
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
const objectUrlCache = new Map<string, string>();
// Concurrency limiter for network fetches
// Concurrency limiter for network fetches.
// Each queue entry is a resolver that signals "slot acquired".
let activeFetches = 0;
const fetchQueue: Array<() => void> = [];
function acquireFetchSlot(): Promise<void> {
/**
* Acquires a fetch slot. Returns true if a slot was granted, false if the
* provided AbortSignal fired while the call was waiting in the queue (in that
* case no slot is held and the caller must NOT call releaseFetchSlot).
*/
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) return Promise.resolve(false);
if (activeFetches < MAX_CONCURRENT_FETCHES) {
activeFetches++;
return Promise.resolve();
return Promise.resolve(true);
}
return new Promise(resolve => fetchQueue.push(resolve));
return new Promise<boolean>(resolve => {
const onGrant = () => {
signal?.removeEventListener('abort', onAbort);
resolve(true);
};
const onAbort = () => {
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
const idx = fetchQueue.indexOf(onGrant);
if (idx !== -1) fetchQueue.splice(idx, 1);
resolve(false);
};
fetchQueue.push(onGrant);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function releaseFetchSlot(): void {
@@ -151,6 +171,26 @@ export async function getImageCacheSize(): Promise<number> {
}
}
/** Removes a single cache entry from both in-memory and IndexedDB caches. */
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
const existing = objectUrlCache.get(cacheKey);
if (existing) {
URL.revokeObjectURL(existing);
objectUrlCache.delete(cacheKey);
}
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(cacheKey);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore
}
}
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
export async function clearImageCache(): Promise<void> {
for (const url of objectUrlCache.values()) {
@@ -174,9 +214,11 @@ export async function clearImageCache(): Promise<void> {
* Returns a cached object URL for an image.
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
* @param cacheKey A stable key that identifies the image across sessions.
* @param signal Optional AbortSignal aborts queue-waiting and in-flight fetches
* so navigating away does not leave zombie fetches draining I/O.
*/
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
if (!fetchUrl) return '';
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
if (!fetchUrl || signal?.aborted) return '';
// 1. In-memory hit (same session)
const existing = objectUrlCache.get(cacheKey);
@@ -184,6 +226,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
// 2. IndexedDB hit (persisted from previous session)
const blob = await getBlob(cacheKey);
if (signal?.aborted) return '';
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
@@ -191,19 +234,26 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
return objUrl;
}
// 3. Network fetch with concurrency limit → store in IDB → return object URL
await acquireFetchSlot();
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
const acquired = await acquireFetchSlot(signal);
if (!acquired || signal?.aborted) {
if (acquired) releaseFetchSlot();
return '';
}
try {
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
const newBlob = await resp.blob();
if (signal?.aborted) return '';
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
evictMemoryIfNeeded();
return objUrl;
} catch {
return fetchUrl;
} catch (e) {
// AbortError → return '' (component is gone). Other errors → return raw URL.
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
} finally {
releaseFetchSlot();
}
+179
View File
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
export interface ColDef {
readonly key: string;
readonly i18nKey?: string | null;
readonly minWidth: number;
readonly defaultWidth: number;
readonly required: boolean;
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
readonly flex?: boolean;
}
function loadPrefs(
storageKey: string,
columns: readonly ColDef[],
): { widths: Record<string, number>; visible: Set<string> } {
const defaultWidths: Record<string, number> = Object.fromEntries(
columns.map(c => [c.key, c.defaultWidth]),
);
const defaultVisible = new Set<string>(columns.map(c => c.key));
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key));
return {
widths: { ...defaultWidths, ...(parsed.widths ?? {}) },
visible,
};
} catch {
return { widths: defaultWidths, visible: defaultVisible };
}
}
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
}
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
const [colWidths, setColWidths] = useState<Record<string, number>>(
() => loadPrefs(storageKey, columns).widths,
);
const [colVisible, setColVisible] = useState<Set<string>>(
() => loadPrefs(storageKey, columns).visible,
);
const [pickerOpen, setPickerOpen] = useState(false);
const tracklistRef = useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
// Refs to avoid stale closures in drag/save handlers
const colWidthsRef = useRef(colWidths);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
const visibleCols = useMemo(
() => columns.filter(c => colVisible.has(c.key)),
[columns, colVisible],
);
const gridTemplate = useMemo(
() =>
visibleCols
.map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
.join(' '),
[visibleCols, colWidths],
);
// Minimum total width so the grid never squishes below its current column sizes.
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
const gridMinWidth = useMemo(() => {
const gapPx = 12; // --space-3
const boxPaddingH = 24; // var(--space-3) * 2
const colSum = visibleCols.reduce<number>(
(s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
0,
);
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
return colSum + gaps + boxPaddingH;
}, [visibleCols, colWidths]);
const gridStyle = useMemo(
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
[gridTemplate, gridMinWidth],
);
// Excel-style column resize:
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
const startResize = useCallback(
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
e.preventDefault();
e.stopPropagation();
const visCols = visibleCols; // stable for the drag duration
const colDef = visCols[colIndex];
const colKey = colDef.key;
const colMin = columns.find(c => c.key === colKey)!.minWidth;
const startX = e.clientX;
const startW = colWidths[colKey];
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
: 12;
const totalGaps = (visCols.length - 1) * gapPx;
const otherFixed = visCols
.filter((_, i) => i !== colIndex)
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
}
const onMove = (me: MouseEvent) => {
const delta = me.clientX - startX;
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
setColWidths(prev => ({ ...prev, [colKey]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[columns, visibleCols, colWidths, storageKey],
);
const toggleColumn = useCallback(
(key: string) => {
const def = columns.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
savePrefs(storageKey, colWidthsRef.current, next);
return next;
});
},
[columns, storageKey],
);
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen]);
return {
colWidths,
colVisible,
visibleCols,
gridStyle,
startResize,
toggleColumn,
pickerOpen,
setPickerOpen,
pickerRef,
tracklistRef,
};
}