Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01f71cf50c | |||
| 39966d1c65 | |||
| cbf72c7eba | |||
| 4207455440 | |||
| 9983d13122 | |||
| 1b55e88cd5 | |||
| 2326f1f94b | |||
| 27ea221130 | |||
| 9cd4743d1c | |||
| e75fda168e | |||
| ec021516c7 | |||
| 82e1b458f3 | |||
| fc7a5815ef | |||
| 1cf5faff21 | |||
| 16cb4f5a6f | |||
| 9b22327bb0 | |||
| dba89cc4e4 | |||
| 91d4cb58b8 | |||
| 374ebce9b5 | |||
| 083c5f62cb | |||
| 592a479c30 | |||
| 6439200e95 | |||
| 9cb1471170 | |||
| 21e26e2604 | |||
| 94323e91fa | |||
| bef6941a2b | |||
| 805b6bf163 | |||
| fee454da5d | |||
| a765bcd5d0 | |||
| 7e50c7af40 | |||
| a12d6a4015 | |||
| da1cc91ff1 | |||
| 08235e1aa5 | |||
| 57dbd50092 | |||
| 4a2baaf87d | |||
| 65e748c464 | |||
| 346fe604d9 | |||
| 600af0e527 | |||
| 2dd993257a | |||
| c2f7d6d495 | |||
| 6a8422ad1f | |||
| 8f45f7230e | |||
| ae2e1bcb97 | |||
| 3d07a877f2 | |||
| bf38a286cd | |||
| 8f18f73b33 | |||
| 1a2352009b | |||
| eedf6f9337 | |||
| b9b8f3fc15 | |||
| b46acc88e3 | |||
| b4c3124168 | |||
| 20dabbfd03 | |||
| 003e7a3203 | |||
| c142e9e983 | |||
| 549677ffd4 | |||
| dcc3e52ad1 | |||
| 4b6888049f | |||
| f45892e975 | |||
| 16c511c167 | |||
|
9986149e2c
|
|||
| 31f72b0459 | |||
| e060737de9 | |||
| 28b23a9de1 | |||
| 649f5223b4 | |||
| 1cc674e4ee | |||
| 41e98d5783 | |||
| 6ffcd6f6fa | |||
| 33a15fd17a | |||
| 084543e59b | |||
| b0081e3d7a | |||
| ccc9f2cae5 | |||
| f34bd7c0f1 | |||
| 8cb5eb9384 | |||
| 02a4c43f61 | |||
| c49f6af38b | |||
| 69c0c5a907 | |||
| 4de577eede | |||
| c9dfbcc19f | |||
| 36f3d42dbe | |||
| 49f7fe5f6e | |||
| 28943f1ecb | |||
| 46cefb5712 | |||
| 74985fe331 | |||
| 4861d99cb1 | |||
| d2592839b0 | |||
| bbeb2f661e | |||
| 5f0fb5dcbd | |||
| 6f6cb0fd6b | |||
| 15dc970f53 | |||
| fc40d235d0 | |||
| f304589ea1 | |||
| 22f7de45e8 | |||
| 05da369aad | |||
| 9671f89a48 | |||
| 9567a94dea | |||
| 1ba8619a37 | |||
| 973b472c1f | |||
| 20bf93c344 | |||
| fd834314ba | |||
| a78c0fe9ac | |||
| 9c57d4f887 | |||
| 5d067b1f8b | |||
| 48d0145dc8 | |||
| 0da92c2fa1 | |||
| 4e3efa3814 | |||
| 77085a544e | |||
| 8ffecb4b7c | |||
| c569ff5f34 | |||
| d16a99a6f9 | |||
| 9b239892ef | |||
| ba670bd1e8 | |||
| c1e57b4c06 | |||
| fbe68116cc | |||
| dba0c26480 | |||
| 3643a78cd6 | |||
| 1e4b851e9e | |||
|
37799fb861
|
|||
| 099516121e | |||
| 9047a44480 | |||
| 47fcade3b3 | |||
| d832dfb253 | |||
| 041d946b58 | |||
| 64d8b1cbd3 | |||
| 5848c621fd | |||
| 0a0dde057d | |||
| 0119e27f6d | |||
| c7adb599ee | |||
| d6546e12ca | |||
| f0438cae5e | |||
| e64d151079 | |||
| f0b423ddc4 | |||
| 8f3b9ebe00 | |||
| d4b18fec5a | |||
| dbb53bfa70 | |||
| 737b057d4a | |||
| 0c2ac13fed | |||
| 1624880c2a | |||
| 6587c82e0c | |||
| 8a1d942128 | |||
| 829936a78d | |||
| ada7dd010f | |||
| 67de94d955 | |||
| 705c80ef07 | |||
| 504c53e71d | |||
| 5be7f7cabd | |||
| c1624342d3 | |||
| 4182b67732 | |||
| 7b4eeea0dc | |||
| 47cea7e3d6 | |||
| 5ed1b58d67 | |||
| 900853fedc | |||
| 2c3c89f078 | |||
| 45fa606ae1 | |||
| d49137475f |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: psychotoxic
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -19,12 +19,7 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
cache: 'npm'
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
@@ -97,12 +92,7 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
cache: 'npm'
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -135,19 +125,13 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev
|
||||
libasound2-dev squashfs-tools
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: cache npm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
cache: 'npm'
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -164,7 +148,8 @@ jobs:
|
||||
env:
|
||||
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
run: npm run tauri:build -- --bundles deb,rpm
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
run: npm run tauri:build -- --bundles deb,rpm,appimage
|
||||
|
||||
- name: upload Linux artifacts
|
||||
env:
|
||||
@@ -172,5 +157,5 @@ jobs:
|
||||
run: |
|
||||
VERSION=${{ needs.create-release.outputs.package_version }}
|
||||
find src-tauri/target/release/bundle \
|
||||
\( -name "*.deb" -o -name "*.rpm" \) \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "app-v${VERSION}" --clobber
|
||||
|
||||
@@ -31,6 +31,7 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
@@ -40,3 +41,6 @@ memory/
|
||||
|
||||
# Local scratchpad / notes (not committed)
|
||||
tmp/
|
||||
|
||||
# Third-party clones for local research (not committed)
|
||||
research/
|
||||
|
||||
@@ -5,6 +5,313 @@ 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.34.10] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- **AppImage bundle for Linux** + X11/XWayland enforcement on all Linux packages: CI now builds `.AppImage` in addition to `.deb` and `.rpm`. `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` are set automatically at startup on all Linux packages — WebKitGTK on Wayland is unstable. Both environment variables are still overridable by setting them before launch.
|
||||
|
||||
- **Audio output device selection** *(Issue [#169](https://github.com/Psychotoxical/psysonic/issues/169))*: Settings → Audio now shows a dropdown of all available output devices. The current OS default is pinned at the top with a label; a Refresh button re-enumerates silently. A device watcher detects hot-plug events and emits `audio:device-reset` after ~9 s of consecutive misses, preventing false positives on busy ALSA devices. On Linux, technical ALSA prefixes are stripped for display (`sysdefault:CARD=U192k` → `U192k`).
|
||||
|
||||
- **Vision Dark & Vision Navy — colorblind-safe themes** *(Issue [#166](https://github.com/Psychotoxical/psysonic/issues/166))*: Two new themes using a Purple & Gold palette designed to be safe for Deuteranopia, Protanopia, and Tritanopia. Vision Dark pairs near-black `#0D0B12` with Gold `#FFD700` (~14.7:1 WCAG AAA); Vision Navy uses deep navy `#0A1628` + Gold (~14.5:1 WCAG AAA). Both appear under a new **Accessibility** group in the Theme Picker. These themes are a first step toward proper colorblind support and will be revised and expanded in upcoming releases — structural improvements such as secondary indicators and pattern/shape cues are still on the roadmap.
|
||||
|
||||
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
|
||||
|
||||
- **Keybindings — in-app modifier chords** *(contributed by [@kveld9](https://github.com/kveld9), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 0–1 progress fraction.
|
||||
|
||||
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fullscreen Player — animation overhead in no-compositing mode** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#175](https://github.com/Psychotoxical/psysonic/pull/175))*: In software-rendering mode (`WEBKIT_DISABLE_COMPOSITING_MODE=1`) the mesh blob pan animations are now stopped (static gradients are preserved), the portrait drift animation is stopped, and `box-shadow` is removed from the seekbar played bar. The seekbar played bar width changes on every playback tick; triggering a full shadow repaint in software mode caused significant CPU overhead.
|
||||
|
||||
- **Folder Browser — arrow keys with modifier keys** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#174](https://github.com/Psychotoxical/psysonic/pull/174))*: Column and list arrow-key handling is now skipped when any modifier key is held, preventing conflicts with browser focus navigation and OS-level shortcuts. Modifier detection uses both `nativeEvent` and `getModifierState` for WebKit/WebView2 compatibility.
|
||||
|
||||
- **Audio output device — Linux stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#176](https://github.com/Psychotoxical/psysonic/pull/176))*: Pinned ALSA/cpal device IDs now stay stable when enumeration temporarily omits the active sink or returns an equivalent name. The Linux device-watcher no longer clears the pin based solely on missing list entries — only macOS and Windows treat repeated absence as "device unplugged". The Settings refresh flow calls `canonicalize` and refetches the list; an i18n label is now shown when the active device is no longer in the enumerated list.
|
||||
|
||||
- **Login — server URL field** *(Issue [#171](https://github.com/Psychotoxical/psysonic/issues/171))*: The placeholder text in the Add Server form was previously a hardcoded English string. It is now fully localised and clarifies that `https://` URLs are accepted.
|
||||
|
||||
- **Offline mode — non-blocking banner** *(Issue [#170](https://github.com/Psychotoxical/psysonic/issues/170))*: The full-screen blocking overlay shown when Psysonic starts without a cached library is replaced with a slim banner at the top of the content area. The banner includes a direct link to Server Settings so the user can fix the connection without navigating manually.
|
||||
|
||||
---
|
||||
|
||||
*Special thanks to everyone who contributed to this release:*
|
||||
*[@cucadmuh](https://github.com/cucadmuh) for two significant Folder Browser improvements and the Linux audio stability fixes — three PRs in one release cycle, remarkable.*
|
||||
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
|
||||
*[@kveld9](https://github.com/kveld9) for the modifier-chord keybindings and the playlist management overhaul.*
|
||||
|
||||
---
|
||||
|
||||
## [1.34.9] - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-select in Playlist Detail & Favorites** *(Issue [#157](https://github.com/Psychotoxical/psysonic/issues/157))*: The same Ctrl/Cmd+Click multi-select system that was previously exclusive to album track lists is now available everywhere. Hold Ctrl (or ⌘ on macOS) to enter select mode, Shift+Click to range-select, click the header checkbox to toggle all. Selected tracks can be dragged as a group directly into the queue. A bulk action bar appears with **Add to Playlist** and **Clear selection** options. Works in Playlist Detail (main tracklist) and in the Favorites song list.
|
||||
|
||||
- **"Open Artist" in context menu**: Song context menus now show an **Open Artist** entry directly below **Open Album**, navigating to the artist detail page. Previously only accessible via the tracklist artist link.
|
||||
|
||||
- **"Add to Playlist" for Artists**: The context menu for artists now includes an **Add to Playlist** submenu. Psysonic fetches all albums from the artist and collects every track, then forwards them to the playlist picker — identical to the existing album-level submenu.
|
||||
|
||||
- **Infinite queue — Instant Mix strategy** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#163](https://github.com/Psychotoxical/psysonic/pull/163))*: When Infinite Queue is enabled, Psysonic now builds the upcoming track list using the same artist-driven logic as Instant Mix. It fetches **Top Songs** and **Similar Songs** for the current track's artist, shuffles and deduplicates the pool, and only falls back to fully random songs when no artist-driven candidates are available. This results in much more coherent listening sessions that stay close to your current musical context.
|
||||
|
||||
- **Fullscreen Player — appearance settings** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: Settings → Appearance → Fullscreen Player now offers a toggle to show/hide the artist portrait and a 0–80 % dimming slider for the background portrait.
|
||||
|
||||
- **Build a Mix hub** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#155](https://github.com/Psychotoxical/psysonic/pull/155))*: The previous *Random Mix* and *Random Albums* sidebar entries have been merged into a single **Build a Mix** page (Wand icon) at `/random`. A landing card lets you choose between *Mix by Tracks* and *Mix by Albums*. Old routes remain fully functional.
|
||||
|
||||
- **Spanish translation** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#159](https://github.com/Psychotoxical/psysonic/pull/159))*: Complete Spanish (es) locale with 964 translated strings. Psysonic now ships in 8 languages: English, German, French, Dutch, Chinese, Norwegian, Russian, and Spanish.
|
||||
|
||||
- **Column-header sorting for Albums & Playlists** *(contributed by [@Kveld9](https://github.com/Kveld9), PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))*: Track lists in Album Detail and Playlist Detail now support click-to-sort directly on the column headers. Three-click cycle: ascending → descending → natural order. Sortable columns: Title, Artist, Album, Favourite, Rating, Duration. The active column is shown bold with a ▲/▼ indicator.
|
||||
|
||||
- **Folder Browser — keyboard navigation & context menus** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))*: Full keyboard navigation in the Folder Browser with arrow keys, Enter to open, and Ctrl+Enter to open the context menu. Context menus for all row types include keyboard-operable submenus and star-rating control via arrow keys. The now-playing path is visually emphasized and updates live. Adaptive column layout prioritizes right-side visibility for deep directory trees. A new configurable *Open Folder Browser* keybinding is available in Settings → Keyboard.
|
||||
|
||||
- **PLS/M3U playlist resolution for Internet Radio**: Stations configured with a `.pls` or `.m3u`/`.m3u8` URL (e.g. SomaFM, schizoid.in) are now resolved to their first direct stream URL before playback. ICY metadata fetching also auto-resolves playlist URLs. Previously these stations would fail to play or show no track info.
|
||||
|
||||
- **Lyrics sources — configurable order & per-source toggle**: The old *Server First* toggle has been replaced with a full drag-to-reorder list in Settings → General. Three sources — **Server** (embedded/OpenSubsonic), **LRCLIB**, and **Netease Cloud Music** — can each be individually enabled or disabled, and their priority order is fully customisable. Embedded SYLT tags from local files always win unconditionally.
|
||||
|
||||
- **ReplayGain Pre-Gain & Fallback** *(audio)*: Two new sliders in Settings → Audio → ReplayGain:
|
||||
- **Pre-Gain** (0–+6 dB): added on top of every ReplayGain-tagged track for users who prefer a louder default.
|
||||
- **Fallback Gain** (−6–0 dB): applied to untagged tracks and internet radio streams, preventing volume jumps when switching between tagged and untagged content.
|
||||
|
||||
- **Context-aware Remix button in Build a Mix**: When a genre filter is active, the Remix button now re-fetches the same genre instead of resetting to the full library pool. An *All Songs* chip is available as the first genre option to return to the global mix without leaving the page.
|
||||
|
||||
- **AlbumTrackList multi-select & psyDnD** *(tracklist polish)*: Album track lists now support full multi-select with Ctrl/Cmd+Click, Shift+Click range selection, and drag-to-queue for multiple tracks simultaneously. The `TrackRow` component is `React.memo` with fine-grained Zustand selectors, so only the toggled row re-renders on selection change (O(1)).
|
||||
|
||||
- **Mute/unmute restores previous volume**: The mute button in the player bar now restores the volume to its level before muting instead of always jumping to 70 %.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Statistics — accurate counts for large libraries**: The statistics page was previously capped at 10 pages (≈ 5,000 albums), causing incorrect totals on larger libraries. The pagination loop now runs until the server returns a partial page, regardless of library size. Sort type changed to `alphabeticalByName` for stable pagination.
|
||||
|
||||
- **Statistics — Artists count tooltip**: The Artists card now shows a tooltip (dotted underline, cursor: help) explaining that the count reflects album artists only — a Subsonic API limitation. Featured or guest artists who do not have their own album are not counted. The tooltip is localised in all 8 languages.
|
||||
|
||||
- **Artists page — alphabet navigation hover effect**: The A–Z filter buttons had inline styles that prevented `:hover` CSS from applying. Buttons are now styled via `.artists-alpha-btn` CSS class with an accent-coloured hover highlight and a subtle glow ring.
|
||||
|
||||
- **Hot Cache — eviction & prefetch budget**: Eviction now correctly keeps only the current and next track; prefetch fetches up to five tracks when under the size cap but always fetches the immediate next; the previous current track is given a grace period until the debounce fires; eviction runs immediately on MB limit or folder changes; the cap is re-read after each download completes. Live disk usage is now shown on the Audio settings page.
|
||||
|
||||
- **Hot Cache + Preload — mutual exclusion on rehydration**: Users who had both Hot Cache and Preload enabled before the mutual-exclusion rule was introduced will have both automatically reset to off on first launch, preventing a conflicting state.
|
||||
|
||||
- **Fullscreen Player — Linux compositing performance** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#156](https://github.com/Psychotoxical/psysonic/pull/156))*: A new `no_compositing_mode` Tauri command detects Linux software-rendering mode and adds an `html.no-compositing` class, which swaps GPU-only CSS effects (`backdrop-filter`, `filter`, `mask-image`) for software-friendly equivalents throughout the fullscreen player.
|
||||
|
||||
- **Fullscreen Player — long lyric lines wrapping**: Long words in lyric lines now wrap correctly instead of overflowing the container.
|
||||
|
||||
- **Russian locale** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))*: Numerous translation improvements across the application, replacing machine-translated or awkward phrasings with natural Russian.
|
||||
|
||||
- **npm audit vulnerabilities**: Updated `axios` and `vite` to address reported security advisories.
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Remove from Queue" context menu item** now has a **Trash** icon, matching the destructive action style of other delete operations.
|
||||
- **Playlist Detail — filter-mode drag**: Rows in a filtered/sorted playlist view can now be dragged to the queue as single songs (previously dragging was disabled entirely in filter mode).
|
||||
- **Infinite queue deduplication**: Tracks already present in the queue are excluded from the candidate pool, preventing the same song from appearing twice in a row during Infinite Queue sessions.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to **v1.34.9**:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Infinite queue via Instant Mix strategy (PR [#163](https://github.com/Psychotoxical/psysonic/pull/163)), Folder Browser keyboard navigation & context menus (PR [#158](https://github.com/Psychotoxical/psysonic/pull/158))
|
||||
- [@kilyabin](https://github.com/kilyabin) — Fullscreen Player performance & appearance settings (PR [#156](https://github.com/Psychotoxical/psysonic/pull/156)), Build a Mix hub (PR [#155](https://github.com/Psychotoxical/psysonic/pull/155)), Russian locale improvements (PR [#148](https://github.com/Psychotoxical/psysonic/pull/148))
|
||||
- [@Kveld9](https://github.com/Kveld9) — Spanish translation (PR [#159](https://github.com/Psychotoxical/psysonic/pull/159)), Column-header sorting (PR [#160](https://github.com/Psychotoxical/psysonic/pull/160))
|
||||
|
||||
A huge thank you to all three of you — your contributions have made this one of the most feature-packed patch releases yet. Psysonic keeps getting better because of people like you. 🙌
|
||||
|
||||
---
|
||||
|
||||
## [1.34.8] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Netease Cloud Music Lyrics** *(opt-in)*: Netease Cloud Music can now be enabled in Settings → General as a last-resort lyrics fallback. It only fires when neither the server nor LRCLIB return results — the existing lyrics chain is completely unaffected. Particularly useful for Asian and international music. Chinese metadata lines (作词/作曲/编曲 etc.) are automatically stripped from the LRC output.
|
||||
|
||||
- **Navidrome AudioMuse-AI Integration** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))*: Psysonic now supports [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) if it is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic keeps its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
|
||||
|
||||
- **ICY metadata & AzuraCast radio** *(contributed by [@nisrael](https://github.com/nisrael), PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))*: Internet radio now displays live track metadata from ICY streams. AzuraCast stations are supported with extended now-playing information.
|
||||
|
||||
- **Automatic audio device switching**: Psysonic now detects newly connected or changed audio output devices and switches to them automatically — no app restart required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Multi-artist tracks**: Tracks with multiple artists (OpenSubsonic `artists[]` field, e.g. semicolon-separated entries) now display each artist individually. Artists with their own profile page are clickable links; artists without one appear as plain text. Separated by `·`.
|
||||
|
||||
- **Gapless + Preload Gate**: The gapless chain and the preload gate now run on separate paths. Previously both could fire simultaneously, causing a brief black flash on track change.
|
||||
|
||||
- **Replay Gain — missing album gain**: When no album gain tag is present, Psysonic now correctly falls back to track gain instead of skipping gain correction entirely.
|
||||
|
||||
- **Statistics — music library scope**: Genre insights now respect the currently selected music library. Fetch results are cached to avoid redundant server requests. Playback durations are displayed in localized units.
|
||||
|
||||
- **Russian locale**: "Most Played" in the sidebar, home page, and page title now uses «Популярное».
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Reset to defaults" buttons** in Settings → Input are now styled as warning buttons (red border).
|
||||
- **Lyrics button** removed from the player bar (redundant with the queue panel tab).
|
||||
- **Icons**: Advanced search now uses the `TextSearch` icon; artist bio button now uses `Highlighter`.
|
||||
- **Album chip** in the album detail header is now opaque across all themes.
|
||||
- **Hot Cache and Hi-Res Audio**: Alpha badges removed — both features are production-ready.
|
||||
- **CPU optimisations**: Next-track buffering and preload settings have been consolidated into a unified control.
|
||||
|
||||
### Theme Fixes
|
||||
|
||||
- **Middle Earth**: Removed vertical stripe pattern from sidebar; improved queue artist contrast on hover; fixed album detail artist colour, bio text, and "Read more" link readability; "Next Tracks" divider label is now lighter.
|
||||
- **Toy Tale**: Fixed sidebar section labels (System/Library), queue tab buttons (Lyrics/Queue), inactive artist text, and "Next Tracks" divider label — all were too dark to read.
|
||||
- **Tetrastack**: Raised all purple and blue palette values (`#a020f0` → `#c070ff`, `#0060f0` → `#4090ff`); raised `--text-muted` from `#3a3a6a` to `#7878b8` — affected settings descriptions, artist names in tracklists, and queue labels.
|
||||
- **Horde & Alliance**: Removed repeating horizontal line pattern from sidebar.
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — AudioMuse-AI Navidrome integration (PR [#147](https://github.com/Psychotoxical/psysonic/pull/147))
|
||||
- [@sorensiimSalling](https://github.com/sorensiimSalling) — ICY metadata & AzuraCast radio support (PR [#146](https://github.com/Psychotoxical/psysonic/pull/146))
|
||||
|
||||
You make Psysonic better. 🙌
|
||||
|
||||
---
|
||||
|
||||
## [1.34.7] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows — Taskbar Thumbnail Toolbar**: Prev / Play-Pause / Next media buttons now appear in the Windows taskbar thumbnail preview (the popup that appears when hovering over the taskbar icon). Buttons emit the same `media:*` events as the tray menu and souvlaki. The Play/Pause icon swaps in real-time as playback state changes.
|
||||
|
||||
- **Windows — High-quality taskbar icons**: The taskbar thumbnail toolbar icons are now loaded from embedded `.ico` assets (`play.ico`, `pause.ico`, `prev.ico`, `next.ico`) via `CreateIconFromResourceEx`, replacing the previous monochrome GDI drawing code. All four icons are properly cleaned up on window destruction.
|
||||
|
||||
- **Professional update modal**: The in-app updater now shows a polished modal with the full release changelog, a **Skip this version** option, and an OS-aware direct download button (`.dmg` on macOS, `.exe` on Windows, `.deb`/`.rpm` on Linux) as a fallback if the auto-update fails. The modal is fully localised in all 7 supported languages.
|
||||
|
||||
- **Self-hosted fonts — no internet required**: All 10 UI fonts are now shipped as WOFF2 files bundled into the app via `@fontsource-variable` npm packages. The previous Google Fonts CDN dependency has been removed entirely — Psysonic now renders correctly with no internet connection and without any external requests on startup.
|
||||
|
||||
- **Help — 11 new FAQ entries**: The Help page covers previously undocumented features across Ratings (how to rate songs/albums/artists, removing a rating, Skip-to-1★, rating filter for mixes), Folder Browser, Theme Scheduler, UI Scale, Seekbar styles, AutoEQ, Replay Gain, Hot Cache, and offline playlist caching. All 7 locales updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Embedded lyrics (MP3 & FLAC)**: A new `get_embedded_lyrics` Tauri command reads lyrics tags directly from local files — `SYLT`/`USLT` frames for MP3 (via the `id3` crate) and `SYNCEDLYRICS`/`LYRICS` tags for FLAC (via `lofty`). Additionally: the LRC parser now correctly handles timestamps without fractional seconds (e.g. `[01:23]`), and the Subsonic structured-lyrics parser now accepts both `synced` and `issynced` field names for compatibility with different server versions.
|
||||
|
||||
- **Linux — player bar disappearing at high zoom / small window sizes**: All `grid-template-rows` definitions now use `minmax(0, 1fr)` instead of bare `1fr`, and the `min-height: 720px` constraint on the app shell has been removed. The player bar no longer gets pushed off-screen when the window is small or the UI scale is above 100 %.
|
||||
|
||||
- **Windows — "Open folder" in Settings crashing**: The Settings page uses a Rust `open_folder` command instead of the Tauri `shell:open` API, which was blocked by the capability scope on Windows for local paths.
|
||||
|
||||
- **macOS — Artist Radio crashing WKWebView after ~10 minutes**: Storing `currentTime` in the persisted Zustand state caused up to ~1,200 synchronous `localStorage.setItem` calls per radio session, eventually crashing the WKWebView SQLite backend. `currentTime` has been removed from the persist partializer. Old played radio tracks are also now trimmed from the queue (keeping the last 5) to cap the localStorage payload during queue top-up.
|
||||
|
||||
- **Artist Radio — predictable track order**: The initial Artist Radio queue is now shuffled via Fisher-Yates, so positions 2+ draw from similar-artist tracks in a random order rather than always playing the server's top-5 tracks in sequence.
|
||||
|
||||
- **Internet Radio — stall / buffering recovery**: Stall events on the HTML5 `<audio>` element now trigger automatic reconnection (up to 5 retries), recovering from transient network interruptions without requiring a manual restart.
|
||||
|
||||
- **Corrupt MP3s — VLC-style frame tolerance**: The audio decoder now tolerates up to 100 consecutive bad frames before giving up (previously 3), matching VLC's behavior for files with invalid `main_data` offset frames. Frame-drop log messages are suppressed in release builds.
|
||||
|
||||
- **Statistics — album/song totals respect selected music library**: Album and track counts on the Statistics page were previously derived from `getGenres()`, which is not scoped to the active music folder. Both counts are now derived from the same paginated `getAlbumList` pass used for playtime, with the same 5,000-album cap and a `≥` prefix when capped. *(PR [#138](https://github.com/Psychotoxical/psysonic/pull/138) by [@cucadmuh](https://github.com/cucadmuh))*
|
||||
|
||||
- **Fullscreen — resize grips visible in native fullscreen**: Resize grips are now hidden whenever the window enters native fullscreen on all platforms (previously only tracked on Linux). An initial check on mount also catches windows that start in a maximized or fullscreen state.
|
||||
|
||||
- **Albums page — year filter input height**: The "From year" / "To year" inputs in the Albums filter bar now match the height and font size of adjacent buttons, fixing the mixed-height row introduced in v1.34.4.
|
||||
|
||||
- **Russian locale — missing lyrics-source strings**: The `lyricsServerFirst` and related settings strings were not translated in the Russian locale. *(PR [#140](https://github.com/Psychotoxical/psysonic/pull/140) by [@kilyabin](https://github.com/kilyabin))*
|
||||
|
||||
### Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release:
|
||||
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — Statistics music-folder scope fix (PR [#138](https://github.com/Psychotoxical/psysonic/pull/138))
|
||||
- [@kilyabin](https://github.com/kilyabin) — Russian locale lyrics strings (PR [#140](https://github.com/Psychotoxical/psysonic/pull/140))
|
||||
|
||||
---
|
||||
|
||||
## [1.34.6] - 2026-04-08
|
||||
|
||||
> I'm sorry this is already the third release today — every time we shipped a critical fix, another critical issue surfaced. Hopefully this one holds. 🤞
|
||||
|
||||
### 🚨 Critical Fix
|
||||
|
||||
- **ZIP downloads no longer freeze the UI**: All ZIP downloads (Album Detail, Playlist Detail, Albums, New Releases, Random Albums) previously buffered the entire file in the JS heap via `fetch + blob + arrayBuffer`, which caused the app to become completely unresponsive for large downloads (e.g. a 600-song, 7 GB playlist). Downloads now stream directly to disk via the Rust backend (`invoke('download_zip')`), matching the existing single-album download behavior. Progress is shown in the download overlay (bottom right).
|
||||
|
||||
- **Offline cache downloads no longer freeze the UI**: Caching a large playlist (600+ songs) triggered up to ~1,200 synchronous `localStorage.setItem` calls as Zustand's `persist` middleware wrote on every state update. Transient download job state has been moved to a new non-persisted store (`offlineJobStore`), reducing localStorage writes for an entire download to **2** regardless of playlist size.
|
||||
|
||||
### Added
|
||||
|
||||
- **Playlist offline toggle**: When a playlist is already cached offline, clicking the cache button now removes it from the offline cache (shown with a red trash icon) instead of re-downloading it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Home page — "Recently Added" section title** now links to `/new-releases` instead of `/albums`.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.5] - 2026-04-08
|
||||
|
||||
### 🚨 Critical Fix
|
||||
|
||||
- **Massive API request flood fixed** *(closes [#133](https://github.com/Psychotoxical/psysonic/issues/133))*: Psysonic was generating 15,000+ background requests per day, filling reverse-proxy access logs (Traefik, nginx) and in some cases crashing the proxy entirely. Four root causes identified and resolved:
|
||||
- **Now Playing polling**: Was firing every 10 seconds unconditionally — even when minimized or the dropdown was closed. Now only polls while the dropdown is open, and respects the Page Visibility API to pause immediately when the window is hidden.
|
||||
- **Connection check interval**: Reduced from every 30 seconds to every **120 seconds** (4× reduction).
|
||||
- **Queue sync debounce**: Increased from 1.5 s to **5 s**, preventing request bursts when skipping rapidly through tracks.
|
||||
- **Rating prefetch cache**: Artist and album ratings are now cached in memory for **7 minutes**. Repeated page loads (Random Albums, Random Mix) no longer re-fetch ratings that were just retrieved.
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme Scheduler** *(Settings → Appearance)*: Automatically switches the active theme at configurable times of day. Two time slots (e.g. a light theme during the day, a dark one at night). English locale displays hours in 12-hour AM/PM format; all other languages use 24-hour format.
|
||||
|
||||
- **Theme Scheduler hint**: When the scheduler is active, a notice appears at the top of the Theme Picker explaining why manually selecting a theme has no immediate effect.
|
||||
|
||||
- **UI Scale** *(Settings → Appearance)*: Adjust the global interface scale (80 % – 125 %) without changing the system font size.
|
||||
|
||||
- **Folder Browser**: New sidebar section with Miller-columns directory navigation. Browse the server's music folder tree and play or queue folders directly.
|
||||
|
||||
- **Seekbar — Waveform fade edges**: The Waveform seekbar style now fades out at both ends, giving it a cleaner, less abrupt look.
|
||||
|
||||
- **Cover art fallback logo**: When a cover art image fails to load (broken URL, server error), the Psysonic logo is shown as a placeholder instead of a broken image icon.
|
||||
|
||||
- **Tiling WM support** *(PR [#134](https://github.com/Psychotoxical/psysonic/pull/134))*: On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, etc.) the custom title bar is automatically hidden — the WM manages window decorations. The title bar toggle in Settings is also hidden on tiling WMs. Detection is based on environment variables (`HYPRLAND_INSTANCE_SIGNATURE`, `SWAYSOCK`, `I3SOCK`, `XDG_CURRENT_DESKTOP`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Custom title bar disabled by default**: New installations start with the native OS title bar. Existing users keep their saved preference.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Custom title bar (Linux) — drag & resize**: Window dragging via the title bar now works correctly (missing Tauri `core:window:allow-start-dragging` capability was silently blocking it). CSS resize grips are now shown at the bottom corners to compensate for the removed native GTK grips. The title bar no longer misplaces itself when the window is resized to a small width (mobile-grid layout now includes the title bar row).
|
||||
|
||||
- **Fullscreen Player — accent color delay**: The dynamic accent color extracted from album artwork now appears in ~200–300 ms instead of up to 18 seconds. The previous implementation queued the cover fetch behind up to 5 concurrent image loads via the app-wide image cache. It now fetches the cover directly and independently. The extracted color is also cached per cover ID, so switching between tracks on the same album is instant.
|
||||
|
||||
- **Artist Detail page — slow initial render** *(closes [#132](https://github.com/Psychotoxical/psysonic/issues/132))*: Artist info and biography are now fetched independently of the main artist data, so the page renders immediately and the bio fades in once available. Previously, a slow `getArtistInfo` response blocked the entire page from rendering.
|
||||
|
||||
- **Seekbar — Pulse Wave & Retro Tape styles**: Pulse Wave no longer leaves a stray connecting line at the playhead position. Retro Tape's rolling wheel is now anchored at the playhead instead of the center of the bar.
|
||||
|
||||
- **Statistics — Top Rated Songs/Artists sections removed**: These sections were incorrectly added in v1.34.4 and have been removed. All other rating features from that release remain fully intact.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.4] - 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (1–5 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
|
||||
|
||||
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
|
||||
|
||||
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating to a song after it has been manually skipped a configurable number of consecutive times. This skip count threshold can be enabled and adjusted in Settings → Ratings.
|
||||
|
||||
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
|
||||
|
||||
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
|
||||
|
||||
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
|
||||
|
||||
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
|
||||
|
||||
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
|
||||
|
||||
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
|
||||
|
||||
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
|
||||
|
||||
### i18n
|
||||
|
||||
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
|
||||
|
||||
---
|
||||
|
||||
## [1.34.3] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> [!WARNING]
|
||||
> **Psysonic is under heavy active development.** Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
@@ -28,27 +32,35 @@ Psysonic is a beautiful desktop music player built completely from the ground up
|
||||
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- 🎨 **Gorgeous UI**: 67 beautiful themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations. A time-based **Theme Scheduler** can automatically switch between a day and night theme.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, Chinese, Norwegian, and Russian.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
|
||||
- 🎤 **Synchronized Lyrics**: Lyrics pane in the sidebar and fullscreen player — powered by LRCLIB and your Navidrome server. Synced lyrics auto-scroll with line highlighting and click-to-seek; plain-text fallback for unsynced tracks.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 🎛️ **10-Band Graphic EQ**: Built-in EQ with presets and the ability to save custom presets. **AutoEQ** support lets you load headphone correction profiles automatically.
|
||||
- 🔀 **Gapless & Crossfade**: True gapless playback and configurable crossfade between tracks (mutually exclusive).
|
||||
- 📻 **Internet Radio**: Built-in internet radio player — browse and play any ICY/HLS stream directly within Psysonic.
|
||||
- ⭐ **Ratings**: Rate songs, albums, and artists with 1–5 stars via the context menu, player bar, or album detail view. Supports the OpenSubsonic ratings extension. Auto-rate-down songs you skip repeatedly (configurable threshold). Filter Random Mix and Random Albums by minimum star rating.
|
||||
- 🖥️ **Fullscreen Player**: A dedicated fullscreen view with album art, animated lyrics overlay, and artist image — toggled with a single click.
|
||||
- 📋 **Playlist Management**: Create, edit, rename, and delete playlists. Drag-and-drop track reordering, song search, and smart suggestions right inside the playlist view.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays, multi-select album actions, and detailed artist pages with related albums.
|
||||
- 〰️ **Multi-Style Seekbar**: 10 canvas-drawn seekbar styles — Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🔤 **Font Picker & UI Scale**: 10 UI fonts and a global zoom slider (80–150%) to match your display and taste.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔔 **System Tray**: Minimize Psysonic to the system tray. Play/Pause, Prev, Next, and Show/Hide controls available from the tray icon.
|
||||
- 💾 **Backup & Restore**: Export and import all your settings, themes, and server profiles in one click.
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
@@ -57,30 +69,41 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] AutoEQ — automatic headphone correction profile loader
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Waveform seekbar
|
||||
- [x] Multi-style seekbar (10 styles: Waveform, Bar, Thick, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, Retro Tape)
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
|
||||
- [x] Statistics — Last.fm top charts, recent scrobbles, top-rated songs & artists
|
||||
- [x] Synchronized lyrics via LRCLIB and Navidrome server (in-sidebar + fullscreen, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] Internet Radio (ICY/HLS streams)
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
- [x] Advanced Search (text + genre + year + result-type filters)
|
||||
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese)
|
||||
- [x] 67 themes across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Time-based Theme Scheduler (auto day/night theme switching)
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese, Norwegian, Russian)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
- [x] Font picker (10 UI fonts) + global UI scale slider
|
||||
- [x] Playlist management (create, edit, delete, drag-and-drop reorder, suggestions)
|
||||
- [x] Fullscreen player with synced lyrics overlay
|
||||
- [x] System tray icon with playback controls
|
||||
- [x] Song / Album / Artist ratings (1–5 stars, OpenSubsonic extension)
|
||||
- [x] Auto-rate-down on repeated skips + minimum-rating filter for mixes
|
||||
- [x] Album multi-select actions
|
||||
- [x] Custom Linux titlebar
|
||||
- [x] Backup & Restore (settings export/import)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 67 themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.2",
|
||||
"version": "1.34.9",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
@@ -773,6 +783,96 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/figtree": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/figtree/-/figtree-5.2.10.tgz",
|
||||
"integrity": "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.2.7.tgz",
|
||||
"integrity": "sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/outfit": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
|
||||
"integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/plus-jakarta-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.2.8.tgz",
|
||||
"integrity": "sha512-iQecBizIdZxezODNHzOn4SvvRMrZL/S8k4MEXGDynCmUrImVW0VmX+tIAMqnADwH4haXlHSXqMgU6+kcfBQJdw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/rubik": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.2.8.tgz",
|
||||
"integrity": "sha512-vGDExLzB4a2Fj9mca5LqNoA2ZKcU9o+x5FEBLte/nxYkCB9hOQwZS6ZlItUv+Ssn7YMzKauGuI/Po+YueFuZbg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/space-grotesk": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz",
|
||||
"integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1505,21 +1605,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
|
||||
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz",
|
||||
"integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-global-shortcut": {
|
||||
@@ -1842,14 +1942,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
@@ -2596,10 +2696,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
@@ -2896,9 +2999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.3",
|
||||
"version": "1.34.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,10 +12,20 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.3
|
||||
pkgver=1.34.9
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 365 KiB |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.3"
|
||||
version = "1.34.10"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -35,7 +35,7 @@ symphonia = { version = "0.5", default-features = false, features = ["flac", "mp
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
@@ -44,6 +44,22 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"]
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
id3 = "1.16.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
|
||||
@@ -28,10 +28,13 @@
|
||||
"window-state:allow-restore-state",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -2,6 +2,8 @@ use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(unix)]
|
||||
use libc;
|
||||
|
||||
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
|
||||
|
||||
@@ -890,7 +892,13 @@ impl MediaSource for SizedCursorSource {
|
||||
// Implements Iterator<Item = i16> + Source — identical interface to
|
||||
// rodio::Decoder, so the rest of the source chain is unchanged.
|
||||
|
||||
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
|
||||
const DECODE_MAX_RETRIES: usize = 3;
|
||||
/// Max *consecutive* DecodeErrors before giving up on a file.
|
||||
/// Non-fatal errors like "invalid main_data offset" are silently dropped up to
|
||||
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
|
||||
/// playable track (VLC-style frame dropping).
|
||||
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
|
||||
|
||||
struct SizedDecoder {
|
||||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||
@@ -899,6 +907,9 @@ struct SizedDecoder {
|
||||
total_duration: Option<Time>,
|
||||
buffer: SampleBuffer<i16>,
|
||||
spec: SignalSpec,
|
||||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||||
consecutive_decode_errors: usize,
|
||||
}
|
||||
|
||||
impl SizedDecoder {
|
||||
@@ -983,6 +994,8 @@ impl SizedDecoder {
|
||||
let mut format = probed.format;
|
||||
|
||||
// Decode the first packet to initialise spec + buffer.
|
||||
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
|
||||
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
|
||||
let mut decode_errors: usize = 0;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
@@ -1002,10 +1015,10 @@ impl SizedDecoder {
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => break decoded,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
eprintln!("[psysonic] decode error (retry {decode_errors}): {msg}");
|
||||
decode_errors += 1;
|
||||
if decode_errors > DECODE_MAX_RETRIES {
|
||||
return Err("too many decode errors — file may be corrupt".into());
|
||||
eprintln!("[psysonic] init: dropped corrupt frame #{decode_errors}: {msg}");
|
||||
if decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("too many consecutive decode errors during init — file may be corrupt".into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1025,6 +1038,7 @@ impl SizedDecoder {
|
||||
total_duration,
|
||||
buffer,
|
||||
spec,
|
||||
consecutive_decode_errors: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1061,16 +1075,19 @@ impl SizedDecoder {
|
||||
if packet.track_id() != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => {
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
errors += 1;
|
||||
if errors > DECODE_MAX_RETRIES { return Err("radio: too many decode errors".into()); }
|
||||
eprintln!("[psysonic] radio init: dropped corrupt frame #{errors}: {msg}");
|
||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("radio: too many consecutive decode errors".into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("radio: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec })
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1120,18 +1137,47 @@ impl Iterator for SizedDecoder {
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<i16> {
|
||||
if self.current_frame_offset >= self.buffer.len() {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet().ok()?;
|
||||
decoded = self.decoder.decode(&p);
|
||||
// Loop until a decodable packet is found or the stream ends.
|
||||
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
|
||||
// drop the frame and advance to the next packet. IO errors and a
|
||||
// clean end-of-stream both terminate the iterator normally.
|
||||
loop {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
match self.decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
self.consecutive_decode_errors = 0;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
break;
|
||||
}
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
#[cfg(not(debug_assertions))]
|
||||
let _ = msg;
|
||||
self.consecutive_decode_errors += 1;
|
||||
// Log sparingly: first drop, then every 10th to avoid spam.
|
||||
#[cfg(debug_assertions)]
|
||||
if self.consecutive_decode_errors == 1
|
||||
|| self.consecutive_decode_errors % 10 == 0
|
||||
{
|
||||
eprintln!(
|
||||
"[psysonic] dropped corrupt frame #{}: {msg}",
|
||||
self.consecutive_decode_errors
|
||||
);
|
||||
}
|
||||
if self.consecutive_decode_errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!(
|
||||
"[psysonic] {MAX_CONSECUTIVE_DECODE_ERRORS} consecutive decode \
|
||||
failures — stream appears unrecoverable, stopping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// continue → fetch next packet
|
||||
}
|
||||
Err(_) => return None, // IO error or fatal codec error → end of stream
|
||||
}
|
||||
}
|
||||
let decoded = decoded.ok()?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = 0;
|
||||
}
|
||||
|
||||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||
@@ -1404,9 +1450,11 @@ pub struct AudioEngine {
|
||||
/// The rate the device was opened at on cold start — used to restore the
|
||||
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||
pub device_default_rate: u32,
|
||||
/// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
|
||||
/// re-open the output device. `is_hi_res` controls thread-priority escalation.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
||||
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||
/// thread to re-open the output device. `device_name = None` → system default.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
||||
/// User-selected output device name (None = follow system default).
|
||||
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
@@ -1467,7 +1515,10 @@ impl AudioCurrent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the system default output device at `desired_rate` Hz (0 = device default).
|
||||
/// Open an output device at `desired_rate` Hz (0 = device default).
|
||||
///
|
||||
/// `device_name`: exact name from `audio_list_devices`. `None` → system default.
|
||||
/// Falls back to the system default if the named device is not found.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Exact rate match in the device's supported config ranges.
|
||||
@@ -1476,12 +1527,31 @@ impl AudioCurrent {
|
||||
/// 4. System default (last resort).
|
||||
///
|
||||
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
|
||||
fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
|
||||
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
// Suppress ALSA stderr noise while enumerating devices on Unix.
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
if let Some(device) = host.default_output_device() {
|
||||
// Resolve the target device: named device first, fall back to system default.
|
||||
let device = device_name.and_then(|name| {
|
||||
host.output_devices().ok()?.find(|d| d.name().ok().as_deref() == Some(name))
|
||||
}).or_else(|| host.default_output_device());
|
||||
|
||||
if let Some(device) = device {
|
||||
if desired_rate > 0 {
|
||||
if let Ok(supported) = device.supported_output_configs() {
|
||||
let configs: Vec<_> = supported.collect();
|
||||
@@ -1564,7 +1634,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (init_tx, init_rx) =
|
||||
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
|
||||
let (reopen_tx, reopen_rx) =
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
@@ -1583,11 +1653,11 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// Thread priority is kept at default during standard-mode playback.
|
||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||
let (mut _stream, handle, rate) = open_stream_for_rate(0);
|
||||
let (mut _stream, handle, rate) = open_stream_for_device_and_rate(None, 0);
|
||||
init_tx.send((handle, rate)).ok();
|
||||
|
||||
// Keep the stream alive and handle sample-rate switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
|
||||
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
@@ -1613,7 +1683,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate);
|
||||
let (new_stream, new_handle, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
_stream = new_stream;
|
||||
reply_tx.send(new_handle).ok();
|
||||
}
|
||||
@@ -1627,6 +1697,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||
device_default_rate: initial_rate,
|
||||
stream_reopen_tx: reopen_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
@@ -1642,6 +1713,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
http_client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.use_rustls_tls()
|
||||
.user_agent(format!("psysonic/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
|
||||
@@ -1727,6 +1799,24 @@ async fn fetch_data(
|
||||
}
|
||||
|
||||
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let status = response.status();
|
||||
let ct = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
let server_hdr = response.headers()
|
||||
.get("server")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("-");
|
||||
// Strip auth params from URL before logging.
|
||||
let safe_url = url.split('?').next().unwrap_or(url);
|
||||
eprintln!(
|
||||
"[audio] fetch {} → {} | content-type: {} | server: {}",
|
||||
safe_url, status, ct, server_hdr
|
||||
);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None); // superseded
|
||||
@@ -1759,11 +1849,13 @@ const MASTER_HEADROOM: f32 = 0.891_254;
|
||||
fn compute_gain(
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
volume: f32,
|
||||
) -> (f32, f32) {
|
||||
let gain_linear = replay_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0);
|
||||
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
|
||||
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0));
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
@@ -1779,6 +1871,8 @@ pub async fn audio_play(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
app: AppHandle,
|
||||
@@ -1868,7 +1962,7 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
@@ -1951,7 +2045,8 @@ pub async fn audio_play(
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, reply_tx)).is_ok() {
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(new_handle) => {
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
@@ -2113,6 +2208,8 @@ pub async fn audio_chain_preload(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -2173,7 +2270,7 @@ pub async fn audio_chain_preload(
|
||||
|
||||
let raw_bytes = Arc::new(data);
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
@@ -2284,8 +2381,8 @@ fn spawn_progress_task(
|
||||
let mut samples_played = samples_played;
|
||||
|
||||
loop {
|
||||
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
@@ -2553,9 +2650,11 @@ pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
@@ -2812,6 +2911,167 @@ pub async fn audio_play_radio(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
|
||||
#[cfg(unix)]
|
||||
fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
|
||||
}
|
||||
}
|
||||
let _guard = unsafe {
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
f()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[inline]
|
||||
fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
f()
|
||||
}
|
||||
|
||||
fn enumerate_output_device_names() -> Vec<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
|
||||
const IFACES: &[&str] = &[
|
||||
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
|
||||
"surround51", "surround71",
|
||||
];
|
||||
let colon = name.find(':')?;
|
||||
let iface = name[..colon].to_ascii_lowercase();
|
||||
if !IFACES.iter().any(|&i| i == iface.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
|
||||
let dev = name
|
||||
.split("DEV=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
Some((iface, card, dev))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[inline]
|
||||
fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (
|
||||
linux_alsa_sink_fingerprint(a),
|
||||
linux_alsa_sink_fingerprint(b),
|
||||
) {
|
||||
(Some(fa), Some(fb)) => fa == fb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||
fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||
available
|
||||
.iter()
|
||||
.any(|d| output_devices_logically_same(d, pinned))
|
||||
}
|
||||
|
||||
/// If the pinned id is missing from cpal's list but another listed id is the same
|
||||
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
||||
#[tauri::command]
|
||||
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
||||
let pinned = state.selected_device.lock().unwrap().clone()?;
|
||||
if pinned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
return None;
|
||||
}
|
||||
let canon = list
|
||||
.iter()
|
||||
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||
.clone();
|
||||
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
///
|
||||
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *state.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
#[tauri::command]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device().and_then(|d| d.name().ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
||||
#[tauri::command]
|
||||
pub async fn audio_set_device(
|
||||
device_name: Option<String>,
|
||||
state: State<'_, AudioEngine>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
state.stream_reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
|
||||
// Drop active sinks — they were bound to the old stream.
|
||||
if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); }
|
||||
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
@@ -2822,3 +3082,156 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls every 3 s for two conditions:
|
||||
// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no
|
||||
// device is pinned → reopen on new default, emit audio:device-changed.
|
||||
// 2. (macOS / Windows only) User-pinned device disappeared from cpal's list →
|
||||
// fall back to system default, clear selected_device, emit audio:device-reset.
|
||||
// Linux: case 2 is disabled — ALSA/cpal often omit the active sink from
|
||||
// enumeration while streaming, which caused false resets to system default.
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let stream_rate = engine.stream_sample_rate.clone();
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
let selected_device = engine.selected_device.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut pinned_miss_count: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host.default_output_device().and_then(|d| d.name().ok());
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
|
||||
// whole session — any miss counter eventually tripped audio:device-reset.
|
||||
// Clearing the pin is left to the user (Settings → System Default) or
|
||||
// to a future explicit error signal from the output stream.
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(ref dev_name) = pinned {
|
||||
if !output_enumeration_includes_pinned(&available, dev_name) {
|
||||
pinned_miss_count += 1;
|
||||
if pinned_miss_count < 3 {
|
||||
continue;
|
||||
}
|
||||
eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
|
||||
pinned_miss_count = 0;
|
||||
*selected_device.lock().unwrap() = None;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let rate = stream_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx2 = reopen_tx.clone();
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
if let Some(handle) = new_handle {
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
|
||||
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
||||
app.emit("audio:device-reset", ()).ok();
|
||||
}
|
||||
|
||||
last_default = current_default;
|
||||
} else {
|
||||
pinned_miss_count = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 1: no pinned device, system default changed ──────────────
|
||||
if current_default == last_default {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let rate = stream_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx2 = reopen_tx.clone();
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
continue;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
|
||||
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
||||
app.emit("audio:device-changed", ()).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod taskbar_win;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{
|
||||
@@ -18,6 +20,13 @@ use tauri::{
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
type ShortcutMap = Mutex<HashMap<String, String>>;
|
||||
|
||||
/// Maximum number of offline track downloads that can run concurrently.
|
||||
/// The frontend queues more tasks than this; Rust is the real throttle.
|
||||
const MAX_DL_CONCURRENCY: usize = 4;
|
||||
|
||||
/// Shared semaphore that caps simultaneous `download_track_offline` executions.
|
||||
type DownloadSemaphore = Arc<tokio::sync::Semaphore>;
|
||||
|
||||
/// 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>>;
|
||||
@@ -36,6 +45,19 @@ fn exit_app(app_handle: tauri::AppHandle) {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
#[tauri::command]
|
||||
fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
let _ = win.set_decorations(enabled);
|
||||
// Re-enabling native decorations on GTK causes the window manager to
|
||||
// re-stack the window, which drops focus. Bring it back immediately.
|
||||
if enabled {
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 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> {
|
||||
@@ -228,6 +250,221 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
Ok((bytes.to_vec(), content_type))
|
||||
}
|
||||
|
||||
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
|
||||
/// Returns the response body as a UTF-8 string for parsing on the JS side.
|
||||
#[tauri::command]
|
||||
async fn fetch_json_url(url: String) -> Result<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")
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let text = resp.text().await.map_err(|e| e.to_string())?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// ICY metadata response returned to the frontend.
|
||||
#[derive(serde::Serialize)]
|
||||
struct IcyMetadata {
|
||||
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
|
||||
stream_title: Option<String>,
|
||||
/// Value of the `icy-name` response header.
|
||||
icy_name: Option<String>,
|
||||
/// Value of the `icy-genre` response header.
|
||||
icy_genre: Option<String>,
|
||||
/// Value of the `icy-url` response header.
|
||||
icy_url: Option<String>,
|
||||
/// Value of the `icy-description` response header.
|
||||
icy_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the first `File1=` stream URL from a PLS playlist file.
|
||||
fn parse_pls_stream_url(content: &str) -> Option<String> {
|
||||
content.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.to_lowercase().starts_with("file1="))
|
||||
.and_then(|l| {
|
||||
let url = l[6..].trim();
|
||||
(url.starts_with("http://") || url.starts_with("https://"))
|
||||
.then(|| url.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file.
|
||||
fn parse_m3u_stream_url(content: &str) -> Option<String> {
|
||||
content.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with('#')
|
||||
&& (l.starts_with("http://") || l.starts_with("https://")))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// If `url` points to a PLS or M3U playlist, fetch it and return the first
|
||||
/// stream URL it contains. Returns `None` for direct stream URLs.
|
||||
async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option<String> {
|
||||
let path = url.split('?').next().unwrap_or(url).to_lowercase();
|
||||
let is_pls = path.ends_with(".pls");
|
||||
let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8");
|
||||
if !is_pls && !is_m3u {
|
||||
return None;
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let text = resp.text().await.ok()?;
|
||||
|
||||
if is_pls || ct.contains("scpls") || ct.contains("pls+xml") {
|
||||
parse_pls_stream_url(&text)
|
||||
} else {
|
||||
parse_m3u_stream_url(&text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch ICY in-stream metadata from a radio stream URL.
|
||||
///
|
||||
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
|
||||
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
|
||||
/// extract the `StreamTitle`. The connection is dropped as soon as the
|
||||
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
|
||||
///
|
||||
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
|
||||
/// stream URL before the ICY request is made.
|
||||
#[tauri::command]
|
||||
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Resolve PLS/M3U playlist files to their first direct stream URL.
|
||||
let url = resolve_playlist_url(&client, &url).await.unwrap_or(url);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Harvest ICY headers before consuming the body.
|
||||
let headers = resp.headers();
|
||||
let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let metaint: Option<usize> = headers
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
// If the server doesn't advertise a metaint we can still return header info.
|
||||
let Some(metaint) = metaint else {
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
};
|
||||
|
||||
// Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks.
|
||||
let metaint = metaint.min(65_536);
|
||||
let needed = metaint + 1; // +1 for the metadata-length byte
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(needed + 256);
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
while buf.len() < needed {
|
||||
let Some(chunk) = stream.next().await else { break };
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if buf.len() < needed {
|
||||
// Stream ended before we reached the metadata block.
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
}
|
||||
|
||||
// The byte immediately after `metaint` audio bytes encodes metadata length:
|
||||
// actual_bytes = length_byte * 16
|
||||
let meta_len = buf[metaint] as usize * 16;
|
||||
if meta_len == 0 {
|
||||
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||
}
|
||||
|
||||
// We may need to read a few more chunks to get the full metadata block.
|
||||
let total_needed = needed + meta_len;
|
||||
while buf.len() < total_needed {
|
||||
let Some(chunk) = stream.next().await else { break };
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
let meta_start = needed; // index of first metadata byte
|
||||
let meta_end = (meta_start + meta_len).min(buf.len());
|
||||
let meta_bytes = &buf[meta_start..meta_end];
|
||||
|
||||
// ICY metadata is Latin-1 encoded; convert to a Rust String lossily.
|
||||
let meta_str: String = meta_bytes
|
||||
.iter()
|
||||
.map(|&b| if b == 0 { '\0' } else { b as char })
|
||||
.collect::<String>();
|
||||
|
||||
// Parse StreamTitle='...' — value ends at the next unescaped single-quote.
|
||||
let stream_title = meta_str
|
||||
.split("StreamTitle='")
|
||||
.nth(1)
|
||||
.and_then(|s| {
|
||||
// Find closing quote that is NOT preceded by a backslash.
|
||||
let mut prev = '\0';
|
||||
let mut end = s.len();
|
||||
for (i, c) in s.char_indices() {
|
||||
if c == '\'' && prev != '\\' {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
let title = s[..end].trim().to_string();
|
||||
if title.is_empty() { None } else { Some(title) }
|
||||
});
|
||||
|
||||
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
|
||||
}
|
||||
|
||||
/// Resolve a PLS or M3U playlist URL to its first direct stream URL.
|
||||
/// Returns the original URL unchanged if it is not a recognised playlist format
|
||||
/// or if the playlist cannot be fetched/parsed.
|
||||
#[tauri::command]
|
||||
async fn resolve_stream_url(url: String) -> String {
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
else {
|
||||
return url;
|
||||
};
|
||||
resolve_playlist_url(&client, &url).await.unwrap_or(url)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -386,8 +623,32 @@ fn mpris_set_playback(
|
||||
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend).
|
||||
#[tauri::command]
|
||||
fn check_dir_accessible(path: String) -> bool {
|
||||
std::path::Path::new(&path).is_dir()
|
||||
}
|
||||
|
||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||
|
||||
/// Streams an HTTP response body directly to `dest_path` in small chunks.
|
||||
/// Never buffers the full file in memory — keeps RAM flat regardless of file size.
|
||||
async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut file = tokio::fs::File::create(dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads a single track to the app's offline cache directory.
|
||||
/// Returns the absolute file path so TypeScript can store it and later
|
||||
/// construct a `psysonic-local://<path>` URL for the audio engine.
|
||||
@@ -398,6 +659,7 @@ async fn download_track_offline(
|
||||
url: String,
|
||||
suffix: String,
|
||||
custom_dir: Option<String>,
|
||||
dl_sem: tauri::State<'_, DownloadSemaphore>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
// Determine base cache directory.
|
||||
@@ -423,11 +685,15 @@ async fn download_track_offline(
|
||||
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// Already cached — skip re-download.
|
||||
// Already cached — skip re-download (no semaphore needed).
|
||||
if file_path.exists() {
|
||||
return Ok(path_str);
|
||||
}
|
||||
|
||||
// Acquire a download slot. The permit is held for the duration of the HTTP transfer
|
||||
// and released automatically when this function returns (success or error).
|
||||
let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
@@ -437,8 +703,14 @@ async fn download_track_offline(
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
tokio::fs::write(&file_path, &bytes)
|
||||
|
||||
// Stream directly to a .part file; rename on success to avoid partial files.
|
||||
let part_path = file_path.with_extension(format!("{suffix}.part"));
|
||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Err(e);
|
||||
}
|
||||
tokio::fs::rename(&part_path, &file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -556,6 +828,378 @@ fn resolve_hot_cache_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the current Linux system is Arch-based
|
||||
/// (checks /etc/arch-release and /etc/os-release).
|
||||
#[tauri::command]
|
||||
fn check_arch_linux() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::path::Path::new("/etc/arch-release").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.starts_with("id=arch") { return true; }
|
||||
if lower.starts_with("id_like=") && lower.contains("arch") { return true; }
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ false }
|
||||
}
|
||||
|
||||
/// Progress payload emitted during an update binary download.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateDownloadProgress {
|
||||
bytes: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Downloads an update installer/package to the user's Downloads folder.
|
||||
/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms.
|
||||
/// Returns the final absolute file path on success.
|
||||
#[tauri::command]
|
||||
async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const EMIT_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?;
|
||||
let dest_path = dest_dir.join(&filename);
|
||||
let part_path = dest_dir.join(format!("{}.part", filename));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(3600))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
let total = response.content_length();
|
||||
|
||||
let result: Result<u64, String> = async {
|
||||
let mut file = tokio::fs::File::create(&part_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut bytes_done: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||
bytes_done += chunk.len() as u64;
|
||||
|
||||
if last_emit.elapsed() >= EMIT_INTERVAL {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total,
|
||||
});
|
||||
last_emit = Instant::now();
|
||||
}
|
||||
}
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
Ok(bytes_done)
|
||||
}.await;
|
||||
|
||||
match result {
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(bytes_done) => {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total: Some(bytes_done),
|
||||
});
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(dest_path.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches synced lyrics from Netease Cloud Music for a given artist + title.
|
||||
/// Performs a track search, then fetches the LRC string for the best match.
|
||||
/// Returns `None` if no match or no lyrics are found.
|
||||
#[tauri::command]
|
||||
async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let query = format!("{} {}", artist, title);
|
||||
let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")];
|
||||
let search: serde_json::Value = client
|
||||
.post("https://music.163.com/api/search/get")
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let song_id = match search["result"]["songs"][0]["id"].as_i64() {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let lyrics: serde_json::Value = client
|
||||
.get(format!(
|
||||
"https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1",
|
||||
song_id
|
||||
))
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string();
|
||||
Ok(if lrc.is_empty() { None } else { Some(lrc) })
|
||||
}
|
||||
|
||||
/// Reads embedded synced / unsynced lyrics from a local audio file.
|
||||
///
|
||||
/// Priority order:
|
||||
/// MP3 → ID3v2 SYLT (synchronized, ms timestamps) → ID3v2 USLT (plain)
|
||||
/// FLAC → Vorbis SYNCEDLYRICS (LRC string) → Vorbis LYRICS (plain)
|
||||
///
|
||||
/// Returns a standard LRC string (`[mm:ss.cc]line\n…`) for synced lyrics,
|
||||
/// or plain text for unsynced lyrics. Returns `None` when no lyrics are found.
|
||||
/// Errors are silenced and mapped to `None` so the frontend falls through to the
|
||||
/// next lyrics source without crashing.
|
||||
#[tauri::command]
|
||||
fn get_embedded_lyrics(path: String) -> Option<String> {
|
||||
use lofty::file::FileType;
|
||||
use lofty::prelude::*;
|
||||
use lofty::probe::Probe;
|
||||
|
||||
let fpath = std::path::Path::new(&path);
|
||||
if !fpath.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Detect file type from magic bytes only — no full tag read yet.
|
||||
// guess_file_type() consumes self and returns Self, so reassign.
|
||||
let probe = Probe::open(fpath).ok()?;
|
||||
let probe = probe.guess_file_type().ok()?;
|
||||
let file_type = probe.file_type();
|
||||
|
||||
// ── MP3 / MPEG: use the `id3` crate for SYLT / USLT ─────────────────────
|
||||
// lofty's MpegFile::id3v2_tag field is pub(crate) — not accessible here.
|
||||
// The `id3` crate exposes a clean public API for typed ID3v2 frames.
|
||||
if matches!(file_type, Some(FileType::Mpeg)) {
|
||||
use id3::{Content, Tag as Id3Tag};
|
||||
|
||||
if let Ok(tag) = Id3Tag::read_from_path(fpath) {
|
||||
// 1. SYLT — millisecond-timestamped synced lyrics.
|
||||
for frame in tag.frames() {
|
||||
if frame.id() != "SYLT" {
|
||||
continue;
|
||||
}
|
||||
if let Content::SynchronisedLyrics(sylt) = frame.content() {
|
||||
// Only accept millisecond timestamps — MPEG-frame-based
|
||||
// timestamps can't be converted to wall-clock seconds.
|
||||
if sylt.timestamp_format != id3::frame::TimestampFormat::Ms {
|
||||
continue;
|
||||
}
|
||||
let lrc: String = sylt
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|(ms, text)| {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mins = ms / 60_000;
|
||||
let secs = (ms % 60_000) / 1_000;
|
||||
let cs = (ms % 1_000) / 10;
|
||||
// [mm:ss.cc] matches parseLrc's /\d+(?:\.\d*)?/ regex
|
||||
Some(format!("[{:02}:{:02}.{:02}]{}\n", mins, secs, cs, t))
|
||||
})
|
||||
.collect();
|
||||
if !lrc.is_empty() {
|
||||
return Some(lrc.trim_end().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. USLT — unsynchronized lyrics, plain-text fallback.
|
||||
for frame in tag.frames() {
|
||||
if frame.id() != "USLT" {
|
||||
continue;
|
||||
}
|
||||
if let Content::Lyrics(uslt) = frame.content() {
|
||||
let text = uslt.text.trim();
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None; // MPEG file but no usable lyrics found
|
||||
}
|
||||
|
||||
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
|
||||
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
|
||||
// It is not a known lofty ItemKey, so access it via ItemKey::Unknown.
|
||||
let tagged = probe.read().ok()?;
|
||||
for tag in tagged.tags() {
|
||||
if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) {
|
||||
let lrc = lrc.trim();
|
||||
if !lrc.is_empty() {
|
||||
return Some(lrc.to_owned());
|
||||
}
|
||||
}
|
||||
if let Some(plain) = tag.get_string(&ItemKey::Lyrics) {
|
||||
let plain = plain.trim();
|
||||
if !plain.is_empty() {
|
||||
return Some(plain.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Opens a directory in the OS file manager (Explorer / Finder / Nautilus).
|
||||
/// Uses platform-specific process spawning — tauri-plugin-shell's open() only
|
||||
/// allows https:// URLs per the capability scope and fails silently for paths.
|
||||
#[tauri::command]
|
||||
fn open_folder(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer.exe")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Progress payload emitted to the frontend during a ZIP download.
|
||||
/// `total` is `None` when the server doesn't send a `Content-Length` header
|
||||
/// (Navidrome on-the-fly ZIPs).
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ZipProgress {
|
||||
id: String,
|
||||
bytes: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Downloads a server-generated ZIP (album/playlist) directly to disk via streaming.
|
||||
/// Emits `download:zip:progress` events every 500 ms so the frontend can show
|
||||
/// live MB-counter without holding any binary data in the WebView process.
|
||||
/// Returns the final destination path on success.
|
||||
#[tauri::command]
|
||||
async fn download_zip(
|
||||
id: String,
|
||||
url: String,
|
||||
dest_path: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const EMIT_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(7200)) // up to 2 h for large on-the-fly ZIPs
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
let total = response.content_length(); // None for Navidrome on-the-fly ZIPs
|
||||
let part_path = format!("{dest_path}.part");
|
||||
|
||||
// Stream to .part file; rename on success, delete on error.
|
||||
let result: Result<u64, String> = async {
|
||||
let mut file = tokio::fs::File::create(&part_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut bytes_done: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||
bytes_done += chunk.len() as u64;
|
||||
|
||||
if last_emit.elapsed() >= EMIT_INTERVAL {
|
||||
let _ = app.emit("download:zip:progress", ZipProgress {
|
||||
id: id.clone(),
|
||||
bytes: bytes_done,
|
||||
total,
|
||||
});
|
||||
last_emit = Instant::now();
|
||||
}
|
||||
}
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
Ok(bytes_done)
|
||||
}.await;
|
||||
|
||||
match result {
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(bytes_done) => {
|
||||
// Final emission so the frontend sees 100 % (or final MB count).
|
||||
let _ = app.emit("download:zip:progress", ZipProgress {
|
||||
id: id.clone(),
|
||||
bytes: bytes_done,
|
||||
total: Some(bytes_done),
|
||||
});
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(dest_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HotCacheDownloadResult {
|
||||
@@ -605,12 +1249,21 @@ async fn download_track_hot_cache(
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
tokio::fs::write(&file_path, &bytes)
|
||||
|
||||
// Stream directly to a .part file; rename on success to avoid partial files.
|
||||
let part_path = file_path.with_extension(format!("{suffix}.part"));
|
||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Err(e);
|
||||
}
|
||||
tokio::fs::rename(&part_path, &file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let size = bytes.len() as u64;
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
Ok(HotCacheDownloadResult {
|
||||
path: path_str,
|
||||
size,
|
||||
@@ -803,6 +1456,63 @@ fn stop_audio_engine(app: &tauri::AppHandle) {
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
}
|
||||
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
|
||||
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
|
||||
/// set by the compositor / DE.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_tiling_wm() -> bool {
|
||||
// Direct compositor signatures (most reliable).
|
||||
let direct = [
|
||||
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
|
||||
"SWAYSOCK", // Sway
|
||||
"I3SOCK", // i3
|
||||
]
|
||||
.iter()
|
||||
.any(|&var| std::env::var_os(var).is_some());
|
||||
|
||||
if direct {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check XDG_CURRENT_DESKTOP for known tiling WMs.
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop = desktop.to_lowercase();
|
||||
let tiling_wms = [
|
||||
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
|
||||
];
|
||||
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set.
|
||||
/// The frontend uses this to apply a CSS class that swaps out GPU-only effects
|
||||
/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents.
|
||||
#[tauri::command]
|
||||
fn no_compositing_mode() -> bool {
|
||||
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Tauri command: lets the frontend know whether we're running under a tiling
|
||||
/// WM so it can decide whether to render the custom TitleBar component.
|
||||
#[tauri::command]
|
||||
fn is_tiling_wm_cmd() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
is_tiling_wm()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
@@ -810,6 +1520,7 @@ pub fn run() {
|
||||
.manage(audio_engine)
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
||||
.manage(TrayState::default())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
@@ -826,6 +1537,18 @@ pub fn run() {
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// ── Custom title bar on Linux ─────────────────────────────────
|
||||
// Remove OS window decorations on all Linux so the React TitleBar
|
||||
// can take over. The frontend checks is_tiling_wm() to decide
|
||||
// whether to actually render the TitleBar (hidden on tiling WMs).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.set_decorations(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System tray ───────────────────────────────────────────────
|
||||
// Always build on startup; the frontend calls toggle_tray_icon(false)
|
||||
// immediately after load if the user has disabled the tray icon.
|
||||
@@ -924,6 +1647,24 @@ pub fn run() {
|
||||
app.manage(MprisControls::new(maybe_controls));
|
||||
}
|
||||
|
||||
// ── Windows Taskbar Thumbnail Toolbar ────────────────────────
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
if let Ok(hwnd) = w.hwnd() {
|
||||
taskbar_win::init(app.handle(), hwnd.0 as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio device-change watcher ───────────────────────────────
|
||||
{
|
||||
use tauri::Manager;
|
||||
let engine = app.state::<audio::AudioEngine>();
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -951,6 +1692,9 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
mpris_set_metadata,
|
||||
@@ -969,6 +1713,10 @@ pub fn run() {
|
||||
audio::audio_play_radio,
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_list_devices,
|
||||
audio::audio_canonicalize_selected_device,
|
||||
audio::audio_default_output_device_name,
|
||||
audio::audio_set_device,
|
||||
audio::audio_chain_preload,
|
||||
discord::discord_update_presence,
|
||||
discord::discord_clear_presence,
|
||||
@@ -980,6 +1728,9 @@ pub fn run() {
|
||||
search_radio_browser,
|
||||
get_top_radio_stations,
|
||||
fetch_url_bytes,
|
||||
fetch_json_url,
|
||||
fetch_icy_metadata,
|
||||
resolve_stream_url,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
@@ -988,6 +1739,15 @@ pub fn run() {
|
||||
delete_hot_cache_track,
|
||||
purge_hot_cache,
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
check_arch_linux,
|
||||
download_update,
|
||||
open_folder,
|
||||
get_embedded_lyrics,
|
||||
fetch_netease_lyrics,
|
||||
#[cfg(target_os = "windows")]
|
||||
taskbar_win::update_taskbar_icon,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
@@ -2,5 +2,16 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
|
||||
// Users can still override by setting these vars before launch.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
if std::env::var("GDK_BACKEND").is_err() {
|
||||
std::env::set_var("GDK_BACKEND", "x11");
|
||||
}
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
|
||||
}
|
||||
}
|
||||
psysonic_lib::run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Windows Taskbar Thumbnail Toolbar (ITaskbarList3::ThumbBarAddButtons).
|
||||
//!
|
||||
//! Adds Prev / Play-Pause / Next buttons to the taskbar thumbnail preview.
|
||||
//! Button clicks are intercepted via SetWindowSubclass and routed to the same
|
||||
//! `media:prev`, `media:play-pause`, `media:next` events as souvlaki / tray.
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
},
|
||||
UI::{
|
||||
Shell::{
|
||||
DefSubclassProc, ITaskbarList3, RemoveWindowSubclass, SetWindowSubclass,
|
||||
TaskbarList, THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBN_CLICKED,
|
||||
THB_FLAGS, THB_ICON, THB_TOOLTIP,
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||
WM_COMMAND, WM_NCDESTROY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Embedded ICO assets ──────────────────────────────────────────────────────
|
||||
|
||||
static PREV_ICO: &[u8] = include_bytes!("../icons/windows/prev.ico");
|
||||
static PLAY_ICO: &[u8] = include_bytes!("../icons/windows/play.ico");
|
||||
static PAUSE_ICO: &[u8] = include_bytes!("../icons/windows/pause.ico");
|
||||
static NEXT_ICO: &[u8] = include_bytes!("../icons/windows/next.ico");
|
||||
|
||||
// Button IDs — arbitrary u32 values, must fit in WPARAM low-word.
|
||||
const BTN_PREV: u32 = 0xE001;
|
||||
const BTN_PLAY: u32 = 0xE002;
|
||||
const BTN_NEXT: u32 = 0xE003;
|
||||
|
||||
// Unique subclass ID.
|
||||
const SUBCLASS_ID: usize = 0xC0DE_7A8B;
|
||||
|
||||
// Raw pointers kept as atomics so `update_taskbar_icon` can reach the
|
||||
// COM object and icons without managed state.
|
||||
static TASKBAR_PTR: AtomicIsize = AtomicIsize::new(0);
|
||||
static HWND_VAL: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// All four HICONs stored for WM_NCDESTROY cleanup and play/pause swapping.
|
||||
static HICON_PREV: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// ── ICO resource loader ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
||||
///
|
||||
/// Parses the ICO directory to pick the entry with the highest bit depth
|
||||
/// (32 bpp = true-colour + alpha), then passes the image bits directly to
|
||||
/// `CreateIconFromResourceEx`.
|
||||
///
|
||||
/// Note: `LookupIconIdFromDirectoryEx` operates on Win32 *resource* group-icon
|
||||
/// format (GRPICONDIR), not raw `.ico` files, so we parse the ICO header ourselves.
|
||||
unsafe fn load_icon_from_memory(bytes: &[u8]) -> HICON {
|
||||
// ICO file layout:
|
||||
// ICONDIR : reserved(2) + type(2) + count(2)
|
||||
// ICONDIRENTRY[] : width(1) height(1) color_count(1) reserved(1)
|
||||
// planes(2) bit_count(2) bytes_in_res(4) image_offset(4)
|
||||
if bytes.len() < 6 {
|
||||
return HICON::default();
|
||||
}
|
||||
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
|
||||
if count == 0 || bytes.len() < 6 + count * 16 {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
// Pick the entry with the highest bit depth; 32 bpp carries alpha.
|
||||
let mut best_idx = 0usize;
|
||||
let mut best_bpp = 0u16;
|
||||
for i in 0..count {
|
||||
let base = 6 + i * 16;
|
||||
let bpp = u16::from_le_bytes([bytes[base + 6], bytes[base + 7]]);
|
||||
if bpp >= best_bpp {
|
||||
best_bpp = bpp;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = &bytes[6 + best_idx * 16..];
|
||||
let img_size = u32::from_le_bytes(entry[8..12].try_into().unwrap_or([0; 4]));
|
||||
let img_offset = u32::from_le_bytes(entry[12..16].try_into().unwrap_or([0; 4])) as usize;
|
||||
|
||||
if img_size == 0 || img_offset + img_size as usize > bytes.len() {
|
||||
return HICON::default();
|
||||
}
|
||||
|
||||
CreateIconFromResourceEx(
|
||||
&bytes[img_offset..img_offset + img_size as usize],
|
||||
true, // fIcon = TRUE
|
||||
0x0003_0000, // dwVer = 3.0 (required by the API)
|
||||
0, 0, // cxDesired / cyDesired — 0 lets the system choose
|
||||
LR_DEFAULTCOLOR,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Button descriptors ───────────────────────────────────────────────────────
|
||||
|
||||
fn copy_tip(dest: &mut [u16], src: &str) {
|
||||
let wide: Vec<u16> = src.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let len = wide.len().min(dest.len());
|
||||
dest[..len].copy_from_slice(&wide[..len]);
|
||||
}
|
||||
|
||||
unsafe fn make_buttons(
|
||||
h_prev: HICON,
|
||||
h_play: HICON,
|
||||
h_next: HICON,
|
||||
) -> [THUMBBUTTON; 3] {
|
||||
let mask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
let flags = THUMBBUTTONFLAGS(0); // THBF_ENABLED
|
||||
|
||||
let mut prev = THUMBBUTTON::default();
|
||||
prev.dwMask = mask; prev.iId = BTN_PREV;
|
||||
prev.hIcon = h_prev; prev.dwFlags = flags;
|
||||
copy_tip(&mut prev.szTip, "Previous");
|
||||
|
||||
let mut play = THUMBBUTTON::default();
|
||||
play.dwMask = mask; play.iId = BTN_PLAY;
|
||||
play.hIcon = h_play; play.dwFlags = flags;
|
||||
copy_tip(&mut play.szTip, "Play");
|
||||
|
||||
let mut next = THUMBBUTTON::default();
|
||||
next.dwMask = mask; next.iId = BTN_NEXT;
|
||||
next.hIcon = h_next; next.dwFlags = flags;
|
||||
copy_tip(&mut next.szTip, "Next");
|
||||
|
||||
[prev, play, next]
|
||||
}
|
||||
|
||||
// ── WndProc subclass ─────────────────────────────────────────────────────────
|
||||
|
||||
struct SubclassData {
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_uid: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
if msg == WM_COMMAND {
|
||||
let hi = (wparam.0 >> 16) as u32;
|
||||
let lo = (wparam.0 & 0xFFFF) as u32;
|
||||
if hi == THBN_CLICKED as u32 {
|
||||
if data != 0 {
|
||||
let state = &*(data as *const SubclassData);
|
||||
let _ = match lo {
|
||||
x if x == BTN_PREV => state.app.emit("media:prev", ()),
|
||||
x if x == BTN_PLAY => state.app.emit("media:play-pause", ()),
|
||||
x if x == BTN_NEXT => state.app.emit("media:next", ()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
return LRESULT(0);
|
||||
}
|
||||
}
|
||||
|
||||
if msg == WM_NCDESTROY {
|
||||
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
|
||||
if data != 0 {
|
||||
drop(Box::from_raw(data as *mut SubclassData));
|
||||
}
|
||||
let raw = TASKBAR_PTR.swap(0, Ordering::SeqCst);
|
||||
if raw != 0 {
|
||||
drop(Box::from_raw(raw as *mut ITaskbarList3));
|
||||
}
|
||||
HWND_VAL.store(0, Ordering::SeqCst);
|
||||
// Destroy all stored HICONs.
|
||||
for cell in [&HICON_PREV, &HICON_PLAY, &HICON_PAUSE, &HICON_NEXT] {
|
||||
let h = cell.swap(0, Ordering::SeqCst);
|
||||
if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); }
|
||||
}
|
||||
}
|
||||
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
// ── Public init ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let taskbar: ITaskbarList3 = match CoCreateInstance(
|
||||
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => { eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
||||
};
|
||||
|
||||
if let Err(e) = taskbar.HrInit() {
|
||||
eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let h_prev = load_icon_from_memory(PREV_ICO);
|
||||
let h_play = load_icon_from_memory(PLAY_ICO);
|
||||
let h_pause = load_icon_from_memory(PAUSE_ICO);
|
||||
let h_next = load_icon_from_memory(NEXT_ICO);
|
||||
|
||||
// Store all HICONs for cleanup and play/pause swapping.
|
||||
HICON_PREV .store(h_prev .0 as isize, Ordering::SeqCst);
|
||||
HICON_PLAY .store(h_play .0 as isize, Ordering::SeqCst);
|
||||
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
||||
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
||||
|
||||
let mut buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &mut buttons) {
|
||||
eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = Box::into_raw(Box::new(taskbar));
|
||||
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
||||
HWND_VAL .store(hwnd_raw, Ordering::SeqCst);
|
||||
|
||||
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
||||
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
||||
eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
||||
drop(Box::from_raw(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri command ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_taskbar_icon(is_playing: bool) {
|
||||
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
||||
let hwnd_raw = HWND_VAL .load(Ordering::SeqCst);
|
||||
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
||||
|
||||
let icon_raw = if is_playing {
|
||||
HICON_PAUSE.load(Ordering::SeqCst)
|
||||
} else {
|
||||
HICON_PLAY.load(Ordering::SeqCst)
|
||||
};
|
||||
if icon_raw == 0 { return; }
|
||||
|
||||
unsafe {
|
||||
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
let mut btn = THUMBBUTTON::default();
|
||||
btn.dwMask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
||||
btn.iId = BTN_PLAY;
|
||||
btn.hIcon = HICON(icon_raw as *mut _);
|
||||
btn.dwFlags = THUMBBUTTONFLAGS(0);
|
||||
copy_tip(&mut btn.szTip, if is_playing { "Pause" } else { "Play" });
|
||||
|
||||
let mut btns = [btn];
|
||||
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &mut btns) {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.3",
|
||||
"version": "1.34.10",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -21,6 +21,7 @@ import ArtistDetail from './pages/ArtistDetail';
|
||||
import NewReleases from './pages/NewReleases';
|
||||
import Favorites from './pages/Favorites';
|
||||
import RandomMix from './pages/RandomMix';
|
||||
import RandomLanding from './pages/RandomLanding';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
@@ -34,6 +35,7 @@ import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -43,7 +45,6 @@ import { DragDropProvider } from './contexts/DragDropContext';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import Genres from './pages/Genres';
|
||||
@@ -51,18 +52,23 @@ import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import { IS_LINUX } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { getMusicFolders } from './api/subsonic';
|
||||
import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore } from './store/keybindingsStore';
|
||||
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
@@ -73,6 +79,34 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
const [isTilingWm, setIsTilingWm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('no_compositing_mode').then(noComp => {
|
||||
if (noComp) document.documentElement.classList.add('no-compositing');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const win = getCurrentWindow();
|
||||
// Check initial state (e.g. app launched maximised / already fullscreen).
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
let unlisten: (() => void) | undefined;
|
||||
// onResized fires on every size change, including fullscreen enter/exit on
|
||||
// all platforms. We re-query isFullscreen() rather than inferring from
|
||||
// the size so the flag is always accurate regardless of platform quirks.
|
||||
win.onResized(() => {
|
||||
win.isFullscreen().then(setIsWindowFullscreen).catch(() => {});
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
@@ -87,24 +121,47 @@ function AppShell() {
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
const enabled = isTilingWm ? false : !useCustomTitlebar;
|
||||
invoke('set_window_decorations', { enabled }).catch(() => {});
|
||||
}, [useCustomTitlebar, isTilingWm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
const serverAtStart = activeServerId;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
|
||||
try {
|
||||
const folders = await getMusicFolders();
|
||||
if (!cancelled) setMusicFolders(folders);
|
||||
if (stillThisServer()) setMusicFolders(folders);
|
||||
} catch {
|
||||
if (!cancelled) setMusicFolders([]);
|
||||
if (stillThisServer()) setMusicFolders([]);
|
||||
}
|
||||
try {
|
||||
const level = await probeEntityRatingSupport();
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, level);
|
||||
} catch {
|
||||
if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders]);
|
||||
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
|
||||
|
||||
// Reset scroll position on route change
|
||||
useEffect(() => {
|
||||
document.querySelector('.content-body')?.scrollTo({ top: 0 });
|
||||
}, [location.pathname]);
|
||||
|
||||
// Auto-navigate to offline library when no connection but cached content exists
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
@@ -248,16 +305,19 @@ function AppShell() {
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="app-shell"
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
data-fullscreen={isWindowFullscreen || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
@@ -280,27 +340,21 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
{connStatus === 'disconnected' && hasOfflineContent && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
|
||||
{connStatus === 'disconnected' && !hasOfflineContent && (
|
||||
<OfflineOverlay
|
||||
serverName={serverName}
|
||||
onRetry={connRetry}
|
||||
isChecking={connRetrying}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/random-albums" element={<RandomAlbums />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random-mix" element={<RandomMix />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
@@ -315,6 +369,7 @@ function AppShell() {
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -346,10 +401,56 @@ function AppShell() {
|
||||
|
||||
// Media key + tray event handler
|
||||
function TauriEventBridge() {
|
||||
const navigate = useNavigate();
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
|
||||
// ZIP download progress events from Rust
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => {
|
||||
useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total);
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
|
||||
// The Rust device-watcher has already reopened the stream on the new device
|
||||
// and dropped the old Sink, so we just need to restart playback.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', () => {
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
// Paused: clear warm-pause flag so the next resume uses the cold path
|
||||
// (audio_play + seek) which creates a new Sink on the new device.
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Pinned output device was unplugged — Rust already fell back to system default.
|
||||
// Clear the stored device so the Settings dropdown resets to "System Default".
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-reset', () => {
|
||||
useAuthStore.getState().setAudioOutputDevice(null);
|
||||
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
playTrack(currentTrack);
|
||||
} else {
|
||||
resetAudioPause();
|
||||
}
|
||||
}).then(u => { unlisten = u; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -362,15 +463,18 @@ function TauriEventBridge() {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
// Global shortcuts use modifier combos — skip in-app bindings for those
|
||||
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const chord = buildInAppBinding(e);
|
||||
if (chord) {
|
||||
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
|
||||
if (registered.includes(chord)) return;
|
||||
}
|
||||
|
||||
const { bindings } = useKeybindingsStore.getState();
|
||||
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
|
||||
|
||||
const action = (Object.entries(bindings) as [string, string | null][])
|
||||
.find(([, code]) => code === e.code)?.[0];
|
||||
.find(([, b]) => matchInAppBinding(e, b))?.[0];
|
||||
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
@@ -383,15 +487,22 @@ function TauriEventBridge() {
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
|
||||
const dur = s.currentTrack?.duration ?? 0;
|
||||
if (!dur) break;
|
||||
seek(Math.min(1, (s.currentTime + 10) / dur));
|
||||
break;
|
||||
}
|
||||
case 'seek-backward': {
|
||||
const s = usePlayerStore.getState();
|
||||
seek(Math.max(0, s.currentTime - 10));
|
||||
const dur = s.currentTrack?.duration ?? 0;
|
||||
if (!dur) break;
|
||||
seek(Math.max(0, (s.currentTime - 10) / dur));
|
||||
break;
|
||||
}
|
||||
case 'toggle-queue': toggleQueue(); break;
|
||||
case 'open-folder-browser':
|
||||
navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } });
|
||||
break;
|
||||
case 'fullscreen-player': toggleFullscreen(); break;
|
||||
case 'native-fullscreen': {
|
||||
const win = getCurrentWindow();
|
||||
@@ -468,18 +579,33 @@ function TauriEventBridge() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const setUiScale = useFontStore(s => s.setUiScale);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
|
||||
// Reset any stored non-100% value so users aren't stuck at a broken scale.
|
||||
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
|
||||
useEffect(() => {
|
||||
if (uiScale !== 1.0) setUiScale(1.0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = String(uiScale);
|
||||
}, [uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
@@ -556,6 +682,7 @@ export default function App() {
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
<ZipDownloadOverlay />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ─── AzuraCast API types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AzuraCastSong {
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
art?: string;
|
||||
text?: string; // "Artist - Title" combined
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlayingTrack {
|
||||
song: AzuraCastSong;
|
||||
duration: number; // seconds
|
||||
elapsed: number; // seconds played so far
|
||||
remaining: number; // seconds remaining
|
||||
played_at?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastListeners {
|
||||
current: number;
|
||||
unique?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface AzuraCastNowPlaying {
|
||||
now_playing: AzuraCastNowPlayingTrack;
|
||||
playing_next?: { song: AzuraCastSong } | null;
|
||||
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
|
||||
listeners: AzuraCastListeners;
|
||||
station?: { name: string; shortcode: string };
|
||||
}
|
||||
|
||||
// ─── Detection helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
|
||||
*
|
||||
* AzuraCast stream URLs follow the pattern:
|
||||
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
|
||||
*
|
||||
* Returns the candidate API URL or `null` if the pattern doesn't match.
|
||||
*/
|
||||
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(streamUrl);
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
// Expect: ['listen', '<shortcode>', '<file>']
|
||||
if (parts.length >= 2 && parts[0] === 'listen') {
|
||||
const shortcode = parts[1];
|
||||
return `${u.origin}/api/nowplaying/${shortcode}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
|
||||
* API endpoint and return the canonical URL to use.
|
||||
*
|
||||
* Accepts:
|
||||
* - https://<host>/api/nowplaying → all stations, we use as-is
|
||||
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
|
||||
*/
|
||||
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(homepageUrl);
|
||||
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
|
||||
return homepageUrl;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
|
||||
* the Rust backend). Returns `null` if the request fails or the response
|
||||
* does not look like a valid AzuraCast payload.
|
||||
*
|
||||
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
|
||||
* first item in the array is returned. Otherwise the single-object form is
|
||||
* used directly.
|
||||
*/
|
||||
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
|
||||
try {
|
||||
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// If the response is an array (all-stations endpoint), take the first item.
|
||||
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
|
||||
const np = obj as Record<string, unknown>;
|
||||
// Minimal validation: must have `now_playing` with a `song` inside.
|
||||
if (
|
||||
np.now_playing &&
|
||||
typeof np.now_playing === 'object' &&
|
||||
(np.now_playing as Record<string, unknown>).song
|
||||
) {
|
||||
return np as unknown as AzuraCastNowPlaying;
|
||||
}
|
||||
} catch {
|
||||
// Network error, JSON parse error, etc.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export async function fetchLyrics(
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
|
||||
// \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches.
|
||||
// parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly.
|
||||
const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
|
||||
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import md5 from 'md5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { version } from '../../package.json';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
|
||||
// ─── Secure random salt ────────────────────────────────────────
|
||||
function secureRandomSalt(): string {
|
||||
@@ -64,6 +69,15 @@ export interface SubsonicAlbum {
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
export interface SubsonicOpenArtistRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
@@ -79,6 +93,11 @@ export interface SubsonicSong {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
userRating?: number;
|
||||
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||
albumUserRating?: number;
|
||||
artistUserRating?: number;
|
||||
artists?: SubsonicOpenArtistRef[];
|
||||
albumArtists?: SubsonicOpenArtistRef[];
|
||||
// Audio technical info
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
@@ -141,6 +160,8 @@ export interface SubsonicArtist {
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
@@ -165,6 +186,68 @@ export interface SubsonicArtistInfo {
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
export interface SubsonicDirectoryEntry {
|
||||
id: string;
|
||||
parent?: string;
|
||||
title: string;
|
||||
isDir: boolean;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
track?: number;
|
||||
year?: number;
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
size?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicDirectory {
|
||||
id: string;
|
||||
parent?: string;
|
||||
name: string;
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
|
||||
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
||||
'getMusicDirectory.view',
|
||||
{ id },
|
||||
);
|
||||
const dir = data.directory;
|
||||
const raw = dir.child;
|
||||
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
||||
}
|
||||
|
||||
/** Returns the top-level artist/directory entries for a music folder root.
|
||||
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs —
|
||||
* use getIndexes.view with musicFolderId instead. */
|
||||
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
||||
type IndexArtist = { id: string; name: string; coverArt?: string };
|
||||
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
|
||||
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
|
||||
'getIndexes.view',
|
||||
{ musicFolderId },
|
||||
);
|
||||
const raw = data.indexes?.index;
|
||||
if (!raw) return [];
|
||||
const indices = Array.isArray(raw) ? raw : [raw];
|
||||
const entries: SubsonicDirectoryEntry[] = [];
|
||||
for (const idx of indices) {
|
||||
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
|
||||
for (const a of artists) {
|
||||
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||
'getMusicFolders.view',
|
||||
@@ -187,8 +270,14 @@ export async function ping(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
/** Test a connection with explicit credentials — does NOT depend on store state. */
|
||||
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
|
||||
export async function pingWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
@@ -199,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
return data?.status === 'ok';
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function restBaseFromUrl(serverUrl: string): string {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
return `${base}/rest`;
|
||||
}
|
||||
|
||||
async function apiWithCredentials<T>(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
|
||||
/**
|
||||
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
|
||||
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
|
||||
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
|
||||
*/
|
||||
export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
if (songs.length === 0) return 'skipped';
|
||||
|
||||
let anyError = false;
|
||||
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
|
||||
try {
|
||||
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
if (list.some(s => s.id !== song.id)) return 'ok';
|
||||
} catch {
|
||||
anyError = true;
|
||||
}
|
||||
}
|
||||
return anyError ? 'error' : 'empty';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
|
||||
export function scheduleInstantMixProbeForServer(
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
identity: SubsonicServerIdentity,
|
||||
): void {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'random',
|
||||
@@ -231,6 +416,68 @@ export async function getAlbumList(
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
||||
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
||||
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
||||
*/
|
||||
let scopedLibraryAlbumIdCache: {
|
||||
serverId: string;
|
||||
folderId: string;
|
||||
filterVersion: number;
|
||||
ids: Set<string>;
|
||||
} | null = null;
|
||||
|
||||
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
||||
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId];
|
||||
if (folder === undefined || folder === 'all') {
|
||||
scopedLibraryAlbumIdCache = null;
|
||||
return null;
|
||||
}
|
||||
const hit = scopedLibraryAlbumIdCache;
|
||||
if (
|
||||
hit &&
|
||||
hit.serverId === activeServerId &&
|
||||
hit.folderId === folder &&
|
||||
hit.filterVersion === musicLibraryFilterVersion
|
||||
) {
|
||||
return hit.ids;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pageSize = 500;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
for (const a of albums) ids.add(a.id);
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
if (offset > 500_000) break;
|
||||
}
|
||||
scopedLibraryAlbumIdCache = {
|
||||
serverId: activeServerId,
|
||||
folderId: folder,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
ids,
|
||||
};
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||
const allowed = await albumIdsInActiveLibraryScope();
|
||||
if (!allowed || allowed.size === 0) return songs;
|
||||
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||
}
|
||||
|
||||
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||
function similarSongsRequestCount(desired: number): number {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||
if (f === undefined || f === 'all') return desired;
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
|
||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||
if (genre) params.genre = genre;
|
||||
@@ -253,6 +500,246 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
|
||||
|
||||
function getCachedRating(key: string): number | undefined | null {
|
||||
const entry = ratingCache.get(key);
|
||||
if (!entry) return null; // cache miss
|
||||
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function setCachedRating(key: string, value: number | undefined): void {
|
||||
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
|
||||
function parseEntityUserRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||
export async function prefetchArtistUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`artist:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= uncached.length) return;
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseEntityUserRating(artist.userRating);
|
||||
setCachedRating(`artist:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, uncached.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
||||
export async function prefetchAlbumUserRatings(
|
||||
ids: string[],
|
||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`album:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= uncached.length) return;
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseEntityUserRating(album.userRating);
|
||||
setCachedRating(`album:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, uncached.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
|
||||
export interface StatisticsLibraryAggregates {
|
||||
playtimeSec: number;
|
||||
albumsCounted: number;
|
||||
songsCounted: number;
|
||||
capped: boolean;
|
||||
genres: SubsonicGenre[];
|
||||
}
|
||||
|
||||
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
|
||||
function statisticsPageCacheKey(prefix: string): string | null {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
|
||||
const folderPart = folder === 'all' ? 'all' : folder;
|
||||
return `${prefix}:${activeServerId}:${folderPart}`;
|
||||
}
|
||||
|
||||
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
|
||||
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
|
||||
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
|
||||
*/
|
||||
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
|
||||
const key = statisticsPageCacheKey('statsAgg');
|
||||
if (key) {
|
||||
const hit = statisticsAggregatesCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
|
||||
let playtimeSec = 0;
|
||||
let albumsCounted = 0;
|
||||
let songsCounted = 0;
|
||||
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
|
||||
const pageSize = 500;
|
||||
const capped = false;
|
||||
let offset = 0;
|
||||
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
|
||||
for (;;) {
|
||||
try {
|
||||
const albums = await nextPage;
|
||||
for (const a of albums) {
|
||||
playtimeSec += a.duration ?? 0;
|
||||
albumsCounted += 1;
|
||||
const sc = a.songCount ?? 0;
|
||||
songsCounted += sc;
|
||||
const label = (a.genre?.trim()) ? a.genre.trim() : '';
|
||||
let g = genreAgg.get(label);
|
||||
if (!g) {
|
||||
g = { songCount: 0, albumCount: 0 };
|
||||
genreAgg.set(label, g);
|
||||
}
|
||||
g.songCount += sc;
|
||||
g.albumCount += 1;
|
||||
}
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const genres: SubsonicGenre[] = [...genreAgg.entries()]
|
||||
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
|
||||
.sort((a, b) => b.songCount - a.songCount);
|
||||
|
||||
const result: StatisticsLibraryAggregates = {
|
||||
playtimeSec,
|
||||
albumsCounted,
|
||||
songsCounted,
|
||||
capped,
|
||||
genres,
|
||||
};
|
||||
if (key) {
|
||||
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Recent / frequent / highest album strips + artist count for Statistics. */
|
||||
export interface StatisticsOverviewData {
|
||||
recent: SubsonicAlbum[];
|
||||
frequent: SubsonicAlbum[];
|
||||
highest: SubsonicAlbum[];
|
||||
artistCount: number;
|
||||
}
|
||||
|
||||
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
|
||||
const key = statisticsPageCacheKey('statsOverview');
|
||||
if (key) {
|
||||
const hit = statisticsOverviewCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const [recent, frequent, highest, artists] = await Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
|
||||
getArtists().catch(() => [] as SubsonicArtist[]),
|
||||
]);
|
||||
const result: StatisticsOverviewData = {
|
||||
recent,
|
||||
frequent,
|
||||
highest,
|
||||
artistCount: artists.length,
|
||||
};
|
||||
if (key) {
|
||||
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Format (suffix) histogram from a random sample for Statistics. */
|
||||
export interface StatisticsFormatSample {
|
||||
rows: { format: string; count: number }[];
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
|
||||
|
||||
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
|
||||
const key = statisticsPageCacheKey('statsFormat');
|
||||
if (key) {
|
||||
const hit = statisticsFormatCache.get(key);
|
||||
if (hit && Date.now() < hit.expiresAt) return hit.value;
|
||||
}
|
||||
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const rows = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
|
||||
if (key) {
|
||||
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
@@ -267,15 +754,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> {
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 });
|
||||
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||
const count = options?.similarArtistCount ?? 5;
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 });
|
||||
return data.topSongs?.song ?? [];
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
||||
const topCount = scoped ? 20 : 5;
|
||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
||||
const raw = data.topSongs?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -283,8 +776,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
|
||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count });
|
||||
return data.similarSongs2?.song ?? [];
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs2?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
|
||||
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs?.song;
|
||||
if (!raw) return [];
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
const filtered = await filterSongsToActiveLibrary(list);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -374,6 +885,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
|
||||
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
|
||||
export type EntityRatingSupportLevel = 'track_only' | 'full';
|
||||
|
||||
/**
|
||||
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
|
||||
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
|
||||
*/
|
||||
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
|
||||
try {
|
||||
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
8000,
|
||||
);
|
||||
if (data.openSubsonic === true) return 'full';
|
||||
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
|
||||
return 'track_only';
|
||||
} catch {
|
||||
return 'track_only';
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrobbleSong(id: string, time: number): Promise<void> {
|
||||
try {
|
||||
await api('scrobble.view', { id, time, submission: true });
|
||||
@@ -662,7 +1195,10 @@ export interface SubsonicLyricLine {
|
||||
}
|
||||
|
||||
export interface SubsonicStructuredLyrics {
|
||||
issynced: boolean;
|
||||
/** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */
|
||||
synced?: boolean;
|
||||
/** Legacy / alternate casing used by some older Subsonic-compatible servers. */
|
||||
issynced?: boolean;
|
||||
lang?: string;
|
||||
offset?: number;
|
||||
displayArtist?: string;
|
||||
@@ -684,7 +1220,7 @@ export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredL
|
||||
);
|
||||
const list = data.lyricsList?.structuredLyrics;
|
||||
if (!list || list.length === 0) return null;
|
||||
return list.find(l => l.issynced) ?? list[0];
|
||||
return list.find(l => l.synced || l.issynced) ?? list[0];
|
||||
} catch {
|
||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, HardDriveDownload } from 'lucide-react';
|
||||
import { Play, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -11,9 +11,14 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
function AlbumCard({ album }: AlbumCardProps) {
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -25,20 +30,29 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id); return; }
|
||||
navigate(`/album/${album.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="album-card card"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
if (selectionMode && selectedAlbums.length > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
if (selectionMode || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
@@ -64,20 +78,27 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{isOffline && (
|
||||
{isOffline && !selectionMode && (
|
||||
<div className="album-card-offline-badge" aria-label="Offline available">
|
||||
<HardDriveDownload size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
|
||||
{selected && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
{!selectionMode && (
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => { e.stopPropagation(); playAlbum(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>
|
||||
@@ -87,6 +108,13 @@ function AlbumCard({ album }: AlbumCardProps) {
|
||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||
>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
{showRating && (album.userRating ?? 0) > 0 && (
|
||||
<div className="album-card-rating-row">
|
||||
<span className="album-card-rating-stars">
|
||||
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2 } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -85,6 +87,10 @@ interface AlbumHeaderProps {
|
||||
onEnqueueAll: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -107,6 +113,9 @@ export default function AlbumHeader({
|
||||
onEnqueueAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -186,6 +195,15 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-detail-entity-rating">
|
||||
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="album-detail-actions-mobile">
|
||||
{/* Row 1 — Primary actions */}
|
||||
@@ -225,7 +243,7 @@ export default function AlbumHeader({
|
||||
aria-label={t('albumDetail.artistBio')}
|
||||
data-tooltip={t('albumDetail.artistBio')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
<Highlighter size={16} />
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
@@ -295,7 +313,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { AddToPlaylistSubmenu } from './ContextMenu';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -24,206 +26,108 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={(hover || value) >= n}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
sorted?: boolean;
|
||||
hasVariousArtists: boolean;
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
ratings: Record<string, number>;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
starredSongs: Set<string>;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
}
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
hasVariousArtists,
|
||||
currentTrack,
|
||||
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
|
||||
// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle.
|
||||
|
||||
interface TrackRowProps {
|
||||
song: SubsonicSong;
|
||||
globalIdx: number;
|
||||
visibleCols: readonly ColDef[];
|
||||
gridStyle: React.CSSProperties;
|
||||
currentTrackId: string | null;
|
||||
isPlaying: boolean;
|
||||
ratingValue: number;
|
||||
isStarred: boolean;
|
||||
inSelectMode: boolean;
|
||||
isContextMenuSong: boolean;
|
||||
onPlaySong: (song: SubsonicSong) => void;
|
||||
onRate: (songId: string, rating: number) => void;
|
||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
onContextMenu: AlbumTrackListProps['onContextMenu'];
|
||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||
setContextMenuSongId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const TrackRow = React.memo(function TrackRow({
|
||||
song,
|
||||
globalIdx,
|
||||
visibleCols,
|
||||
gridStyle,
|
||||
currentTrackId,
|
||||
isPlaying,
|
||||
ratings,
|
||||
starredSongs,
|
||||
ratingValue,
|
||||
isStarred,
|
||||
inSelectMode,
|
||||
isContextMenuSong,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
}: AlbumTrackListProps) {
|
||||
onToggleSelect,
|
||||
onDragStart,
|
||||
setContextMenuSongId,
|
||||
}: TrackRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||
const isActive = currentTrackId === song.id;
|
||||
|
||||
// ── 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);
|
||||
if (shift && lastSelectedIdx !== null) {
|
||||
const from = Math.min(lastSelectedIdx, globalIdx);
|
||||
const to = Math.max(lastSelectedIdx, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedIdx(globalIdx);
|
||||
};
|
||||
|
||||
const allSelected = selectedIds.size === songs.length && songs.length > 0;
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = discNums.length > 1;
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const renderCell = (colDef: ColDef) => {
|
||||
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' : ''}`}
|
||||
className={`track-num${isActive ? ' track-num-active' : ''}${isActive && !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); }}
|
||||
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
{currentTrack?.id === song.id && isPlaying && (
|
||||
{isActive && isPlaying && (
|
||||
<span className="track-num-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
@@ -238,27 +142,36 @@ export default function AlbumTrackList({
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist':
|
||||
case 'artist': {
|
||||
const artistRefs = song.artists && song.artists.length > 0
|
||||
? song.artists
|
||||
: [{ id: song.artistId, name: song.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>
|
||||
{artistRefs.map((a, i) => (
|
||||
<React.Fragment key={a.id ?? a.name ?? i}>
|
||||
{i > 0 && <span className="track-artist-sep"> · </span>}
|
||||
<span
|
||||
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
|
||||
>
|
||||
{a.name ?? song.artist}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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' : ''}`}
|
||||
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={e => onToggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -266,7 +179,7 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? song.userRating ?? 0}
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
@@ -295,7 +208,284 @@ export default function AlbumTrackList({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ─────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
className={`track-row track-row-va${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
onToggleSelect(song.id, globalIdx, false);
|
||||
} else if (inSelectMode) {
|
||||
onToggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
onDragStart(song, me);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => renderCell(colDef))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AlbumTrackList({
|
||||
songs,
|
||||
sorted,
|
||||
hasVariousArtists: _hasVariousArtists,
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
ratings,
|
||||
userRatingOverrides,
|
||||
starredSongs,
|
||||
onPlaySong,
|
||||
onRate,
|
||||
onToggleSongStar,
|
||||
onContextMenu,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: AlbumTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
// Selection state lives in selectionStore — only the toggled row re-renders (O(1)).
|
||||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||||
const inSelectMode = selectedCount > 0;
|
||||
const allSelected = selectedCount === songs.length && songs.length > 0;
|
||||
const lastSelectedIdxRef = useRef<number | null>(null);
|
||||
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state ──────────────────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
// Clear selection when the song list changes (different album / filter applied).
|
||||
useEffect(() => {
|
||||
useSelectionStore.getState().clearAll();
|
||||
lastSelectedIdxRef.current = null;
|
||||
}, [songs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Clear selection on click outside the tracklist (header, album art, etc.)
|
||||
useEffect(() => {
|
||||
if (!inSelectMode) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [inSelectMode, tracklistRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
|
||||
|
||||
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
|
||||
useSelectionStore.getState().setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdxRef.current !== null) {
|
||||
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
|
||||
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
|
||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||
} else {
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
}
|
||||
lastSelectedIdxRef.current = globalIdx;
|
||||
return next;
|
||||
});
|
||||
}, [songs]);
|
||||
|
||||
// Drag: if the dragged song is part of the selection, drag all selected songs.
|
||||
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
|
||||
const { selectedIds } = useSelectionStore.getState();
|
||||
if (selectedIds.has(song.id) && selectedIds.size > 1) {
|
||||
const tracks = songs
|
||||
.filter(s => selectedIds.has(s.id))
|
||||
.map(s => songToTrack(s));
|
||||
psyDrag.startDrag(
|
||||
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
|
||||
me.clientX, me.clientY,
|
||||
);
|
||||
} else {
|
||||
psyDrag.startDrag(
|
||||
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
|
||||
me.clientX, me.clientY,
|
||||
);
|
||||
}
|
||||
}, [songs, psyDrag]);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
} else {
|
||||
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
|
||||
}
|
||||
}, [allSelected, songs]);
|
||||
|
||||
// ── Disc grouping ─────────────────────────────────────────────────────────
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
if (!sorted) {
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
if (!discs.has(disc)) discs.set(disc, []);
|
||||
discs.get(disc)!.push(song);
|
||||
});
|
||||
} else {
|
||||
discs.set(1, songs as SubsonicSong[]);
|
||||
}
|
||||
const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b);
|
||||
const isMultiDisc = !sorted && discNums.length > 1;
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
|
||||
// ── Sortable columns ──────────────────────────────────────────────────────
|
||||
const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
||||
|
||||
const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey);
|
||||
|
||||
const handleHeaderClick = (key: ColKey | string) => {
|
||||
if (!isSortable(key) || !onSort) return;
|
||||
onSort(key);
|
||||
};
|
||||
|
||||
const renderSortIndicator = (key: SortKey) => {
|
||||
if (sortKey !== key) return null;
|
||||
return (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||
{sortDir === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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}`) : '';
|
||||
const canSort = isSortable(key) && onSort;
|
||||
const isActive = canSort && sortKey === key;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => handleHeaderClick(key)}
|
||||
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && renderSortIndicator(key as SortKey)}
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => handleHeaderClick(key)}
|
||||
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Mobile tracklist ──────────────────────────────────────────────────────
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="tracklist-mobile">
|
||||
@@ -307,7 +497,7 @@ export default function AlbumTrackList({
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const isActive = currentTrack?.id === song.id;
|
||||
const isActive = currentTrackId === song.id;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
@@ -340,13 +530,19 @@ export default function AlbumTrackList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
<div
|
||||
className="tracklist"
|
||||
ref={tracklistRef}
|
||||
onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
}}
|
||||
>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedIds.size })}
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
@@ -358,15 +554,15 @@ export default function AlbumTrackList({
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); setSelectedIds(new Set()); }}
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
@@ -422,45 +618,29 @@ export default function AlbumTrackList({
|
||||
CD {discNum}
|
||||
</div>
|
||||
)}
|
||||
{discs.get(discNum)!.map((song) => {
|
||||
{discs.get(discNum)!.map(song => {
|
||||
const globalIdx = songs.indexOf(song);
|
||||
return (
|
||||
<div
|
||||
<TrackRow
|
||||
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) {
|
||||
toggleSelect(song.id, globalIdx, e.shiftKey);
|
||||
} else {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
|
||||
</div>
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
isStarred={starredSongs.has(song.id)}
|
||||
inSelectMode={inSelectMode}
|
||||
isContextMenuSong={contextMenuSongId === song.id}
|
||||
onPlaySong={onPlaySong}
|
||||
onRate={onRate}
|
||||
onToggleSongStar={onToggleSongStar}
|
||||
onContextMenu={onContextMenu}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
@@ -16,57 +22,327 @@ function isNewer(a: string, b: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Minimal inline-markdown renderer (bold, italic, code)
|
||||
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
||||
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChangelog(body: string) {
|
||||
return body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### '))
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
if (line.startsWith('#### '))
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
if (line.startsWith('## '))
|
||||
return null; // skip nested release headers in body
|
||||
if (line.startsWith('- '))
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
});
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ReleaseData {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||
if (IS_WINDOWS) {
|
||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||
?? assets.find(a => a.name.endsWith('.exe'));
|
||||
}
|
||||
if (IS_MACOS) {
|
||||
// Prefer Apple Silicon, fall back to Intel
|
||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||
}
|
||||
if (IS_LINUX) {
|
||||
// AppImage > deb > rpm
|
||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||
?? assets.find(a => a.name.endsWith('.deb'))
|
||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [isArch, setIsArch] = useState(false);
|
||||
const [dlState, setDlState] = useState<DlState>('idle');
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
const version = tag.replace(/^[^0-9]*/, '');
|
||||
if (!version) return;
|
||||
if (!preview) {
|
||||
if (!isNewer(version, currentVersion)) return;
|
||||
const skipped = localStorage.getItem(SKIP_KEY);
|
||||
if (skipped === version) return;
|
||||
}
|
||||
setDismissed(false);
|
||||
setDlState('idle');
|
||||
setRelease({
|
||||
version,
|
||||
tag,
|
||||
body: (data.body ?? '').trim(),
|
||||
assets: data.assets ?? [],
|
||||
});
|
||||
if (IS_LINUX) {
|
||||
const arch = await invoke<boolean>('check_arch_linux');
|
||||
setIsArch(arch);
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setNewVersion(tag.replace(/^[^0-9]*/, ''));
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
}, 4000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||
|
||||
const handler = () => fetchRelease(true);
|
||||
window.addEventListener('psysonic:preview-update', handler);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!newVersion || dismissed) return null;
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
setDlError('');
|
||||
|
||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||
'update:download:progress',
|
||||
e => {
|
||||
setDlProgress({
|
||||
bytes: e.payload.bytes,
|
||||
total: e.payload.total ?? asset.size,
|
||||
});
|
||||
}
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
try {
|
||||
const finalPath = await invoke<string>('download_update', {
|
||||
url: asset.browser_download_url,
|
||||
filename: asset.name,
|
||||
});
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlPath(finalPath);
|
||||
setDlState('done');
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowFolder = async () => {
|
||||
// tauri-plugin-shell's open() only allows https:// per capability scope —
|
||||
// local paths are blocked and fail silently. Delegate to Rust instead.
|
||||
const dir = await dirname(dlPath);
|
||||
await invoke('open_folder', { path: dir });
|
||||
};
|
||||
|
||||
const pct = dlProgress.total > 0
|
||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||
: 0;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
||||
<div
|
||||
className="eq-popup update-modal"
|
||||
style={{ zIndex: 3001 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="eq-popup-header update-modal-header">
|
||||
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
|
||||
<span className="update-modal-versions">
|
||||
v{currentVersion} → <strong>v{release.version}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="app-updater-dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
data-tooltip={t('common.updaterRemindBtn')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body: changelog + download area — single overflow container */}
|
||||
<div className="update-modal-body">
|
||||
{/* Collapsible Changelog */}
|
||||
{release.body && (
|
||||
<div className="update-modal-changelog">
|
||||
<button
|
||||
type="button"
|
||||
className="update-modal-changelog-toggle"
|
||||
onClick={() => setChangelogOpen(v => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transform: changelogOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{t('common.updaterChangelog')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div className="update-modal-changelog-body">
|
||||
{renderChangelog(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download / AUR area */}
|
||||
<div className="update-modal-download-area">
|
||||
{showAurHint ? (
|
||||
<div className="update-modal-aur">
|
||||
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{fmtBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
|
||||
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
|
||||
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
|
||||
<FolderOpen size={14} />
|
||||
{t('common.updaterShowFolder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="update-modal-asset-none">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
|
||||
>
|
||||
{t('common.updaterOpenGitHub')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{!showAurHint && asset && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
|
||||
<div className="app-updater-actions">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
className="app-updater-btn-secondary"
|
||||
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
|
||||
>
|
||||
{t('common.updaterWebsite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||
const [inView, setInView] = useState(false);
|
||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,14 +50,34 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
|
||||
// URL upgrades within the same image — avoids the end-of-load flash.
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setFallbackSrc(undefined);
|
||||
}, [cacheKey]);
|
||||
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (onError) {
|
||||
// Caller wants custom error handling (e.g. hide the element)
|
||||
onError(e);
|
||||
} else {
|
||||
// Nullify the DOM-level handler first to prevent any infinite loop
|
||||
e.currentTarget.onerror = null;
|
||||
setFallbackSrc('/logo-psysonic.png');
|
||||
}
|
||||
};
|
||||
|
||||
const isFallback = fallbackSrc !== undefined;
|
||||
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
src={finalSrc}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease', ...fallbackStyle }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
onError={handleError}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,10 @@ interface Props {
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
|
||||
export default function CustomSelect({ value, options, onChange, className = '', style, disabled }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -25,15 +26,14 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const updateDropStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const maxH = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
||||
|
||||
setDropStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
@@ -44,6 +44,17 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
||||
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updateDropStyle();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('scroll', updateDropStyle, true);
|
||||
return () => window.removeEventListener('scroll', updateDropStyle, true);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +81,8 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
||||
type="button"
|
||||
className={`custom-select-trigger ${className}`}
|
||||
style={style}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) setOpen(v => !v); }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
|
||||
@@ -192,34 +192,50 @@ const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
|
||||
// ─── Full-width seekbar — imperative DOM updates, zero React re-renders on 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);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const s = usePlayerStore.getState();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return usePlayerStore.subscribe(state => {
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
||||
[seek]
|
||||
);
|
||||
|
||||
const pct = progress * 100;
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
|
||||
return (
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span ref={timeRef} />
|
||||
<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}%` }} />
|
||||
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||
<div className="fs-seekbar-played" ref={playedRef} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
aria-label="seek"
|
||||
/>
|
||||
@@ -245,6 +261,10 @@ interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Module-level cache: artKey → accent color string.
|
||||
// Survives track changes so same-album songs reuse the extracted color instantly.
|
||||
const coverAccentCache = new Map<string, string>();
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
@@ -291,18 +311,35 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// Reset to null on track change so the previous color doesn't linger while
|
||||
// the new one is being extracted.
|
||||
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
|
||||
|
||||
// On cover change: hit cache for instant result, or fetch → extract → cache.
|
||||
// Cache hit avoids re-fetching for same-album tracks. Reset only when uncached.
|
||||
useEffect(() => {
|
||||
setDynamicAccent(null);
|
||||
if (!artUrl || !artKey) return;
|
||||
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
|
||||
const cached = coverAccentCache.get(artKey);
|
||||
if (cached) { setDynamicAccent(cached); return; }
|
||||
// No cache hit — keep the previous color visible until extraction completes.
|
||||
let cancelled = false;
|
||||
getCachedUrl(artUrl, artKey).then(blobUrl => {
|
||||
if (cancelled || !blobUrl) return;
|
||||
extractCoverColors(blobUrl).then(colors => {
|
||||
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
|
||||
});
|
||||
});
|
||||
let blobUrl = '';
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(artUrl);
|
||||
if (cancelled) return;
|
||||
const blob = await resp.blob();
|
||||
if (cancelled) return;
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
const colors = await extractCoverColors(blobUrl);
|
||||
if (cancelled) return;
|
||||
if (colors.accent) {
|
||||
coverAccentCache.set(artKey, colors.accent);
|
||||
setDynamicAccent(colors.accent);
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [artKey]); // artKey is stable per track — artUrl would also work
|
||||
}, [artKey]);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
@@ -318,7 +355,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
||||
@@ -382,7 +421,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
@@ -392,7 +434,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
<FsPortrait url={portraitUrl} />
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playAlbum';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||
const HERO_RANDOM_POOL = 32;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
@@ -36,7 +40,6 @@ function HeroBg({ url }: { url: string }) {
|
||||
style={{
|
||||
backgroundImage: `url(${layer.url})`,
|
||||
opacity: layer.visible ? 1 : 0,
|
||||
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -54,14 +57,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, [albumsProp, musicLibraryFilterVersion]);
|
||||
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
|
||||
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
|
||||
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
|
||||
getRandomAlbums(pool)
|
||||
.then(async raw => {
|
||||
const list = albumMix
|
||||
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
|
||||
: raw;
|
||||
setAlbums(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [
|
||||
albumsProp,
|
||||
musicLibraryFilterVersion,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react';
|
||||
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -126,7 +126,7 @@ export default function LiveSearch() {
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('search.advanced')}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
<TextSearch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
? t('player.lyricsSourceServer')
|
||||
: source === 'lrclib'
|
||||
? t('player.lyricsSourceLrclib')
|
||||
: null;
|
||||
: source === 'netease'
|
||||
? t('player.lyricsSourceNetease')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
|
||||
@@ -36,16 +36,14 @@ export default function NowPlayingDropdown() {
|
||||
});
|
||||
};
|
||||
|
||||
// Poll in background so the badge stays current without opening the dropdown
|
||||
// Poll only while the dropdown is open AND the page is visible.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchNowPlaying();
|
||||
const id = setInterval(fetchNowPlaying, 10000);
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') fetchNowPlaying();
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Refresh immediately when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchNowPlaying();
|
||||
}, [isOpen]);
|
||||
|
||||
// Click outside to close
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import React from 'react';
|
||||
import { WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { WifiOff, RefreshCw, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
onRetry: () => void;
|
||||
isChecking: boolean;
|
||||
showSettingsLink?: boolean;
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export default function OfflineBanner({ onRetry, isChecking }: Props) {
|
||||
export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const message = showSettingsLink
|
||||
? t('connection.offlineNoCacheBanner', { server: serverName })
|
||||
: t('connection.offlineModeBanner');
|
||||
return (
|
||||
<div className="offline-banner">
|
||||
<WifiOff size={14} />
|
||||
<span>{t('connection.offlineModeBanner')}</span>
|
||||
<span>{message}</span>
|
||||
{showSettingsLink && (
|
||||
<button
|
||||
className="offline-banner-retry"
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
>
|
||||
<Settings size={12} />
|
||||
{t('connection.serverSettings')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="offline-banner-retry"
|
||||
onClick={onRetry}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -23,25 +26,68 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Renders the playback clock without ever causing PlayerBar to re-render.
|
||||
// Updates the DOM directly via an imperative store subscription.
|
||||
const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
if (spanRef.current) {
|
||||
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
|
||||
}
|
||||
return usePlayerStore.subscribe(state => {
|
||||
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
|
||||
});
|
||||
}, []);
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const premuteVolumeRef = useRef(1);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
|
||||
const {
|
||||
currentTrack, currentRadio, isPlaying, currentTime, volume,
|
||||
currentTrack, currentRadio, isPlaying, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
} = usePlayerStore();
|
||||
userRatingOverrides, setUserRatingOverride,
|
||||
} = usePlayerStore(useShallow(s => ({
|
||||
currentTrack: s.currentTrack,
|
||||
currentRadio: s.currentRadio,
|
||||
isPlaying: s.isPlaying,
|
||||
volume: s.volume,
|
||||
togglePlay: s.togglePlay,
|
||||
next: s.next,
|
||||
previous: s.previous,
|
||||
setVolume: s.setVolume,
|
||||
stop: s.stop,
|
||||
toggleRepeat: s.toggleRepeat,
|
||||
repeatMode: s.repeatMode,
|
||||
toggleFullscreen: s.toggleFullscreen,
|
||||
lastfmLoved: s.lastfmLoved,
|
||||
toggleLastfmLove: s.toggleLastfmLove,
|
||||
isQueueVisible: s.isQueueVisible,
|
||||
toggleQueue: s.toggleQueue,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
setUserRatingOverride: s.setUserRatingOverride,
|
||||
})));
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
const isRadio = !!currentRadio;
|
||||
|
||||
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||
|
||||
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
@@ -127,17 +173,40 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<div className="player-track-meta">
|
||||
<MarqueeText
|
||||
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
|
||||
text={isRadio
|
||||
? (radioMeta.currentTitle
|
||||
? (radioMeta.currentArtist
|
||||
? `${radioMeta.currentArtist} — ${radioMeta.currentTitle}`
|
||||
: radioMeta.currentTitle)
|
||||
: (currentRadio?.name ?? '—'))
|
||||
: (currentTrack?.title ?? t('player.noTitle'))}
|
||||
className="player-track-name"
|
||||
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
/>
|
||||
<MarqueeText
|
||||
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
|
||||
text={isRadio
|
||||
? (radioMeta.currentTitle && currentRadio?.name
|
||||
? currentRadio.name
|
||||
: t('radio.liveStream'))
|
||||
: (currentTrack?.artist ?? '—')}
|
||||
className="player-track-artist"
|
||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
{currentTrack && !isRadio && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||
className="player-track-rating"
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
)}
|
||||
{isRadio && radioMeta.listeners != null && (
|
||||
<span className="player-radio-listeners">
|
||||
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
@@ -197,15 +266,32 @@ export default function PlayerBar() {
|
||||
<div className="player-waveform-section">
|
||||
{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>
|
||||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
|
||||
<>
|
||||
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<div className="radio-progress-bar">
|
||||
<div
|
||||
className="radio-progress-fill"
|
||||
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(radioMeta.duration)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlaybackTime className="player-time" />
|
||||
<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>
|
||||
<PlaybackTime className="player-time" />
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
@@ -214,16 +300,6 @@ export default function PlayerBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lyrics Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
|
||||
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={15} />
|
||||
</button>
|
||||
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
@@ -231,14 +307,21 @@ export default function PlayerBar() {
|
||||
aria-label="Equalizer"
|
||||
data-tooltip="Equalizer"
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<SlidersVertical size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
||||
onClick={() => {
|
||||
if (volume === 0) {
|
||||
setVolume(premuteVolumeRef.current);
|
||||
} else {
|
||||
premuteVolumeRef.current = volume;
|
||||
setVolume(0);
|
||||
}
|
||||
}}
|
||||
aria-label={t('player.volume')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -214,6 +215,7 @@ export default function QueuePanel() {
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentCoverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
||||
[currentTrack?.coverArt]
|
||||
@@ -271,6 +273,12 @@ export default function QueuePanel() {
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
/** Only these drag types may be dropped into the queue. */
|
||||
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
|
||||
const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
|
||||
})();
|
||||
// Keep for the onPsyDrop radio-reject check below
|
||||
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
|
||||
})();
|
||||
@@ -314,6 +322,8 @@ export default function QueuePanel() {
|
||||
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
|
||||
} else if (parsedData.type === 'song') {
|
||||
enqueueAt([parsedData.track], insertIdx);
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map((s: any) => ({
|
||||
@@ -373,9 +383,9 @@ export default function QueuePanel() {
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
|
||||
className={`queue-panel${isQueueDrag ? ' queue-drop-active' : ''}`}
|
||||
onMouseMove={e => {
|
||||
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
|
||||
if (!isQueueDrag || !queueListRef.current) return;
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
let found = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -447,7 +457,7 @@ export default function QueuePanel() {
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,9 +560,9 @@ export default function QueuePanel() {
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isPsyDragging && psyDragFromIdxRef.current === idx) {
|
||||
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
|
||||
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
|
||||
} else if (isPsyDragging && externalDropTarget?.idx === idx) {
|
||||
} else if (isQueueDrag && externalDropTarget?.idx === idx) {
|
||||
if (externalDropTarget.before) {
|
||||
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
|
||||
} else {
|
||||
@@ -646,10 +656,9 @@ export default function QueuePanel() {
|
||||
onClose={() => setSaveModalOpen(false)}
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
await createPlaylist(name, queue.map(t => t.id));
|
||||
const playlists = await getPlaylists();
|
||||
const created = playlists.find(p => p.name === name);
|
||||
if (created) setActivePlaylist({ id: created.id, name: created.name });
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, queue.map(t => t.id));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to save playlist', e);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
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, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp,
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
import { getPlaylists } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
// Exported so Settings can render the same item metadata.
|
||||
@@ -20,14 +23,14 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums', section: 'library' },
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||
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' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
@@ -43,7 +46,8 @@ export default function Sidebar({
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const offlineJobs = useOfflineStore(s => s.jobs);
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -54,6 +58,14 @@ export default function Sidebar({
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
const playlistsRaw = usePlaylistStore(s => s.playlists);
|
||||
const playlistsLoading = usePlaylistStore(s => s.playlistsLoading);
|
||||
const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists);
|
||||
// Sort playlists alphabetically by name
|
||||
const playlists = useMemo(() => {
|
||||
return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [playlistsRaw]);
|
||||
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
|
||||
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
@@ -111,6 +123,12 @@ export default function Sidebar({
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
fetchPlaylists();
|
||||
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
@@ -214,17 +232,69 @@ export default function Sidebar({
|
||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||
))}
|
||||
{visibleLibrary.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
item.to === '/playlists' ? (
|
||||
// Playlists item with expand button
|
||||
<div key={item.to} className="sidebar-playlists-wrapper">
|
||||
<div className="sidebar-playlists-header-row">
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
|
||||
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
|
||||
aria-expanded={playlistsExpanded}
|
||||
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
|
||||
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && playlistsExpanded && (
|
||||
<div className="sidebar-playlists-list">
|
||||
{playlistsLoading ? (
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
playlists.map((pl: { id: string; name: string }) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? pl.name : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<PlayCircle size={12} />
|
||||
<span>{pl.name}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
@@ -287,6 +357,15 @@ export default function Sidebar({
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
|
||||
)}
|
||||
<button
|
||||
className="sidebar-offline-cancel"
|
||||
onClick={cancelAllDownloads}
|
||||
data-tooltip={t('sidebar.cancelDownload')}
|
||||
data-tooltip-pos="right"
|
||||
aria-label={t('sidebar.cancelDownload')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { getSong, SubsonicSong } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -33,7 +34,9 @@ function Divider() {
|
||||
|
||||
export default function SongInfoModal() {
|
||||
const { t } = useTranslation();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore();
|
||||
const { songInfoModal, closeSongInfo } = usePlayerStore(
|
||||
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
|
||||
);
|
||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
maxStars = 5,
|
||||
maxSelectable: maxSelectableProp,
|
||||
labelKey = 'albumDetail.ratingLabel',
|
||||
ariaLabel,
|
||||
className = '',
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (rating: number) => void;
|
||||
disabled?: boolean;
|
||||
/** Number of star buttons (1…maxStars). Default 5. */
|
||||
maxStars?: number;
|
||||
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
|
||||
maxSelectable?: number;
|
||||
labelKey?: string;
|
||||
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stars = React.useMemo(
|
||||
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
|
||||
[maxStars]
|
||||
);
|
||||
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
|
||||
const [hover, setHover] = React.useState(0);
|
||||
const [pulseStar, setPulseStar] = React.useState<number | null>(null);
|
||||
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(null);
|
||||
/** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */
|
||||
const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false);
|
||||
const prevValueRef = React.useRef(value);
|
||||
const internalClickRef = React.useRef(false);
|
||||
|
||||
const cappedValue = Math.min(Math.max(0, value), selectCap);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value > 0) setSuppressHoverPreview(false);
|
||||
}, [value]);
|
||||
|
||||
// Keep keyboard-driven changes visually in sync with mouse click effects.
|
||||
React.useEffect(() => {
|
||||
const prev = prevValueRef.current;
|
||||
const next = value;
|
||||
prevValueRef.current = value;
|
||||
|
||||
if (internalClickRef.current) {
|
||||
internalClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (prev === next) return;
|
||||
|
||||
setPulseStar(null);
|
||||
setClearShrinkStar(null);
|
||||
|
||||
if (next > prev) {
|
||||
const star = Math.max(1, Math.min(selectCap, next));
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setPulseStar(star));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (next < prev) {
|
||||
const star = Math.max(1, Math.min(selectCap, prev));
|
||||
if (next === 0) setSuppressHoverPreview(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setClearShrinkStar(star));
|
||||
});
|
||||
}
|
||||
}, [value, selectCap]);
|
||||
|
||||
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
|
||||
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
|
||||
|
||||
const handleStarClick = (n: number) => {
|
||||
if (disabled || n > selectCap) return;
|
||||
setSuppressHoverPreview(false);
|
||||
|
||||
const next = cappedValue === n ? 0 : n;
|
||||
internalClickRef.current = true;
|
||||
onChange(next);
|
||||
setHover(0);
|
||||
|
||||
setPulseStar(null);
|
||||
setClearShrinkStar(null);
|
||||
|
||||
if (next === 0) {
|
||||
setSuppressHoverPreview(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setClearShrinkStar(n));
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setPulseStar(n));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerLeave = () => {
|
||||
setHover(0);
|
||||
setSuppressHoverPreview(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? t(labelKey)}
|
||||
aria-disabled={disabled}
|
||||
onMouseLeave={disabled ? undefined : handleContainerLeave}
|
||||
>
|
||||
{stars.map(n => {
|
||||
const locked = n > selectCap;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
|
||||
onMouseEnter={() =>
|
||||
!disabled && !suppressHoverPreview && !locked && setHover(n)
|
||||
}
|
||||
onClick={() => handleStarClick(n)}
|
||||
onAnimationEnd={e => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
const name = e.animationName;
|
||||
if (name === 'star-rating-star-pulse') {
|
||||
setPulseStar(s => (s === n ? null : s));
|
||||
}
|
||||
if (name === 'star-rating-star-clear-shrink') {
|
||||
setClearShrinkStar(s => (s === n ? null : s));
|
||||
}
|
||||
}}
|
||||
disabled={disabled || locked}
|
||||
aria-label={`${n}`}
|
||||
role="radio"
|
||||
aria-checked={filled(n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ interface ThemeDef {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Games',
|
||||
themes: [
|
||||
@@ -123,6 +123,13 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Accessibility',
|
||||
themes: [
|
||||
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
|
||||
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Social Media',
|
||||
themes: [
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { X, Minus, Square } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export default function TitleBar() {
|
||||
const win = getCurrentWindow();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
return (
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
|
||||
|
||||
<div className="titlebar-track" data-tauri-drag-region>
|
||||
{currentTrack && (
|
||||
<>
|
||||
<span className="titlebar-track-state">{isPlaying ? '▶' : '⏸'}</span>
|
||||
<span className="titlebar-track-text truncate">
|
||||
{currentTrack.artist && `${currentTrack.artist} – `}{currentTrack.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="titlebar-controls">
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-minimize"
|
||||
onClick={() => win.minimize()}
|
||||
data-tooltip="Minimize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-maximize"
|
||||
onClick={() => win.toggleMaximize()}
|
||||
data-tooltip="Maximize"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Square size={9} />
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-close"
|
||||
onClick={() => win.close()}
|
||||
data-tooltip="Close"
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
|
||||
|
||||
function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
@@ -7,6 +8,65 @@ function fmt(s: number): string {
|
||||
}
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
const SEG_COUNT = 60;
|
||||
|
||||
// ── animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
life: number; maxLife: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AnimState = {
|
||||
particles: Particle[];
|
||||
time: number;
|
||||
lastProgress: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
export function makeAnimState(): AnimState {
|
||||
return { particles: [], time: 0, lastProgress: 0, angle: 0 };
|
||||
}
|
||||
|
||||
const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'liquidfill', 'retrotape']);
|
||||
|
||||
// ── color helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getColors() {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
|
||||
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
|
||||
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
|
||||
};
|
||||
}
|
||||
|
||||
// ── canvas setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
function setupCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
return { ctx, w, h };
|
||||
}
|
||||
|
||||
// ── waveform heights ──────────────────────────────────────────────────────────
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
@@ -17,160 +77,820 @@ function hashStr(str: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeHeights(trackId: string): Float32Array {
|
||||
export function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
// Smooth for an organic look
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
// Normalize to [0.12, 1.0]
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
}
|
||||
if (max > 0) for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── draw functions ────────────────────────────────────────────────────────────
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorAccent = style.getPropertyValue('--waveform-played').trim() || style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--waveform-buffered').trim() || style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--waveform-unplayed').trim() || style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
// Pass 1 — unplayed (dim)
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillStyle = unplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
// Pass 2 — buffered (slightly brighter)
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = colorBuffered;
|
||||
ctx.fillStyle = buffCol;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
// Pass 3 — played (accent color + glow)
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = colorAccent;
|
||||
ctx.shadowColor = colorAccent;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Fade both edges to transparent using destination-in gradient mask
|
||||
const fadeW = Math.min(22, w * 0.07);
|
||||
const mask = ctx.createLinearGradient(0, 0, w, 0);
|
||||
mask.addColorStop(0, 'transparent');
|
||||
mask.addColorStop(fadeW / w, 'black');
|
||||
mask.addColorStop(1 - fadeW / w, 'black');
|
||||
mask.addColorStop(1, 'transparent');
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = mask;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, buffered * w, lh);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.fillRect(0, cy - lh / 2, progress * w, lh);
|
||||
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = 4;
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const bh = Math.min(14, h);
|
||||
const rad = bh / 2;
|
||||
const y = (h - bh) / 2;
|
||||
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, w, bh, rad);
|
||||
ctx.fill();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, buffered * w, bh, rad);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const gap = 2;
|
||||
const segW = (w - gap * (SEG_COUNT - 1)) / SEG_COUNT;
|
||||
const segH = h * 0.65;
|
||||
const y = (h - segH) / 2;
|
||||
const playedIdx = Math.floor(progress * SEG_COUNT);
|
||||
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const frac = i / SEG_COUNT;
|
||||
const x = i * (segW + gap);
|
||||
ctx.shadowBlur = 0;
|
||||
if (frac < progress) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
if (i === playedIdx - 1) {
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
}
|
||||
} else if (frac < buffered) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
} else {
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── new styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
// Ghost track — barely visible
|
||||
ctx.globalAlpha = 0.07;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
if (progress <= 0) return;
|
||||
|
||||
const px = progress * w;
|
||||
|
||||
// Wide outer glow
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.fillRect(0, cy - 5, px, 10);
|
||||
|
||||
// Mid glow
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillRect(0, cy - 2.5, px, 5);
|
||||
|
||||
// Inner glow
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - 1.5, px, 3);
|
||||
|
||||
// Bright white core
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 0.75, px, 1.5);
|
||||
|
||||
// End-cap flare
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawPulseWave(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
const t = animState.time;
|
||||
|
||||
// Base line
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Animated pulse centered at playhead
|
||||
const pulseR = Math.min(38, w * 0.13);
|
||||
const amp = Math.min(h * 0.42, 5.5);
|
||||
const sigma = pulseR * 0.42;
|
||||
const startX = Math.max(0, px - pulseR);
|
||||
const endX = Math.min(w, px + pulseR);
|
||||
|
||||
// Flat played line up to where the wave envelope starts
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillRect(0, cy - 1, startX, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, cy);
|
||||
for (let x = startX; x <= endX; x += 0.75) {
|
||||
const dx = x - px;
|
||||
const env = Math.exp(-(dx * dx) / (2 * sigma * sigma));
|
||||
const wave = env * amp * Math.sin(dx * 0.28 - t * 18);
|
||||
ctx.lineTo(x, cy - wave);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawParticleTrail(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
const px = progress * w;
|
||||
|
||||
// Spawn particles at playhead based on movement
|
||||
const prevPx = animState.lastProgress * w;
|
||||
const moved = Math.abs(px - prevPx);
|
||||
const spawnN = Math.min(5, 1 + Math.floor(moved * 1.5));
|
||||
for (let i = 0; i < spawnN; i++) {
|
||||
animState.particles.push({
|
||||
x: px + (Math.random() - 0.5) * 3,
|
||||
y: cy + (Math.random() - 0.5) * (h * 0.55),
|
||||
vx: -(Math.random() * 1.0 + 0.3),
|
||||
vy: (Math.random() - 0.5) * 0.6,
|
||||
life: 1,
|
||||
maxLife: 25 + Math.random() * 35,
|
||||
size: Math.random() * 1.8 + 0.8,
|
||||
});
|
||||
}
|
||||
animState.lastProgress = progress;
|
||||
|
||||
// Update + cull
|
||||
for (const p of animState.particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy *= 0.97;
|
||||
p.life -= 1 / p.maxLife;
|
||||
}
|
||||
animState.particles = animState.particles.filter(p => p.life > 0);
|
||||
if (animState.particles.length > 180) {
|
||||
animState.particles = animState.particles.slice(-180);
|
||||
}
|
||||
|
||||
// Background line
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Particles
|
||||
ctx.shadowColor = played;
|
||||
for (const p of animState.particles) {
|
||||
ctx.globalAlpha = p.life * 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Playhead dot
|
||||
if (progress > 0) {
|
||||
const dx = Math.max(5, Math.min(w - 5, px));
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawLiquidFill(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const t = animState.time;
|
||||
|
||||
const tubeH = Math.min(13, Math.max(6, h * 0.62));
|
||||
const tubeR = tubeH / 2;
|
||||
const y0 = (h - tubeH) / 2;
|
||||
const y1 = y0 + tubeH;
|
||||
|
||||
// Glass tube background
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.stroke();
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, y0, buffered * w, tubeH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
const px = progress * w;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.clip();
|
||||
|
||||
// Liquid body with animated wave on top surface
|
||||
const surfaceY = y0 + tubeH * 0.22; // liquid surface ~78% full
|
||||
const waveAmp = Math.min(2.0, tubeH * 0.14);
|
||||
const waveFreq = 0.09;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-1, y1 + 1);
|
||||
ctx.lineTo(-1, surfaceY);
|
||||
|
||||
for (let x = 0; x <= px + 1; x += 1) {
|
||||
const wave = waveAmp * Math.sin(x * waveFreq + t * 2.2);
|
||||
ctx.lineTo(x, surfaceY + wave);
|
||||
}
|
||||
ctx.lineTo(px + 1, y1 + 1);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 9;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Glass highlight on top
|
||||
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
|
||||
hl.addColorStop(0, 'rgba(255,255,255,0.28)');
|
||||
hl.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillStyle = hl;
|
||||
ctx.fillRect(0, y0, px, tubeH * 0.45);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Tube outline (on top)
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.strokeStyle = unplayed;
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y0, w, tubeH, tubeR);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawRetroTape(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
animState.angle += 0.055;
|
||||
|
||||
const reelR = Math.min(h / 2 - 0.5, 9);
|
||||
// Map progress to a center x that keeps the reel fully within the canvas
|
||||
const px = reelR + (w - 2 * reelR) * progress;
|
||||
|
||||
// Background track
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played portion — up to the left edge of the reel
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 1, px - reelR, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Spinning reel at playhead
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
|
||||
// Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, reelR * 0.28);
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spokes
|
||||
if (reelR > hubR + 2) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = played;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = animState.angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── dispatcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function drawSeekbar(
|
||||
canvas: HTMLCanvasElement,
|
||||
style: SeekbarStyle,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
animState?: AnimState,
|
||||
) {
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'waveform': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
case 'linedot': drawLineDot(canvas, progress, buffered); break;
|
||||
case 'bar': drawBar(canvas, progress, buffered); break;
|
||||
case 'thick': drawThick(canvas, progress, buffered); break;
|
||||
case 'segmented': drawSegmented(canvas, progress, buffered); break;
|
||||
case 'neon': drawNeon(canvas, progress, buffered); break;
|
||||
case 'pulsewave': drawPulseWave(canvas, progress, buffered, anim); break;
|
||||
case 'particletrail': drawParticleTrail(canvas, progress, buffered, anim); break;
|
||||
case 'liquidfill': drawLiquidFill(canvas, progress, buffered, anim); break;
|
||||
case 'retrotape': drawRetroTape(canvas, progress, buffered, anim); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SeekbarPreview (animated, for Settings) ───────────────────────────────────
|
||||
|
||||
export function SeekbarPreview({
|
||||
style,
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
style: SeekbarStyle;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const heights = style === 'waveform' ? makeHeights('seekbar-preview-demo') : null;
|
||||
const animState = makeAnimState();
|
||||
let t = 0;
|
||||
const tick = () => {
|
||||
t += 0.016;
|
||||
animState.time = t;
|
||||
const progress = 0.15 + 0.65 * (0.5 + 0.5 * Math.sin(t));
|
||||
const buffered = Math.min(1, progress + 0.18);
|
||||
drawSeekbar(canvas, style, heights, progress, buffered, animState);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); };
|
||||
}, [style]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--ctp-base))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
alignItems: 'stretch',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: 24, display: 'block' }}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
fontWeight: selected ? 600 : 400,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Architecture:
|
||||
// Static styles (waveform, bar, …): drawn directly in the Zustand subscription
|
||||
// callback — no React re-renders, no rAF loop. 2 draws/s at the 500 ms
|
||||
// Rust interval. shadowBlur + 500 canvas bars on a software-rendered
|
||||
// WebKitGTK context is too expensive for a continuous 60 fps loop.
|
||||
// Animated styles (pulsewave, particletrail, …): rAF loop at 60 fps, reads
|
||||
// refs that the subscription keeps up-to-date.
|
||||
// Drag: draws synchronously in seekToFraction for 1:1 responsiveness.
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||
const isDragging = useRef(false);
|
||||
const animStateRef = useRef<AnimState>(makeAnimState());
|
||||
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
// Ref so the subscription callback (closed over at mount) can read the
|
||||
// current style without stale-closure issues.
|
||||
const styleRef = useRef(seekbarStyle);
|
||||
styleRef.current = seekbarStyle;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId]);
|
||||
return usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
|
||||
progressRef.current = state.progress;
|
||||
bufferedRef.current = state.buffered;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial draw for static styles when style or track changes.
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
}, [seekbarStyle, trackId]);
|
||||
|
||||
// rAF loop — animated styles only.
|
||||
useEffect(() => {
|
||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
animStateRef.current = makeAnimState();
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
const trackIdRef = useRef(trackId);
|
||||
trackIdRef.current = trackId;
|
||||
const seekRef = useRef(seek);
|
||||
seekRef.current = seek;
|
||||
|
||||
// Seek to a 0–1 fraction: draw immediately for 1:1 responsiveness, then
|
||||
// let the store + Rust catch up asynchronously.
|
||||
const seekToFraction = (fraction: number) => {
|
||||
progressRef.current = fraction;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
|
||||
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
|
||||
}
|
||||
seekRef.current(fraction);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackIdRef.current) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
|
||||
const onUp = () => { isDragging.current = false; };
|
||||
@@ -198,7 +918,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
onMouseDown={e => {
|
||||
isDragging.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
if (!trackId) return;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HardDriveDownload, Check, X } from 'lucide-react';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
|
||||
function formatMB(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ZipDownloadItem({ id }: { id: string }) {
|
||||
const dismiss = useZipDownloadStore(s => s.dismiss);
|
||||
const item = useZipDownloadStore(s => s.downloads.find(d => d.id === id));
|
||||
|
||||
// Auto-dismiss 3 s after completion or error.
|
||||
useEffect(() => {
|
||||
if (!item?.done && !item?.error) return;
|
||||
const timer = setTimeout(() => dismiss(id), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [item?.done, item?.error, id, dismiss]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const pct = item.total && item.total > 0
|
||||
? Math.min(100, (item.bytes / item.total) * 100)
|
||||
: null;
|
||||
|
||||
const isIndeterminate = !item.done && !item.error && (item.total === null || item.total === 0);
|
||||
|
||||
return (
|
||||
<div className={`zip-dl-item${item.done ? ' zip-dl-done' : item.error ? ' zip-dl-error' : ''}`}>
|
||||
<div className="zip-dl-header">
|
||||
{item.done
|
||||
? <Check size={13} />
|
||||
: item.error
|
||||
? <X size={13} />
|
||||
: <HardDriveDownload size={13} className="spin-slow" />
|
||||
}
|
||||
<span className="zip-dl-name" data-tooltip={item.filename} data-tooltip-pos="top">{item.filename}</span>
|
||||
{(item.done || item.error) && (
|
||||
<button className="zip-dl-close" onClick={() => dismiss(id)} aria-label="Close">
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!item.done && !item.error && (
|
||||
<>
|
||||
<div className="zip-dl-info">
|
||||
{formatMB(item.bytes)}
|
||||
{item.total !== null && item.total > 0 && (
|
||||
<> / {formatMB(item.total)} ({pct!.toFixed(0)}%)</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`zip-dl-track${isIndeterminate ? ' zip-dl-indeterminate' : ''}`}>
|
||||
{!isIndeterminate && pct !== null && (
|
||||
<div className="zip-dl-fill" style={{ width: `${pct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ZipDownloadOverlay() {
|
||||
// Subscribe to the array reference directly — never derive a new array in the selector
|
||||
// (selector returning new array on every call causes an infinite re-render loop).
|
||||
const downloads = useZipDownloadStore(s => s.downloads);
|
||||
if (downloads.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="zip-dl-overlay">
|
||||
{downloads.map(d => <ZipDownloadItem key={d.id} id={d.id} />)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
@@ -37,8 +37,20 @@ export function useConnectionStatus() {
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await pingWithCredentials(server.url, server.username, server.password);
|
||||
setStatus(ok ? 'connected' : 'disconnected');
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (ping.ok) {
|
||||
const sid = useAuthStore.getState().activeServerId;
|
||||
if (sid) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
useAuthStore.getState().setSubsonicServerIdentity(sid, identity);
|
||||
scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity);
|
||||
}
|
||||
}
|
||||
setStatus(ping.ok ? 'connected' : 'disconnected');
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
@@ -49,7 +61,7 @@ export function useConnectionStatus() {
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
intervalRef.current = setInterval(check, 30_000);
|
||||
intervalRef.current = setInterval(check, 120_000);
|
||||
|
||||
const handleOnline = () => check();
|
||||
const handleOffline = () => setStatus('disconnected');
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { fetchNeteaselyrics } from '../api/netease';
|
||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib';
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
@@ -20,7 +25,9 @@ export const lyricsCache = new Map<string, CachedLyrics>();
|
||||
export function parseStructuredLyrics(
|
||||
lyrics: SubsonicStructuredLyrics,
|
||||
): Pick<CachedLyrics, 'syncedLines' | 'plainLyrics'> {
|
||||
if (lyrics.issynced && lyrics.line.length > 0) {
|
||||
// Accept both `synced` (OpenSubsonic spec) and `issynced` (legacy servers).
|
||||
const isSynced = !!(lyrics.synced ?? lyrics.issynced);
|
||||
if (isSynced && lyrics.line.length > 0) {
|
||||
const lines: LrcLine[] = lyrics.line
|
||||
.filter(l => l.start !== undefined)
|
||||
.map(l => ({ time: l.start! / 1000, text: l.value.trim() }))
|
||||
@@ -41,7 +48,7 @@ export interface UseLyricsResult {
|
||||
|
||||
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
@@ -79,6 +86,37 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// For offline / hot-cached tracks we have the file locally — read SYLT /
|
||||
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
|
||||
// Fast path: both store lookups are synchronous; returns false immediately
|
||||
// for streaming tracks so it has zero impact on the normal fetch sequence.
|
||||
const fetchEmbedded = async (): Promise<boolean> => {
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const localUrl =
|
||||
useOfflineStore.getState().getLocalUrl(currentTrack.id, serverId) ??
|
||||
useHotCacheStore.getState().getLocalUrl(currentTrack.id, serverId);
|
||||
if (!localUrl) return false;
|
||||
|
||||
const prefix = 'psysonic-local://';
|
||||
const filePath = localUrl.startsWith(prefix) ? localUrl.slice(prefix.length) : null;
|
||||
if (!filePath) return false;
|
||||
|
||||
try {
|
||||
const lrcString = await invoke<string | null>('get_embedded_lyrics', { path: filePath });
|
||||
if (!lrcString) return false;
|
||||
|
||||
const lines = parseLrc(lrcString);
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
const plain = synced ? null : (lrcString.trim() || null);
|
||||
if (!synced && !plain) return false;
|
||||
|
||||
store({ syncedLines: synced, plainLyrics: plain, source: 'embedded', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServer = async (): Promise<boolean> => {
|
||||
const structured = await getLyricsBySongId(currentTrack.id);
|
||||
if (!structured) return false;
|
||||
@@ -106,20 +144,45 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const [first, second] = lyricsServerFirst
|
||||
? [fetchServer, fetchLrclibFn]
|
||||
: [fetchLrclibFn, fetchServer];
|
||||
const NETEASE_META = /^(作词|作曲|编曲|制作人|出版|发行|MV导演|录音|混音|监制)/;
|
||||
const fetchNetease = async (): Promise<boolean> => {
|
||||
try {
|
||||
const lrc = await fetchNeteaselyrics(currentTrack.artist ?? '', currentTrack.title);
|
||||
if (!lrc) return false;
|
||||
const lines = parseLrc(lrc).filter(l => !NETEASE_META.test(l.text));
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
if (!synced) return false;
|
||||
store({ syncedLines: synced, plainLyrics: null, source: 'netease', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFns: Record<string, () => Promise<boolean>> = {
|
||||
server: fetchServer,
|
||||
lrclib: fetchLrclibFn,
|
||||
netease: fetchNetease,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Embedded lyrics from local file always win (most accurate SYLT data).
|
||||
if (cancelled) return;
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) return;
|
||||
if (await fetchEmbedded()) return;
|
||||
|
||||
// Try enabled sources in user-defined order.
|
||||
for (const src of lyricsSources) {
|
||||
if (!src.enabled) continue;
|
||||
const fn = fetchFns[src.id];
|
||||
if (!fn) continue;
|
||||
if (cancelled) return;
|
||||
if (await fn()) return;
|
||||
}
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack?.id, lyricsSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { InternetRadioStation } from '../api/subsonic';
|
||||
import {
|
||||
guessAzuraCastApiUrl,
|
||||
normaliseAzuraCastHomepageUrl,
|
||||
fetchAzuraCastNowPlaying,
|
||||
type AzuraCastNowPlaying,
|
||||
type AzuraCastSong,
|
||||
} from '../api/azuracast';
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
|
||||
|
||||
export interface RadioHistoryItem {
|
||||
song: AzuraCastSong;
|
||||
playedAt?: number; // unix timestamp
|
||||
}
|
||||
|
||||
export interface RadioMetadata {
|
||||
/** Metadata source that is currently active. */
|
||||
source: RadioMetadataSource;
|
||||
/** Station name (from ICY icy-name or AzuraCast station.name). */
|
||||
stationName?: string;
|
||||
/** Current track title (combined or individual fields). */
|
||||
currentTitle?: string;
|
||||
currentArtist?: string;
|
||||
currentAlbum?: string;
|
||||
currentArt?: string;
|
||||
/** AzuraCast-only: seconds elapsed in current track. */
|
||||
elapsed?: number;
|
||||
/** AzuraCast-only: total duration of current track in seconds. */
|
||||
duration?: number;
|
||||
/** AzuraCast-only: number of current listeners. */
|
||||
listeners?: number;
|
||||
/** AzuraCast-only: last N played tracks. */
|
||||
history: RadioHistoryItem[];
|
||||
/** AzuraCast-only: next track queued. */
|
||||
nextSong?: AzuraCastSong;
|
||||
}
|
||||
|
||||
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
|
||||
|
||||
interface IcyMetadataResult {
|
||||
stream_title?: string;
|
||||
icy_name?: string;
|
||||
icy_genre?: string;
|
||||
icy_url?: string;
|
||||
icy_description?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
|
||||
const sep = streamTitle.indexOf(' - ');
|
||||
if (sep !== -1) {
|
||||
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
|
||||
}
|
||||
return { title: streamTitle };
|
||||
}
|
||||
|
||||
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
|
||||
const nowPlaying = np.now_playing;
|
||||
const song = nowPlaying?.song;
|
||||
return {
|
||||
source: 'azuracast',
|
||||
stationName: np.station?.name,
|
||||
currentTitle: song?.title,
|
||||
currentArtist: song?.artist,
|
||||
currentAlbum: song?.album,
|
||||
currentArt: song?.art,
|
||||
elapsed: nowPlaying?.elapsed,
|
||||
duration: nowPlaying?.duration,
|
||||
listeners: np.listeners?.current,
|
||||
history: (np.song_history ?? []).slice(0, 5).map(h => ({
|
||||
song: h.song,
|
||||
playedAt: h.played_at,
|
||||
})),
|
||||
nextSong: np.playing_next?.song ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AZURACAST_POLL_MS = 15_000;
|
||||
const ICY_POLL_MS = 30_000;
|
||||
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
|
||||
|
||||
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
|
||||
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
|
||||
|
||||
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
|
||||
const elapsedRef = useRef<number | null>(null);
|
||||
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stationRef = useRef<InternetRadioStation | null>(null);
|
||||
|
||||
// Store resolved AzuraCast API URL for the current station (or null).
|
||||
const azuraCastUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Stop the elapsed ticker.
|
||||
function stopElapsedTick() {
|
||||
if (elapsedIntervalRef.current) {
|
||||
clearInterval(elapsedIntervalRef.current);
|
||||
elapsedIntervalRef.current = null;
|
||||
}
|
||||
elapsedRef.current = null;
|
||||
}
|
||||
|
||||
// Start a 1-second elapsed ticker that advances the stored elapsed value and
|
||||
// updates the metadata state so the progress bar moves smoothly between polls.
|
||||
function startElapsedTick(initial: number) {
|
||||
stopElapsedTick();
|
||||
elapsedRef.current = initial;
|
||||
elapsedIntervalRef.current = setInterval(() => {
|
||||
if (elapsedRef.current === null) return;
|
||||
elapsedRef.current += 1;
|
||||
setMetadata(prev =>
|
||||
prev.source === 'azuracast'
|
||||
? { ...prev, elapsed: elapsedRef.current! }
|
||||
: prev
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!station) {
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
return;
|
||||
}
|
||||
|
||||
stationRef.current = station;
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Determine which AzuraCast API URL to try, in priority order:
|
||||
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
|
||||
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
|
||||
const candidateApiUrl =
|
||||
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
|
||||
guessAzuraCastApiUrl(station.streamUrl);
|
||||
|
||||
async function pollAzuraCast(apiUrl: string) {
|
||||
if (cancelled) return;
|
||||
const np = await fetchAzuraCastNowPlaying(apiUrl);
|
||||
if (cancelled) return;
|
||||
if (np) {
|
||||
const m = nowPlayingToMetadata(np);
|
||||
setMetadata(m);
|
||||
startElapsedTick(m.elapsed ?? 0);
|
||||
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
|
||||
} else {
|
||||
// AzuraCast check failed — fall back to ICY
|
||||
azuraCastUrlRef.current = null;
|
||||
pollIcy();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollIcy() {
|
||||
if (cancelled) return;
|
||||
const currentStation = stationRef.current;
|
||||
if (!currentStation) return;
|
||||
try {
|
||||
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
|
||||
if (cancelled) return;
|
||||
if (result.stream_title || result.icy_name) {
|
||||
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
|
||||
setMetadata({
|
||||
source: 'icy',
|
||||
stationName: result.icy_name,
|
||||
currentTitle: parsed?.title,
|
||||
currentArtist: parsed?.artist,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ICY metadata not available — leave empty metadata
|
||||
}
|
||||
if (!cancelled) {
|
||||
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off detection and polling.
|
||||
if (candidateApiUrl) {
|
||||
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
|
||||
azuraCastUrlRef.current = candidateApiUrl;
|
||||
pollAzuraCast(candidateApiUrl);
|
||||
} else {
|
||||
pollIcy();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
stopElapsedTick();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useThemeStore, getScheduledTheme } from '../store/themeStore';
|
||||
|
||||
export function useThemeScheduler(): string {
|
||||
const state = useThemeStore();
|
||||
const [effectiveTheme, setEffectiveTheme] = useState(() => getScheduledTheme(state));
|
||||
|
||||
useEffect(() => {
|
||||
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
|
||||
if (!state.enableThemeScheduler) return;
|
||||
const id = setInterval(() => {
|
||||
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
|
||||
}, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.enableThemeScheduler, state.theme, state.themeDay, state.themeNight, state.timeDayStart, state.timeNightStart]);
|
||||
|
||||
return effectiveTheme;
|
||||
}
|
||||
@@ -1,16 +1,67 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from './api/subsonic';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useHotCacheStore } from './store/hotCacheStore';
|
||||
import { HOT_CACHE_PROTECT_AFTER_CURRENT, useHotCacheStore, type HotCacheEntry } from './store/hotCacheStore';
|
||||
import { useOfflineStore } from './store/offlineStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
|
||||
import { usePlayerStore, type Track } from './store/playerStore';
|
||||
import {
|
||||
bumpHotCachePreviousTrackGrace,
|
||||
clearHotCachePreviousGrace,
|
||||
getDeferHotCachePrefetch,
|
||||
} from './utils/hotCacheGate';
|
||||
|
||||
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
function entryKey(serverId: string, trackId: string): string {
|
||||
return `${serverId}:${trackId}`;
|
||||
}
|
||||
|
||||
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
|
||||
function sumCachedBytesInProtectedWindow(
|
||||
queue: Track[],
|
||||
queueIndex: number,
|
||||
serverId: string,
|
||||
entries: Record<string, HotCacheEntry>,
|
||||
): number {
|
||||
const protectLo = Math.max(0, queueIndex);
|
||||
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
||||
let sum = 0;
|
||||
for (let i = protectLo; i <= protectHi; i++) {
|
||||
const e = entries[entryKey(serverId, queue[i].id)];
|
||||
if (e) sum += e.sizeBytes || 0;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** Conservative size guess so we do not prefetch when the protected window could exceed the cap. */
|
||||
function estimateTrackHotCacheBytes(track: Track): number {
|
||||
const sz = track.size;
|
||||
if (typeof sz === 'number' && Number.isFinite(sz) && sz > 0) {
|
||||
return Math.ceil(sz * 1.06);
|
||||
}
|
||||
const dur =
|
||||
typeof track.duration === 'number' && Number.isFinite(track.duration) && track.duration > 0
|
||||
? track.duration
|
||||
: 240;
|
||||
const sfx = (track.suffix || '').toLowerCase();
|
||||
const lossless = /^(flac|wav|dsf|dff|alac|ape|wv)$/.test(sfx);
|
||||
let kbps =
|
||||
typeof track.bitRate === 'number' && Number.isFinite(track.bitRate) && track.bitRate > 0
|
||||
? track.bitRate
|
||||
: 320;
|
||||
if (lossless && kbps < 800) {
|
||||
kbps = Math.max(kbps, 900);
|
||||
}
|
||||
const raw = Math.ceil((dur * kbps * 1000) / 8);
|
||||
return Math.max(256 * 1024, Math.ceil(raw * (lossless ? 1.2 : 1.15)));
|
||||
}
|
||||
|
||||
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
|
||||
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingQueue: PrefetchJob[] = [];
|
||||
let workerRunning = false;
|
||||
|
||||
@@ -20,6 +71,22 @@ function debounceMs(): number {
|
||||
return Math.min(600, s) * 1000;
|
||||
}
|
||||
|
||||
function scheduleEvictAfterPreviousGrace(): void {
|
||||
if (graceEvictTimer) {
|
||||
clearTimeout(graceEvictTimer);
|
||||
graceEvictTimer = null;
|
||||
}
|
||||
const ms = debounceMs();
|
||||
if (ms <= 0) {
|
||||
void replanNow();
|
||||
return;
|
||||
}
|
||||
graceEvictTimer = setTimeout(() => {
|
||||
graceEvictTimer = null;
|
||||
void replanNow();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function enqueueJobs(jobs: PrefetchJob[]) {
|
||||
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
||||
for (const j of jobs) {
|
||||
@@ -56,7 +123,8 @@ async function runWorker() {
|
||||
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
|
||||
|
||||
const { queue, queueIndex } = usePlayerStore.getState();
|
||||
const player = usePlayerStore.getState();
|
||||
const { queue, queueIndex } = player;
|
||||
const wantIds = new Set(
|
||||
queue
|
||||
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
||||
@@ -64,6 +132,14 @@ async function runWorker() {
|
||||
);
|
||||
if (!wantIds.has(job.trackId)) continue;
|
||||
|
||||
const track = queue.find(t => t.id === job.trackId);
|
||||
if (!track) continue;
|
||||
const hotEntries = useHotCacheStore.getState().entries;
|
||||
const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries);
|
||||
const est = estimateTrackHotCacheBytes(track);
|
||||
const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId;
|
||||
if (!isImmediateNext && occupied + est > maxBytes) continue;
|
||||
|
||||
const url = buildStreamUrl(job.trackId);
|
||||
try {
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
@@ -76,12 +152,14 @@ async function runWorker() {
|
||||
});
|
||||
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
|
||||
const fresh = usePlayerStore.getState();
|
||||
const authAfter = useAuthStore.getState();
|
||||
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
|
||||
await useHotCacheStore.getState().evictToFit(
|
||||
fresh.queue,
|
||||
fresh.queueIndex,
|
||||
maxBytes,
|
||||
auth.activeServerId,
|
||||
customDir,
|
||||
maxAfter,
|
||||
authAfter.activeServerId ?? '',
|
||||
authAfter.hotCacheDownloadDir || null,
|
||||
);
|
||||
} catch {
|
||||
/* network / HTTP — skip */
|
||||
@@ -93,10 +171,6 @@ async function runWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(serverId: string, trackId: string): string {
|
||||
return `${serverId}:${trackId}`;
|
||||
}
|
||||
|
||||
function scheduleReplan() {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
||||
@@ -133,15 +207,21 @@ async function replanNow() {
|
||||
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
||||
|
||||
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
||||
const immediateNextId = queue[queueIndex + 1]?.id;
|
||||
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hot.entries);
|
||||
const jobs: PrefetchJob[] = [];
|
||||
for (const t of targets) {
|
||||
if (offline.isDownloaded(t.id, serverId)) continue;
|
||||
if (hot.entries[entryKey(serverId, t.id)]) continue;
|
||||
jobs.push({
|
||||
trackId: t.id,
|
||||
serverId,
|
||||
suffix: t.suffix || 'mp3',
|
||||
});
|
||||
const isImmediateNext = t.id === immediateNextId;
|
||||
if (isImmediateNext) {
|
||||
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
|
||||
continue;
|
||||
}
|
||||
const est = estimateTrackHotCacheBytes(t);
|
||||
if (projectedOccupied + est > maxBytes) break;
|
||||
projectedOccupied += est;
|
||||
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
|
||||
}
|
||||
enqueueJobs(jobs);
|
||||
}
|
||||
@@ -157,19 +237,61 @@ export function initHotCachePrefetch(): () => void {
|
||||
const q = state.queue;
|
||||
const i = state.queueIndex;
|
||||
if (q === lastQueueRef && i === lastQueueIndex) return;
|
||||
const prevIdx = lastQueueIndex;
|
||||
const prevQ = lastQueueRef;
|
||||
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
|
||||
lastQueueRef = q;
|
||||
lastQueueIndex = i;
|
||||
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
|
||||
const left = (prevQ as Track[])[prevIdx];
|
||||
const a = useAuthStore.getState();
|
||||
if (left && a.activeServerId) {
|
||||
bumpHotCachePreviousTrackGrace(left.id, a.activeServerId, a.hotCacheDebounceSec);
|
||||
scheduleEvictAfterPreviousGrace();
|
||||
}
|
||||
}
|
||||
if (onlyIndexMoved) void replanNow();
|
||||
else scheduleReplan();
|
||||
});
|
||||
|
||||
let lastAuthSig = '';
|
||||
const unsubAuth = useAuthStore.subscribe(state => {
|
||||
const unsubAuth = useAuthStore.subscribe((state, prev) => {
|
||||
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
|
||||
if (sig === lastAuthSig) return;
|
||||
lastAuthSig = sig;
|
||||
if (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan();
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
|
||||
if (!state.hotCacheEnabled || !state.isLoggedIn) {
|
||||
pendingQueue.length = 0;
|
||||
clearHotCachePreviousGrace();
|
||||
return;
|
||||
}
|
||||
|
||||
const budgetSettingsChanged =
|
||||
!prev ||
|
||||
state.hotCacheMaxMb !== prev.hotCacheMaxMb ||
|
||||
state.hotCacheDownloadDir !== prev.hotCacheDownloadDir ||
|
||||
state.hotCacheEnabled !== prev.hotCacheEnabled ||
|
||||
state.activeServerId !== prev.activeServerId ||
|
||||
state.isLoggedIn !== prev.isLoggedIn;
|
||||
|
||||
const onlyDebounceChanged =
|
||||
!!prev &&
|
||||
state.hotCacheDebounceSec !== prev.hotCacheDebounceSec &&
|
||||
!budgetSettingsChanged;
|
||||
|
||||
if (budgetSettingsChanged) {
|
||||
if (prev && state.hotCacheMaxMb < prev.hotCacheMaxMb) {
|
||||
pendingQueue.length = 0;
|
||||
}
|
||||
void replanNow();
|
||||
} else if (onlyDebounceChanged) {
|
||||
scheduleReplan();
|
||||
}
|
||||
});
|
||||
|
||||
void replanNow();
|
||||
@@ -179,6 +301,9 @@ export function initHotCachePrefetch(): () => void {
|
||||
unsubAuth();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
||||
graceEvictTimer = null;
|
||||
pendingQueue.length = 0;
|
||||
clearHotCachePreviousGrace();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { initReactI18next } from 'react-i18next';
|
||||
import { deTranslation } from './locales/de';
|
||||
import { enTranslation } from './locales/en';
|
||||
import { frTranslation } from './locales/fr';
|
||||
import { nbTranslation } from './locales/nb';
|
||||
import { nlTranslation } from './locales/nl';
|
||||
import { ruTranslation } from './locales/ru';
|
||||
import { zhTranslation } from './locales/zh';
|
||||
import { nbTranslation } from './locales/nb';
|
||||
import { ruTranslation } from './locales/ru';
|
||||
import { nlTranslation } from './locales/nl';
|
||||
import { esTranslation } from './locales/es';
|
||||
|
||||
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
|
||||
|
||||
@@ -16,6 +17,7 @@ i18n
|
||||
resources: {
|
||||
en: { translation: enTranslation },
|
||||
de: { translation: deTranslation },
|
||||
es: { translation: esTranslation },
|
||||
fr: { translation: frTranslation },
|
||||
nl: { translation: nlTranslation },
|
||||
zh: { translation: zhTranslation },
|
||||
|
||||
@@ -5,6 +5,7 @@ export const deTranslation = {
|
||||
newReleases: 'Neueste',
|
||||
allAlbums: 'Alle Alben',
|
||||
randomAlbums: 'Zufallsalben',
|
||||
randomPicker: 'Mix erstellen',
|
||||
artists: 'Künstler',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
@@ -16,13 +17,17 @@ export const deTranslation = {
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
cancelDownload: 'Download abbrechen',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
expandPlaylists: 'Playlists ausklappen',
|
||||
collapsePlaylists: 'Playlists einklappen',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -92,6 +97,8 @@ export const deTranslation = {
|
||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||
enqueueAlbum: 'Ganzes Album einreihen',
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
||||
lfmLove: 'Auf Last.fm liken',
|
||||
lfmUnlove: 'Last.fm-Like entfernen',
|
||||
favorite: 'Favorisieren',
|
||||
@@ -101,10 +108,14 @@ export const deTranslation = {
|
||||
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
|
||||
unfavoriteAlbum: 'Album aus Favoriten entfernen',
|
||||
removeFromQueue: 'Diesen Song entfernen',
|
||||
removeFromPlaylist: 'Aus Playlist entfernen',
|
||||
openAlbum: 'Album öffnen',
|
||||
goToArtist: 'Zum Künstler',
|
||||
download: 'Herunterladen (ZIP)',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
selectedPlaylists: '{{count}} Playlists ausgewählt',
|
||||
selectedAlbums: '{{count}} Alben ausgewählt',
|
||||
selectedArtists: '{{count}} Künstler ausgewählt',
|
||||
songInfo: 'Song-Infos',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -131,6 +142,7 @@ export const deTranslation = {
|
||||
goToArtist: 'Zu {{artist}} wechseln',
|
||||
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Interpret',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
@@ -144,6 +156,18 @@ export const deTranslation = {
|
||||
bioClose: 'Schließen',
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
filterSongs: 'Titel filtern…',
|
||||
sortNatural: 'Reihenfolge',
|
||||
sortByTitle: 'A–Z (Titel)',
|
||||
sortByArtist: 'A–Z (Künstler)',
|
||||
sortByAlbum: 'A–Z (Album)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbewertung',
|
||||
artistShort: 'Künstlerbewertung',
|
||||
albumAriaLabel: 'Albumbewertung',
|
||||
artistAriaLabel: 'Künstlerbewertung',
|
||||
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -186,6 +210,13 @@ export const deTranslation = {
|
||||
removeSong: 'Aus Favoriten entfernen',
|
||||
stations: 'Radiosender',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix erstellen',
|
||||
mixByTracks: 'Mix nach Titeln',
|
||||
mixByTracksDesc: 'Zufällige Auswahl aus deiner gesamten Mediathek',
|
||||
mixByAlbums: 'Mix nach Alben',
|
||||
mixByAlbumsDesc: 'Zufällige Alben für neue Entdeckungen',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
refresh: 'Neu laden',
|
||||
@@ -206,6 +237,8 @@ export const deTranslation = {
|
||||
title: 'Zufallsmix',
|
||||
remix: 'Neu mixen',
|
||||
remixTooltip: 'Neue Songs laden',
|
||||
remixGenre: '{{genre}} neu mixen',
|
||||
remixTooltipGenre: 'Neue {{genre}}-Songs laden',
|
||||
playAll: 'Alle abspielen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Künstler',
|
||||
@@ -227,6 +260,7 @@ export const deTranslation = {
|
||||
blacklistToggle: 'Keyword-Filter',
|
||||
genreMixTitle: 'Genre-Mix',
|
||||
genreMixDesc: 'Top 20 Genres nach Songanzahl — klicken für einen Zufallsmix',
|
||||
genreMixAll: 'Alle Songs',
|
||||
genreMixLoadMore: '10 weitere laden',
|
||||
genreMixNoGenres: 'Keine Genres auf dem Server gefunden.',
|
||||
shuffleGenres: 'Andere Genres anzeigen',
|
||||
@@ -244,6 +278,17 @@ export const deTranslation = {
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
select: 'Mehrfachauswahl',
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
selectionCount: '{{count}} ausgewählt',
|
||||
downloadZips: 'ZIPs herunterladen',
|
||||
addOffline: 'Offline hinzufügen',
|
||||
downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
|
||||
downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
|
||||
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
|
||||
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
|
||||
},
|
||||
artists: {
|
||||
title: 'Künstler',
|
||||
@@ -257,13 +302,18 @@ export const deTranslation = {
|
||||
notFound: 'Keine Künstler gefunden.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Alben',
|
||||
selectionCount: '{{count}} ausgewählt',
|
||||
select: 'Mehrfachauswahl',
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Dein Navidrome Desktop Player',
|
||||
serverName: 'Server-Name (optional)',
|
||||
serverNamePlaceholder: 'Mein Navidrome',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
|
||||
username: 'Benutzername',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Passwort',
|
||||
@@ -287,6 +337,7 @@ export const deTranslation = {
|
||||
offlineTitle: 'Keine Serververbindung',
|
||||
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
|
||||
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
|
||||
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
|
||||
offlineLibraryTitle: 'Offline-Bibliothek',
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
@@ -296,6 +347,7 @@ export const deTranslation = {
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
serverSettings: 'Server-Einstellungen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
},
|
||||
@@ -324,12 +376,27 @@ export const deTranslation = {
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
clearSelection: 'Auswahl aufheben',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||
bulkClear: 'Auswahl aufheben',
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} verfügbar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Neue Version verfügbar',
|
||||
updaterChangelog: 'Was ist neu',
|
||||
updaterDownloadBtn: 'Jetzt herunterladen',
|
||||
updaterSkipBtn: 'Version überspringen',
|
||||
updaterRemindBtn: 'Später erinnern',
|
||||
updaterDone: 'Download abgeschlossen',
|
||||
updaterShowFolder: 'Im Ordner anzeigen',
|
||||
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -347,6 +414,7 @@ export const deTranslation = {
|
||||
servers: 'Server',
|
||||
serverName: 'Server-Name',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
|
||||
serverUsername: 'Benutzername',
|
||||
serverPassword: 'Passwort',
|
||||
addServer: 'Server hinzufügen',
|
||||
@@ -362,6 +430,11 @@ export const deTranslation = {
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
|
||||
audiomuseIssueHint:
|
||||
'Instant Mix ist kürzlich fehlgeschlagen — Navidrome-Plugin und AudioMuse-API prüfen. Ohne Server-Treffer werden ähnliche Künstler über Last.fm geladen.',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -426,6 +499,13 @@ export const deTranslation = {
|
||||
hotCacheDebounceImmediate: 'Sofort',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Hot-Cache leeren',
|
||||
audioOutputDevice: 'Audio-Ausgabegerät',
|
||||
audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.',
|
||||
audioOutputDeviceDefault: 'Systemstandard',
|
||||
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
|
||||
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
|
||||
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
|
||||
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
|
||||
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
||||
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
|
||||
@@ -437,12 +517,21 @@ export const deTranslation = {
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
useCustomTitlebar: 'Eigene Titelleiste',
|
||||
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
|
||||
discordAppleCovers: 'Cover über Apple Music für Discord laden',
|
||||
discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
|
||||
enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.',
|
||||
lyricsSourcesTitle: 'Lyrics-Quellen',
|
||||
lyricsSourcesDesc: 'Wähle, welche Quellen für Lyrics abgefragt werden und in welcher Reihenfolge. Ziehen zum Sortieren. Deaktivierte Quellen werden übersprungen.',
|
||||
lyricsSourceServer: 'Server',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
@@ -452,7 +541,6 @@ export const deTranslation = {
|
||||
logout: 'Abmelden',
|
||||
aboutTitle: 'Über Psysonic',
|
||||
aboutDesc: 'Ein moderner Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Basiert auf Tauri v2 mit einer nativen Rust-Audio-Engine — schlank und schnell, aber vollgepackt mit Features: Wellenform-Seekbar, synchronisierte Lyrics, Last.fm-Integration, 10-Band-EQ, Crossfade, nahtlose Wiedergabe, Replay Gain, Genre-Browser und eine große Theme-Bibliothek.',
|
||||
aboutFeatures: 'Multi-Server · Last.fm Scrobbling & Love · Vollbild Ambient Stage · Wellenform-Seek · Sync-Lyrics · 10-Band-EQ · Crossfade & Gapless · Replay Gain · Genres · 63 Themes · 5 Sprachen',
|
||||
aboutLicense: 'Lizenz',
|
||||
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
@@ -495,10 +583,23 @@ export const deTranslation = {
|
||||
shortcutSeekForward: '10s vorspulen',
|
||||
shortcutSeekBackward: '10s zurückspulen',
|
||||
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
|
||||
shortcutOpenFolderBrowser: '{{folderBrowser}} öffnen',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
ratingsSectionTitle: 'Bewertungen',
|
||||
ratingsSkipStarTitle: 'Skip → 1 Stern',
|
||||
ratingsSkipStarDesc:
|
||||
'Wird ein Titel mehrmals hintereinander übersprungen, bekommt er automatisch 1★. Nur für noch nicht bewertete Titel.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips',
|
||||
ratingsMixFilterTitle: 'Nach Bewertung filtern',
|
||||
ratingsMixFilterDesc:
|
||||
'Gering bewertete Titel in {{mix}} und {{albums}} ausblenden. Nochmals auf den gewählten Stern klicken, um den Filter zu deaktivieren.',
|
||||
ratingsMixMinSong: 'Titel',
|
||||
ratingsMixMinAlbum: 'Alben',
|
||||
ratingsMixMinArtist: 'Interpreten',
|
||||
ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
@@ -514,6 +615,8 @@ export const deTranslation = {
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
|
||||
replayGainFallback: 'Fallback (ohne Tags / Radio)',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
@@ -523,6 +626,9 @@ export const deTranslation = {
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
nextTrackBufferingTitle: 'Nächster Track – Pufferung',
|
||||
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck – nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
|
||||
preloadOff: 'Aus',
|
||||
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
|
||||
preloadCustom: 'Benutzerdefiniert',
|
||||
@@ -530,6 +636,32 @@ export const deTranslation = {
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
fsPlayerSection: 'Vollbild-Player',
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
seekbarWaveform: 'Wellenform',
|
||||
seekbarLinedot: 'Linie & Punkt',
|
||||
seekbarBar: 'Balken',
|
||||
seekbarThick: 'Dicker Balken',
|
||||
seekbarSegmented: 'Segmentiert',
|
||||
seekbarNeon: 'Neonröhre',
|
||||
seekbarPulsewave: 'Pulswelle',
|
||||
seekbarParticletrail: 'Partikel-Spur',
|
||||
seekbarLiquidfill: 'Flüssigkeit',
|
||||
seekbarRetrotape: 'Retro-Band',
|
||||
themeSchedulerTitle: 'Theme-Zeitplan',
|
||||
themeSchedulerEnable: 'Theme-Zeitplan aktivieren',
|
||||
themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes basierend auf der Uhrzeit',
|
||||
themeSchedulerDayTheme: 'Tages-Theme',
|
||||
themeSchedulerDayStart: 'Tag beginnt um',
|
||||
themeSchedulerNightTheme: 'Nacht-Theme',
|
||||
themeSchedulerNightStart: 'Nacht beginnt um',
|
||||
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
|
||||
uiScaleTitle: 'Interface-Skalierung',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Was ist neu',
|
||||
@@ -612,6 +744,28 @@ export const deTranslation = {
|
||||
a35: 'Album öffnen und auf das Download-Symbol im Album-Header klicken. Psysonic lädt alle Tracks im Hintergrund herunter. Der Fortschritt wird am Button angezeigt. Nach dem Download wird das Symbol grün. Gecachte Alben können in der Offline-Bibliothek (Seitenleiste) verwaltet und gelöscht werden.',
|
||||
q36: 'Wie viel Speicherplatz darf der Cache belegen?',
|
||||
a36: 'In Einstellungen → Bibliothek kann eine maximale Cache-Größe festgelegt werden. Wird das Limit erreicht, erscheint ein Warnbanner auf der Albumseite. Einzelne Alben können in der Offline-Bibliothek gelöscht werden, um Speicherplatz freizugeben.',
|
||||
q37: 'Wie bewerte ich Songs, Alben und Künstler?',
|
||||
a37: 'Psysonic unterstützt 1–5-Sterne-Bewertungen über die OpenSubsonic-API (erfordert Navidrome ≥ 0.53). Songs können in der Trackliste eines Albums oder einer Playlist (Spalte „Bewertung"), über das Rechtsklick-Kontextmenü oder direkt in der Playerleiste unterhalb des Künstlernamens bewertet werden. Alben werden auf der Albumdetailseite bewertet, Künstler auf der Künstlerseite. Bewertungen werden sofort an den Server übertragen.',
|
||||
q38: 'Kann ich eine Bewertung entfernen oder ändern?',
|
||||
a38: 'Ja. Ein erneuter Klick auf den bereits aktiven Stern entfernt die Bewertung vollständig – der zweite Klick auf denselben Stern löscht sie also. Um eine Bewertung zu ändern, einfach einen anderen Stern anklicken.',
|
||||
q39: 'Was ist Skip-to-1★ und der Bewertungsfilter?',
|
||||
a39: 'Skip-to-1★ vergibt automatisch eine 1-Stern-Bewertung, wenn ein Song mehrfach hintereinander manuell übersprungen wird. Aktivierung und Schwellenwert in Einstellungen → Bewertungen. Dort lässt sich auch eine Mindestbewertung für Zufalls-Mix und Zufallsalben festlegen – so werden nur Songs, Alben oder Künstler ab einer bestimmten Sternzahl in generierten Mixes berücksichtigt.',
|
||||
q40: 'Was ist der Ordner-Browser?',
|
||||
a40: 'Der Ordner-Browser (Sidebar) ermöglicht die Navigation im Musikverzeichnis des Servers über ein Miller-Column-Layout. Ein Klick auf einen Ordner öffnet dessen Inhalt; über das Play-Symbol lassen sich Ordner direkt abspielen oder zur Warteschlange hinzufügen.',
|
||||
q41: 'Was ist der Theme-Scheduler?',
|
||||
a41: 'Einstellungen → Darstellung → Theme automatisch wechseln: Tagesthema und Nachtthema mit Startzeiten konfigurieren. Psysonic wechselt automatisch zur eingestellten Uhrzeit. Ist der Scheduler aktiv, erscheint im Theme-Picker ein Hinweis, warum manuelle Änderungen keine sofortige Wirkung haben.',
|
||||
q42: 'Kann ich die Benutzeroberfläche vergrößern oder verkleinern?',
|
||||
a42: 'Ja. Einstellungen → Darstellung → Anzeigeskalierung ermöglicht eine Skalierung der gesamten Oberfläche zwischen 80 % und 125 %, unabhängig von der Systemschriftgröße.',
|
||||
q43: 'Kann ich den Stil der Seekbar ändern?',
|
||||
a43: 'Ja. Einstellungen → Darstellung → Seekbar-Stil bietet 10 Stile: Wellenform, Linie & Punkt, Balken, Dicker Balken, Segmentiert, Neon-Glow, Pulswelle, Partikelspur, Flüssigfüllung und Retro-Tape.',
|
||||
q44: 'Was ist AutoEQ?',
|
||||
a44: 'Der 10-Band-EQ in Einstellungen → Audio enthält eine AutoEQ-Suche. Kopfhörermodell eingeben – Psysonic lädt das Korrekturprofil aus der AutoEQ-Datenbank und überträgt es automatisch auf die EQ-Bänder.',
|
||||
q45: 'Was ist Replay Gain?',
|
||||
a45: 'Replay Gain normalisiert die Lautstärke, sodass laute und leise Alben auf einem gleichmäßigen Pegel wiedergegeben werden. Aktivierung in Einstellungen → Audio → Replay Gain; Modus Track (pro Song) oder Album (behält die relative Dynamik innerhalb eines Albums).',
|
||||
q46: 'Was ist der Hot Cache?',
|
||||
a46: 'Der Hot Cache (Alpha, Einstellungen → Bibliothek) lädt die nächsten Songs der Warteschlange vorab auf die Festplatte, damit die Wiedergabe ohne Pufferverzögerung startet – besonders nützlich bei langsamen oder entfernten Servern. Psysonic hält den aktuellen Track und die nächsten 5 im Cache und verdrängt ältere Einträge, wenn das Größenlimit erreicht wird. Cache-Größe und Debounce-Verzögerung sind konfigurierbar.',
|
||||
q47: 'Kann ich eine Playlist offline cachen?',
|
||||
a47: 'Ja. In den Playlist-Details auf das Download-Symbol klicken – alle Tracks werden im Hintergrund heruntergeladen. Ist die Playlist vollständig gecacht, wechselt das Symbol zu einem roten Papierkorb; ein erneuter Klick entfernt die Playlist aus dem Offline-Cache.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Warteschlange',
|
||||
@@ -652,6 +806,7 @@ export const deTranslation = {
|
||||
genreDistribution: 'Genre-Verteilung (Top 20)',
|
||||
loadMore: 'Mehr laden',
|
||||
statArtists: 'Künstler',
|
||||
statArtistsTooltip: 'Nur Album-Künstler — Künstler, die ausschließlich als Track-Künstler vorkommen (Featured, Gast usw.) und kein eigenes Album haben, werden nicht gezählt.',
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
@@ -685,6 +840,10 @@ export const deTranslation = {
|
||||
lfmMinutesAgo: 'vor {{n}} Min.',
|
||||
lfmHoursAgo: 'vor {{n}} Std.',
|
||||
lfmDaysAgo: 'vor {{n}} Tagen',
|
||||
topRatedSongs: 'Bestbewertete Songs',
|
||||
topRatedArtists: 'Bestbewertete Künstler',
|
||||
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
|
||||
noRatedArtists: 'Noch keine bewerteten Künstler.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
@@ -711,6 +870,7 @@ export const deTranslation = {
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
lyricsSourceServer: 'Quelle: Server',
|
||||
lyricsSourceLrclib: 'Quelle: LRCLIB',
|
||||
lyricsSourceNetease: 'Quelle: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
@@ -762,8 +922,10 @@ export const deTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
addSelected: 'Ausgewählte hinzufügen',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
removeOffline: 'Aus Offline-Cache entfernen',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
publicLabel: 'Öffentlich',
|
||||
privateLabel: 'Privat',
|
||||
@@ -779,6 +941,33 @@ export const deTranslation = {
|
||||
coverUpdated: 'Cover aktualisiert',
|
||||
metaSaved: 'Playlist aktualisiert',
|
||||
downloadZip: 'Herunterladen (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
|
||||
addPartial: '{{added}} hinzugefügt, {{skipped}} übersprungen (Duplikate)',
|
||||
addAllSkipped: 'Alle übersprungen ({{count}} Duplikate)',
|
||||
addError: 'Fehler beim Hinzufügen von Songs',
|
||||
createAndAddSuccess: 'Playlist "{{playlist}}" mit {{count}} Songs erstellt',
|
||||
createError: 'Fehler beim Erstellen der Playlist',
|
||||
deleteSuccess: '{{count}} Playlists gelöscht',
|
||||
deleteFailed: 'Fehler beim Löschen von {{name}}',
|
||||
deleteSelected: 'Ausgewählte löschen',
|
||||
mergeSuccess: '{{count}} Songs in {{playlist}} zusammengeführt',
|
||||
mergeNoNewSongs: 'Keine neuen Songs zum Hinzufügen',
|
||||
mergeError: 'Fehler beim Zusammenführen von Playlists',
|
||||
mergeInto: 'Zusammenführen in',
|
||||
selectionCount: '{{count}} ausgewählt',
|
||||
select: 'Auswählen',
|
||||
startSelect: 'Auswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
loadingAlbums: '{{count}} Alben werden aufgelöst…',
|
||||
loadingArtists: '{{count}} Künstler werden aufgelöst…',
|
||||
myPlaylists: 'Meine Playlists',
|
||||
noOtherPlaylists: 'Keine anderen Playlists verfügbar',
|
||||
addToPlaylistSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
|
||||
addToPlaylistNoNew: 'Keine neuen Songs für {{playlist}}',
|
||||
addToPlaylistError: 'Fehler beim Hinzufügen zur Playlist',
|
||||
removeSuccess: 'Song aus Playlist entfernt',
|
||||
removeError: 'Fehler beim Entfernen aus der Playlist',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meistgehört',
|
||||
@@ -789,6 +978,9 @@ export const deTranslation = {
|
||||
sortLeast: 'Wenigste Plays zuerst',
|
||||
loadMore: 'Mehr Alben laden',
|
||||
noData: 'Noch keine Wiedergabedaten. Fang an zu hören!',
|
||||
noArtists: 'Alle Künstler herausgefiltert.',
|
||||
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
|
||||
filterCompilationsShort: 'Sampler ausblenden',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -820,5 +1012,13 @@ export const deTranslation = {
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
listenerCount_one: '{{count}} Hörer',
|
||||
listenerCount_other: '{{count}} Hörer',
|
||||
recentlyPlayed: 'Zuletzt gespielt',
|
||||
upNext: 'Als Nächstes',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Leerer Ordner',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const enTranslation = {
|
||||
newReleases: 'New Releases',
|
||||
allAlbums: 'All Albums',
|
||||
randomAlbums: 'Random Albums',
|
||||
randomPicker: 'Build a Mix',
|
||||
artists: 'Artists',
|
||||
randomMix: 'Random Mix',
|
||||
favorites: 'Favorites',
|
||||
@@ -17,13 +18,17 @@ export const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
cancelDownload: 'Cancel download',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
expandPlaylists: 'Expand playlists',
|
||||
collapsePlaylists: 'Collapse playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -93,6 +98,8 @@ export const enTranslation = {
|
||||
addToQueue: 'Add to Queue',
|
||||
enqueueAlbum: 'Enqueue Album',
|
||||
startRadio: 'Start Radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||
lfmLove: 'Love on Last.fm',
|
||||
lfmUnlove: 'Unlove on Last.fm',
|
||||
favorite: 'Favorite',
|
||||
@@ -102,10 +109,14 @@ export const enTranslation = {
|
||||
unfavoriteArtist: 'Remove Artist from Favorites',
|
||||
unfavoriteAlbum: 'Remove Album from Favorites',
|
||||
removeFromQueue: 'Remove from Queue',
|
||||
removeFromPlaylist: 'Remove from Playlist',
|
||||
openAlbum: 'Open Album',
|
||||
goToArtist: 'Go to Artist',
|
||||
download: 'Download (ZIP)',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
selectedPlaylists: '{{count}} playlists selected',
|
||||
selectedAlbums: '{{count}} albums selected',
|
||||
selectedArtists: '{{count}} artists selected',
|
||||
songInfo: 'Song Info',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -132,6 +143,7 @@ export const enTranslation = {
|
||||
goToArtist: 'Go to {{artist}}',
|
||||
moreLabelAlbums: 'More albums on {{label}}',
|
||||
trackTitle: 'Title',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artist',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
@@ -145,6 +157,18 @@ export const enTranslation = {
|
||||
bioClose: 'Close',
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
filterSongs: 'Filter songs…',
|
||||
sortNatural: 'Natural',
|
||||
sortByTitle: 'A–Z (Title)',
|
||||
sortByArtist: 'A–Z (Artist)',
|
||||
sortByAlbum: 'A–Z (Album)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Album rating',
|
||||
artistShort: 'Artist rating',
|
||||
albumAriaLabel: 'Album rating',
|
||||
artistAriaLabel: 'Artist rating',
|
||||
saveFailed: 'Could not save rating.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
@@ -187,6 +211,13 @@ export const enTranslation = {
|
||||
removeSong: 'Remove from favorites',
|
||||
stations: 'Radio Stations',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Build a Mix',
|
||||
mixByTracks: 'Mix by Tracks',
|
||||
mixByTracksDesc: 'Random selection of tracks from your entire library',
|
||||
mixByAlbums: 'Mix by Albums',
|
||||
mixByAlbumsDesc: 'Random album picks for your next discovery',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
refresh: 'Refresh',
|
||||
@@ -207,6 +238,8 @@ export const enTranslation = {
|
||||
title: 'Random Mix',
|
||||
remix: 'Remix',
|
||||
remixTooltip: 'Load new random songs',
|
||||
remixGenre: 'Remix {{genre}}',
|
||||
remixTooltipGenre: 'Load new {{genre}} songs',
|
||||
playAll: 'Play All',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
@@ -228,6 +261,7 @@ export const enTranslation = {
|
||||
blacklistToggle: 'Keyword Filter',
|
||||
genreMixTitle: 'Genre Mix',
|
||||
genreMixDesc: 'Top 20 genres by song count — click to load a random mix',
|
||||
genreMixAll: 'All Songs',
|
||||
genreMixLoadMore: 'Load 10 more',
|
||||
genreMixNoGenres: 'No genres found on server.',
|
||||
shuffleGenres: 'Show different genres',
|
||||
@@ -245,6 +279,17 @@ export const enTranslation = {
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
selectionCount: '{{count}} selected',
|
||||
downloadZips: 'Download ZIPs',
|
||||
addOffline: 'Add Offline',
|
||||
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) downloaded',
|
||||
downloadZipFailed: 'Failed to download {{name}}',
|
||||
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
|
||||
offlineFailed: 'Failed to add {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artists',
|
||||
@@ -258,13 +303,18 @@ export const enTranslation = {
|
||||
notFound: 'No artists found.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
albumCount_other: '{{count}} Albums',
|
||||
selectionCount: '{{count}} selected',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Your Navidrome Desktop Player',
|
||||
serverName: 'Server Name (optional)',
|
||||
serverNamePlaceholder: 'My Navidrome',
|
||||
serverUrl: 'Server URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Password',
|
||||
@@ -288,6 +338,7 @@ export const enTranslation = {
|
||||
offlineTitle: 'No server connection',
|
||||
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
|
||||
offlineModeBanner: 'Offline Mode — playing from local cache',
|
||||
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
|
||||
offlineLibraryTitle: 'Offline Library',
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
@@ -297,6 +348,7 @@ export const enTranslation = {
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
serverSettings: 'Server Settings',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
},
|
||||
@@ -325,12 +377,27 @@ export const enTranslation = {
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
bulkSelected: '{{count}} selected',
|
||||
clearSelection: 'Clear selection',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||
bulkClear: 'Clear selection',
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is available',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'New Version Available',
|
||||
updaterChangelog: "What's New",
|
||||
updaterDownloadBtn: 'Download Now',
|
||||
updaterSkipBtn: 'Skip this Version',
|
||||
updaterRemindBtn: 'Remind me Later',
|
||||
updaterDone: 'Download complete',
|
||||
updaterShowFolder: 'Show in Folder',
|
||||
updaterInstallHint: 'Close Psysonic and run the installer manually.',
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -342,12 +409,14 @@ export const enTranslation = {
|
||||
languageZh: 'Chinese',
|
||||
languageNb: 'Norwegian',
|
||||
languageRu: 'Russian',
|
||||
languageEs: 'Spanish',
|
||||
font: 'Font',
|
||||
theme: 'Theme',
|
||||
appearance: 'Appearance',
|
||||
servers: 'Servers',
|
||||
serverName: 'Server Name',
|
||||
serverUrl: 'Server URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
|
||||
serverUsername: 'Username',
|
||||
serverPassword: 'Password',
|
||||
addServer: 'Add Server',
|
||||
@@ -363,6 +432,11 @@ export const enTranslation = {
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
|
||||
audiomuseIssueHint:
|
||||
'Instant Mix failed recently — check the Navidrome plugin and AudioMuse API. Similar artists fall back to Last.fm when the server returns none.',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -427,6 +501,13 @@ export const enTranslation = {
|
||||
hotCacheDebounceImmediate: 'Immediate',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Clear hot cache',
|
||||
audioOutputDevice: 'Audio Output Device',
|
||||
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
|
||||
audioOutputDeviceDefault: 'System Default',
|
||||
audioOutputDeviceRefresh: 'Refresh device list',
|
||||
audioOutputDeviceOsDefaultNow: 'current system output',
|
||||
audioOutputDeviceListError: 'Could not load the audio device list.',
|
||||
audioOutputDeviceNotInCurrentList: 'not in current list',
|
||||
hiResTitle: 'Native Hi-Res Playback',
|
||||
hiResEnabled: 'Enable native hi-res playback',
|
||||
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
|
||||
@@ -438,12 +519,21 @@ export const enTranslation = {
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
useCustomTitlebar: 'Custom title bar',
|
||||
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
|
||||
discordAppleCovers: 'Fetch covers from Apple Music for Discord',
|
||||
discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music lyrics',
|
||||
enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.',
|
||||
lyricsSourcesTitle: 'Lyrics Sources',
|
||||
lyricsSourcesDesc: 'Choose which sources to query for lyrics and in what order. Drag to reorder. Disabled sources are skipped entirely.',
|
||||
lyricsSourceServer: 'Server',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
@@ -453,7 +543,6 @@ export const enTranslation = {
|
||||
logout: 'Logout',
|
||||
aboutTitle: 'About Psysonic',
|
||||
aboutDesc: 'A modern desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Built on Tauri v2 with a native Rust audio engine — lightweight and fast, yet packed with features: waveform seekbar, synchronized lyrics, Last.fm integration, 10-band EQ, crossfade, gapless playback, Replay Gain, genre browsing, and a large library of themes.',
|
||||
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Synced lyrics · 10-band EQ · Crossfade & Gapless · Replay Gain · Genres · 63 themes · 5 languages',
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
@@ -484,6 +573,18 @@ export const enTranslation = {
|
||||
tabServer: 'Server',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
ratingsSectionTitle: 'Ratings',
|
||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||
ratingsSkipStarDesc:
|
||||
'After several skips in a row, set the track to 1★. Only for tracks not yet rated.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips',
|
||||
ratingsMixFilterTitle: 'Filter by rating',
|
||||
ratingsMixFilterDesc:
|
||||
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
|
||||
ratingsMixMinSong: 'Songs',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artists',
|
||||
ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
@@ -507,6 +608,7 @@ export const enTranslation = {
|
||||
shortcutSeekForward: 'Seek forward 10s',
|
||||
shortcutSeekBackward: 'Seek backward 10s',
|
||||
shortcutToggleQueue: 'Toggle queue',
|
||||
shortcutOpenFolderBrowser: 'Open {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Fullscreen player',
|
||||
shortcutNativeFullscreen: 'Native fullscreen',
|
||||
playbackTitle: 'Playback',
|
||||
@@ -515,6 +617,8 @@ export const enTranslation = {
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (tagged files)',
|
||||
replayGainFallback: 'Fallback (untagged / radio)',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
@@ -524,6 +628,9 @@ export const enTranslation = {
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
nextTrackBufferingTitle: 'Next Track Buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
|
||||
preloadOff: 'Off',
|
||||
preloadBalanced: 'Balanced (30 s before end)',
|
||||
preloadEarly: 'Early (after 5 s of playback)',
|
||||
preloadCustom: 'Custom',
|
||||
@@ -531,6 +638,32 @@ export const enTranslation = {
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Fullscreen Player',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
seekbarStyle: 'Seekbar Style',
|
||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||
seekbarWaveform: 'Waveform',
|
||||
seekbarLinedot: 'Line & Dot',
|
||||
seekbarBar: 'Bar',
|
||||
seekbarThick: 'Thick Bar',
|
||||
seekbarSegmented: 'Segmented',
|
||||
seekbarNeon: 'Neon Glow',
|
||||
seekbarPulsewave: 'Pulse Wave',
|
||||
seekbarParticletrail: 'Particle Trail',
|
||||
seekbarLiquidfill: 'Liquid Fill',
|
||||
seekbarRetrotape: 'Retro Tape',
|
||||
themeSchedulerTitle: 'Auto-Switch Theme',
|
||||
themeSchedulerEnable: 'Enable Theme Scheduler',
|
||||
themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day',
|
||||
themeSchedulerDayTheme: 'Day Theme',
|
||||
themeSchedulerDayStart: 'Day Starts At',
|
||||
themeSchedulerNightTheme: 'Night Theme',
|
||||
themeSchedulerNightStart: 'Night Starts At',
|
||||
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
|
||||
uiScaleTitle: 'Interface Scale',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "What's New",
|
||||
@@ -568,7 +701,7 @@ export const enTranslation = {
|
||||
q12: 'How do I change the theme?',
|
||||
a12: 'Settings → Theme. Choose from a large selection of themes across 8 groups: Psysonic Themes, Mediaplayer, Operating Systems, Games, Movies, Series, Social Media, and Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox).',
|
||||
q13: 'How do I change the language?',
|
||||
a13: 'Settings → Language. English, German, French, Dutch, Chinese, Norwegian, and Russian are supported.',
|
||||
a13: 'Settings → Language. English, German, Spanish, French, Dutch, Chinese, Norwegian, and Russian are supported.',
|
||||
q15: 'How do I set a download folder?',
|
||||
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
|
||||
s5: 'Scrobbling',
|
||||
@@ -613,6 +746,28 @@ export const enTranslation = {
|
||||
a35: 'Open any album and click the download icon in the album header. Psysonic downloads all tracks in the background. Progress is shown on the button. Once cached, the icon turns green. You can view and remove cached albums in the Offline Library page (sidebar).',
|
||||
q36: 'How much storage can offline caching use?',
|
||||
a36: 'You can set a maximum cache size in Settings → Library. When the limit is reached, a warning banner appears on the album page. You can delete individual albums from the Offline Library to free up space.',
|
||||
q37: 'How do I rate songs, albums, and artists?',
|
||||
a37: 'Psysonic supports 1–5 star ratings via the OpenSubsonic API (requires Navidrome ≥ 0.53). Rate songs in the album or playlist track list using the star column, in the right-click context menu, or directly in the player bar below the artist name. Albums can be rated on their detail page; artists on their artist page. Ratings are saved to the server immediately.',
|
||||
q38: 'Can I remove or change a rating?',
|
||||
a38: 'Yes. Click the currently active star to clear the rating entirely — clicking the same star a second time removes it. To change a rating, simply click a different star.',
|
||||
q39: 'What is Skip-to-1★ and the rating filter?',
|
||||
a39: 'Skip-to-1★ automatically assigns a 1-star rating to a song after you manually skip it a configurable number of consecutive times. Enable it and set the skip threshold in Settings → Ratings. The same settings panel lets you set a minimum star rating for Random Mix and Random Albums, so only songs, albums, or artists above a certain rating are included in generated mixes.',
|
||||
q40: 'What is the Folder Browser?',
|
||||
a40: 'The Folder Browser (sidebar) lets you navigate your server\'s music directory tree using a Miller-column layout. Click a folder to drill into it; click a track or the play icon on a folder to play or enqueue its contents directly.',
|
||||
q41: 'What is the Theme Scheduler?',
|
||||
a41: 'Settings → Appearance → Auto-Switch Theme: define a day theme and a night theme with start times. Psysonic switches automatically at the configured hours. When the scheduler is active, a hint in the theme picker explains why manual theme selection has no immediate effect.',
|
||||
q42: 'Can I resize the interface?',
|
||||
a42: 'Yes. Settings → Appearance → Interface Scale lets you scale the entire UI between 80 % and 125 % without affecting your system font size.',
|
||||
q43: 'Can I change the seekbar style?',
|
||||
a43: 'Yes. Settings → Appearance → Seekbar Style offers 10 styles: Waveform, Line & Dot, Bar, Thick Bar, Segmented, Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.',
|
||||
q44: 'What is AutoEQ?',
|
||||
a44: 'The 10-band EQ in Settings → Audio includes an AutoEQ lookup. Enter your headphone model and Psysonic fetches a correction profile from the AutoEQ database and applies it automatically to the equalizer bands.',
|
||||
q45: 'What is Replay Gain?',
|
||||
a45: 'Replay Gain normalizes track volume so loud and quiet albums play at a consistent level. Enable it in Settings → Audio → Replay Gain and choose Track mode (per-song normalization) or Album mode (preserves relative dynamics within an album).',
|
||||
q46: 'What is the Hot Cache?',
|
||||
a46: 'Hot Cache (Alpha, Settings → Library) preloads the next several tracks in your queue to disk so playback starts instantly with no buffering delay — especially useful on slow or remote servers. Psysonic keeps the current track and the next 5 in cache and evicts older entries when the size limit is reached. You can set the maximum cache size and a debounce delay to avoid unnecessary fetches when skipping quickly.',
|
||||
q47: 'Can I cache a playlist for offline use?',
|
||||
a47: 'Yes. Open any playlist and click the download icon in the playlist header. All tracks are downloaded in the background — progress is shown in the offline download indicator. Once fully cached, the icon changes to a red trash icon; clicking it removes the playlist from the offline cache.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Queue',
|
||||
@@ -653,6 +808,7 @@ export const enTranslation = {
|
||||
genreDistribution: 'Genre Distribution (Top 20)',
|
||||
loadMore: 'Load more',
|
||||
statArtists: 'Artists',
|
||||
statArtistsTooltip: 'Album artists only — artists appearing only as a track-level artist (featured, guest, etc.) without their own album are not included.',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
@@ -686,6 +842,10 @@ export const enTranslation = {
|
||||
lfmMinutesAgo: '{{n}}m ago',
|
||||
lfmHoursAgo: '{{n}}h ago',
|
||||
lfmDaysAgo: '{{n}}d ago',
|
||||
topRatedSongs: 'Top Rated Songs',
|
||||
topRatedArtists: 'Top Rated Artists',
|
||||
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
|
||||
noRatedArtists: 'No rated artists yet.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
@@ -712,6 +872,7 @@ export const enTranslation = {
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
lyricsSourceServer: 'Source: Server',
|
||||
lyricsSourceLrclib: 'Source: LRCLIB',
|
||||
lyricsSourceNetease: 'Source: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
@@ -763,8 +924,10 @@ export const enTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
addSelected: 'Add selected',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
removeOffline: 'Remove from offline cache',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Public',
|
||||
privateLabel: 'Private',
|
||||
@@ -780,6 +943,33 @@ export const enTranslation = {
|
||||
coverUpdated: 'Cover updated',
|
||||
metaSaved: 'Playlist updated',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} songs added to {{playlist}}',
|
||||
addPartial: '{{added}} added, {{skipped}} skipped (duplicates)',
|
||||
addAllSkipped: 'All skipped ({{count}} duplicates)',
|
||||
addError: 'Error adding songs',
|
||||
createAndAddSuccess: 'Playlist "{{playlist}}" created with {{count}} songs',
|
||||
createError: 'Error creating playlist',
|
||||
deleteSuccess: '{{count}} playlists deleted',
|
||||
deleteFailed: 'Error deleting {{name}}',
|
||||
deleteSelected: 'Delete selected',
|
||||
mergeSuccess: '{{count}} songs merged into {{playlist}}',
|
||||
mergeNoNewSongs: 'No new songs to add',
|
||||
mergeError: 'Error merging playlists',
|
||||
mergeInto: 'Merge into',
|
||||
selectionCount: '{{count}} selected',
|
||||
select: 'Select',
|
||||
startSelect: 'Enable selection',
|
||||
cancelSelect: 'Cancel',
|
||||
loadingAlbums: 'Resolving {{count}} albums…',
|
||||
loadingArtists: 'Resolving {{count}} artists…',
|
||||
myPlaylists: 'My Playlists',
|
||||
noOtherPlaylists: 'No other playlists available',
|
||||
addToPlaylistSuccess: '{{count}} songs added to {{playlist}}',
|
||||
addToPlaylistNoNew: 'No new songs to add to {{playlist}}',
|
||||
addToPlaylistError: 'Error adding to playlist',
|
||||
removeSuccess: 'Song removed from playlist',
|
||||
removeError: 'Error removing song from playlist',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Most Played',
|
||||
@@ -790,6 +980,9 @@ export const enTranslation = {
|
||||
sortLeast: 'Fewest plays first',
|
||||
loadMore: 'Load more albums',
|
||||
noData: 'No play data yet. Start listening!',
|
||||
noArtists: 'All artists filtered out.',
|
||||
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Hide compilations',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
@@ -821,5 +1014,13 @@ export const enTranslation = {
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
listenerCount_one: '{{count}} listener',
|
||||
listenerCount_other: '{{count}} listeners',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
upNext: 'Up Next',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Empty folder',
|
||||
error: 'Failed to load',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const frTranslation = {
|
||||
newReleases: 'Nouveautés',
|
||||
allAlbums: 'Tous les albums',
|
||||
randomAlbums: 'Albums aléatoires',
|
||||
randomPicker: 'Créer un mix',
|
||||
artists: 'Artistes',
|
||||
randomMix: 'Mix aléatoire',
|
||||
favorites: 'Favoris',
|
||||
@@ -16,13 +17,17 @@ export const frTranslation = {
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
cancelDownload: 'Annuler le téléchargement',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
folderBrowser: 'Explorateur de dossiers',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
expandPlaylists: 'Développer les playlists',
|
||||
collapsePlaylists: 'Réduire les playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
@@ -92,6 +97,8 @@ export const frTranslation = {
|
||||
addToQueue: 'Ajouter à la file',
|
||||
enqueueAlbum: 'Mettre l\'album en file',
|
||||
startRadio: 'Démarrer la radio',
|
||||
instantMix: 'Mix instantané',
|
||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
||||
lfmLove: 'Aimer sur Last.fm',
|
||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
||||
favorite: 'Favori',
|
||||
@@ -101,10 +108,14 @@ export const frTranslation = {
|
||||
unfavoriteArtist: 'Retirer l\'artiste des favoris',
|
||||
unfavoriteAlbum: 'Retirer l\'album des favoris',
|
||||
removeFromQueue: 'Retirer de la file',
|
||||
removeFromPlaylist: 'Retirer de la playlist',
|
||||
openAlbum: 'Ouvrir l\'album',
|
||||
goToArtist: 'Aller à l\'artiste',
|
||||
download: 'Télécharger (ZIP)',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
selectedPlaylists: '{{count}} playlists sélectionnées',
|
||||
selectedAlbums: '{{count}} albums sélectionnés',
|
||||
selectedArtists: '{{count}} artistes sélectionnés',
|
||||
songInfo: 'Infos du morceau',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -131,6 +142,7 @@ export const frTranslation = {
|
||||
goToArtist: 'Aller à {{artist}}',
|
||||
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
|
||||
trackTitle: 'Titre',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artiste',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Format',
|
||||
@@ -144,6 +156,18 @@ export const frTranslation = {
|
||||
bioClose: 'Fermer',
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
filterSongs: 'Filtrer…',
|
||||
sortNatural: 'Naturel',
|
||||
sortByTitle: 'A–Z (Titre)',
|
||||
sortByArtist: 'A–Z (Artiste)',
|
||||
sortByAlbum: 'A–Z (Album)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Note de l’album',
|
||||
artistShort: 'Note de l’artiste',
|
||||
albumAriaLabel: 'Note de l’album',
|
||||
artistAriaLabel: 'Note de l’artiste',
|
||||
saveFailed: 'Impossible d’enregistrer la note.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
@@ -186,6 +210,13 @@ export const frTranslation = {
|
||||
removeSong: 'Retirer des favoris',
|
||||
stations: 'Stations de radio',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Créer un mix',
|
||||
mixByTracks: 'Mix par pistes',
|
||||
mixByTracksDesc: 'Sélection aléatoire de titres depuis toute votre médiathèque',
|
||||
mixByAlbums: 'Mix par albums',
|
||||
mixByAlbumsDesc: 'Albums aléatoires pour vos prochaines découvertes',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Albums aléatoires',
|
||||
refresh: 'Actualiser',
|
||||
@@ -206,6 +237,8 @@ export const frTranslation = {
|
||||
title: 'Mix aléatoire',
|
||||
remix: 'Remixer',
|
||||
remixTooltip: 'Charger de nouveaux morceaux aléatoires',
|
||||
remixGenre: 'Remixer {{genre}}',
|
||||
remixTooltipGenre: 'Charger de nouveaux morceaux {{genre}}',
|
||||
playAll: 'Tout lire',
|
||||
trackTitle: 'Titre',
|
||||
trackArtist: 'Artiste',
|
||||
@@ -227,6 +260,7 @@ export const frTranslation = {
|
||||
blacklistToggle: 'Filtre par mot-clé',
|
||||
genreMixTitle: 'Mix par genre',
|
||||
genreMixDesc: 'Top 20 genres par nombre de morceaux — cliquer pour un mix aléatoire',
|
||||
genreMixAll: 'Tous les morceaux',
|
||||
genreMixLoadMore: 'Charger 10 de plus',
|
||||
genreMixNoGenres: 'Aucun genre trouvé sur le serveur.',
|
||||
shuffleGenres: 'Afficher d\'autres genres',
|
||||
@@ -244,6 +278,17 @@ export const frTranslation = {
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
select: 'Sélection multiple',
|
||||
startSelect: 'Activer la sélection multiple',
|
||||
cancelSelect: 'Annuler',
|
||||
selectionCount: '{{count}} sélectionnés',
|
||||
downloadZips: 'Télécharger les ZIPs',
|
||||
addOffline: 'Ajouter hors ligne',
|
||||
downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) téléchargé(s)',
|
||||
downloadZipFailed: 'Échec du téléchargement de {{name}}',
|
||||
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
|
||||
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistes',
|
||||
@@ -257,13 +302,18 @@ export const frTranslation = {
|
||||
notFound: 'Aucun artiste trouvé.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
selectionCount: '{{count}} sélectionnés',
|
||||
select: 'Sélection multiple',
|
||||
startSelect: 'Activer la sélection multiple',
|
||||
cancelSelect: 'Annuler',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Votre lecteur de bureau Navidrome',
|
||||
serverName: 'Nom du serveur (facultatif)',
|
||||
serverNamePlaceholder: 'Mon Navidrome',
|
||||
serverUrl: 'URL du serveur',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 ou music.exemple.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
|
||||
username: 'Nom d\'utilisateur',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Mot de passe',
|
||||
@@ -287,6 +337,7 @@ export const frTranslation = {
|
||||
offlineTitle: 'Pas de connexion au serveur',
|
||||
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
|
||||
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
|
||||
offlineNoCacheBanner: 'Pas de connexion au serveur — {{server}} inaccessible',
|
||||
offlineLibraryTitle: 'Bibliothèque hors ligne',
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
@@ -296,6 +347,7 @@ export const frTranslation = {
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
serverSettings: 'Paramètres serveur',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
},
|
||||
@@ -324,12 +376,27 @@ export const frTranslation = {
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
clearSelection: 'Effacer la sélection',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||
bulkClear: 'Désélectionner',
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est disponible',
|
||||
updaterWebsite: 'Site Web',
|
||||
updaterModalTitle: 'Nouvelle version disponible',
|
||||
updaterChangelog: 'Nouveautés',
|
||||
updaterDownloadBtn: 'Télécharger maintenant',
|
||||
updaterSkipBtn: 'Ignorer cette version',
|
||||
updaterRemindBtn: 'Me rappeler plus tard',
|
||||
updaterDone: 'Téléchargement terminé',
|
||||
updaterShowFolder: 'Afficher dans le dossier',
|
||||
updaterInstallHint: 'Fermez Psysonic et lancez le programme d\'installation manuellement.',
|
||||
updaterAurHint: 'Installer la mise à jour via AUR :',
|
||||
updaterErrorMsg: 'Échec du téléchargement',
|
||||
updaterRetryBtn: 'Réessayer',
|
||||
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -347,6 +414,7 @@ export const frTranslation = {
|
||||
servers: 'Serveurs',
|
||||
serverName: 'Nom du serveur',
|
||||
serverUrl: 'URL du serveur',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
|
||||
serverUsername: 'Nom d\'utilisateur',
|
||||
serverPassword: 'Mot de passe',
|
||||
addServer: 'Ajouter un serveur',
|
||||
@@ -362,6 +430,11 @@ export const frTranslation = {
|
||||
testBtn: 'Tester la connexion',
|
||||
testingBtn: 'Test en cours…',
|
||||
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
|
||||
audiomuseIssueHint:
|
||||
'Le mix instantané a échoué récemment — vérifiez le plugin Navidrome et l’API AudioMuse. Les artistes similaires utilisent Last.fm si le serveur ne renvoie rien.',
|
||||
connected: 'Connecté',
|
||||
failed: 'Échec',
|
||||
eqTitle: 'Égaliseur',
|
||||
@@ -426,6 +499,13 @@ export const frTranslation = {
|
||||
hotCacheDebounceImmediate: 'Immédiat',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Vider le cache à chaud',
|
||||
audioOutputDevice: 'Périphérique de sortie audio',
|
||||
audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.',
|
||||
audioOutputDeviceDefault: 'Défaut système',
|
||||
audioOutputDeviceRefresh: 'Actualiser la liste',
|
||||
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
|
||||
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
|
||||
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
|
||||
hiResTitle: 'Lecture haute résolution native',
|
||||
hiResEnabled: 'Activer la lecture haute résolution native',
|
||||
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
|
||||
@@ -443,6 +523,13 @@ export const frTranslation = {
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
lyricsServerFirst: 'Préférer les paroles du serveur',
|
||||
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
|
||||
enableNeteaselyrics: 'Paroles Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.',
|
||||
lyricsSourcesTitle: 'Sources de paroles',
|
||||
lyricsSourcesDesc: 'Choisissez quelles sources interroger et dans quel ordre. Glissez pour réordonner. Les sources désactivées sont ignorées.',
|
||||
lyricsSourceServer: 'Serveur',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
@@ -452,7 +539,6 @@ export const frTranslation = {
|
||||
logout: 'Se déconnecter',
|
||||
aboutTitle: 'À propos de Psysonic',
|
||||
aboutDesc: 'Un lecteur de musique de bureau moderne pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Construit sur Tauri v2 avec un moteur audio Rust natif — léger et rapide, mais riche en fonctionnalités : barre waveform, paroles synchronisées, intégration Last.fm, égaliseur 10 bandes, crossfade, lecture sans blanc, Replay Gain, navigation par genre et une grande bibliothèque de thèmes.',
|
||||
aboutFeatures: 'Multi-serveur · Scrobbling & love Last.fm · Ambient Stage plein écran · Waveform seek · Paroles synchro · EQ 10 bandes · Crossfade & Gapless · Replay Gain · Genres · 63 thèmes · 5 langues',
|
||||
aboutLicense: 'Licence',
|
||||
aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.',
|
||||
aboutRepo: 'Code source sur GitHub',
|
||||
@@ -495,10 +581,23 @@ export const frTranslation = {
|
||||
shortcutSeekForward: 'Avancer de 10s',
|
||||
shortcutSeekBackward: 'Reculer de 10s',
|
||||
shortcutToggleQueue: 'Afficher/masquer la file',
|
||||
shortcutOpenFolderBrowser: 'Ouvrir {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
ratingsSectionTitle: 'Notes',
|
||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||
ratingsSkipStarDesc:
|
||||
"Après plusieurs sauts d’affilée, le morceau passe à 1 étoile. Uniquement s’il n’était pas encore noté.",
|
||||
ratingsSkipStarThresholdLabel: 'Sauts',
|
||||
ratingsMixFilterTitle: 'Filtrage par note',
|
||||
ratingsMixFilterDesc:
|
||||
"Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.",
|
||||
ratingsMixMinSong: 'Morceaux',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artistes',
|
||||
ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
@@ -514,6 +613,8 @@ export const frTranslation = {
|
||||
replayGainMode: 'Mode',
|
||||
replayGainTrack: 'Piste',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pré-Gain (fichiers taggés)',
|
||||
replayGainFallback: 'Repli (sans tags / radio)',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
@@ -523,6 +624,9 @@ export const frTranslation = {
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
nextTrackBufferingTitle: 'Piste suivante – mise en mémoire tampon',
|
||||
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
|
||||
preloadOff: 'Désactivé',
|
||||
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||
preloadEarly: 'Tôt (après 5 s de lecture)',
|
||||
preloadCustom: 'Personnalisé',
|
||||
@@ -530,6 +634,32 @@ export const frTranslation = {
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
fsPlayerSection: 'Lecteur plein écran',
|
||||
fsShowArtistPortrait: "Afficher la photo de l'artiste",
|
||||
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
|
||||
fsPortraitDim: "Assombrissement de la photo",
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
seekbarWaveform: 'Forme d\'onde',
|
||||
seekbarLinedot: 'Ligne & point',
|
||||
seekbarBar: 'Barre',
|
||||
seekbarThick: 'Barre épaisse',
|
||||
seekbarSegmented: 'Segmentée',
|
||||
seekbarNeon: 'Néon',
|
||||
seekbarPulsewave: 'Onde pulsée',
|
||||
seekbarParticletrail: 'Traînée de particules',
|
||||
seekbarLiquidfill: 'Tube liquide',
|
||||
seekbarRetrotape: 'Bande rétro',
|
||||
themeSchedulerTitle: 'Planificateur de thème',
|
||||
themeSchedulerEnable: 'Activer le planificateur de thème',
|
||||
themeSchedulerEnableSub: 'Bascule automatiquement entre deux thèmes selon l\'heure',
|
||||
themeSchedulerDayTheme: 'Thème de jour',
|
||||
themeSchedulerDayStart: 'Début du jour',
|
||||
themeSchedulerNightTheme: 'Thème de nuit',
|
||||
themeSchedulerNightStart: 'Début de la nuit',
|
||||
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
|
||||
uiScaleTitle: "Mise à l'échelle de l'interface",
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Quoi de neuf',
|
||||
@@ -612,6 +742,28 @@ export const frTranslation = {
|
||||
a35: 'Ouvrez un album et cliquez sur l\'icône de téléchargement dans l\'en-tête de l\'album. Psysonic télécharge toutes les pistes en arrière-plan. La progression est affichée sur le bouton. Une fois en cache, l\'icône devient verte. Les albums mis en cache peuvent être consultés et supprimés dans la page Bibliothèque hors ligne (barre latérale).',
|
||||
q36: 'Quelle quantité de stockage le cache hors ligne peut-il utiliser ?',
|
||||
a36: 'Vous pouvez définir une taille de cache maximale dans Paramètres → Bibliothèque. Lorsque la limite est atteinte, une bannière d\'avertissement s\'affiche sur la page de l\'album. Vous pouvez supprimer des albums individuels dans la Bibliothèque hors ligne pour libérer de l\'espace.',
|
||||
q37: 'Comment noter des morceaux, albums et artistes ?',
|
||||
a37: 'Psysonic prend en charge les notes de 1 à 5 étoiles via l\'API OpenSubsonic (Navidrome ≥ 0.53 requis). Notez les morceaux dans la liste de pistes d\'un album ou d\'une playlist, via le menu contextuel ou directement dans la barre du lecteur. Les albums sont notés sur leur page de détail, les artistes sur leur page.',
|
||||
q38: 'Puis-je supprimer ou modifier une note ?',
|
||||
a38: 'Oui. Cliquer à nouveau sur l\'étoile active supprime entièrement la note — un second clic sur la même étoile l\'efface. Pour modifier une note, cliquez simplement sur une étoile différente.',
|
||||
q39: 'Qu\'est-ce que Skip-to-1★ et le filtre de notes ?',
|
||||
a39: 'Skip-to-1★ attribue automatiquement une note d\'une étoile à un morceau après un certain nombre de sauts consécutifs. Activez-le dans Paramètres → Notes. Vous pouvez également y définir une note minimale pour les mix aléatoires.',
|
||||
q40: 'Qu\'est-ce que le Navigateur de dossiers ?',
|
||||
a40: 'Le Navigateur de dossiers (barre latérale) permet de parcourir l\'arborescence musicale du serveur en colonnes Miller. Cliquez sur un dossier pour l\'explorer ; utilisez l\'icône lecture pour jouer ou ajouter son contenu à la file.',
|
||||
q41: 'Qu\'est-ce que le planificateur de thème ?',
|
||||
a41: 'Paramètres → Apparence → Changer de thème automatiquement : définissez un thème de jour et un thème de nuit avec des heures de début. Psysonic bascule automatiquement à l\'heure configurée.',
|
||||
q42: 'Puis-je redimensionner l\'interface ?',
|
||||
a42: 'Oui. Paramètres → Apparence → Échelle de l\'interface permet de régler l\'interface entre 80 % et 125 %.',
|
||||
q43: 'Puis-je changer le style de la barre de progression ?',
|
||||
a43: 'Oui. Paramètres → Apparence → Style de la barre de progression offre 10 styles : Waveform, Ligne & Point, Barre, Barre épaisse, Segmenté, Neon Glow, Pulse Wave, Particle Trail, Liquid Fill et Retro Tape.',
|
||||
q44: 'Qu\'est-ce qu\'AutoEQ ?',
|
||||
a44: 'L\'égaliseur 10 bandes dans Paramètres → Audio comprend une recherche AutoEQ. Saisissez le modèle de votre casque pour charger automatiquement le profil de correction.',
|
||||
q45: 'Qu\'est-ce que le Replay Gain ?',
|
||||
a45: 'Le Replay Gain normalise le volume afin que les albums forts et doux soient lus à un niveau cohérent. Activez-le dans Paramètres → Audio → Replay Gain ; mode Piste ou Album.',
|
||||
q46: 'Qu\'est-ce que le Hot Cache ?',
|
||||
a46: 'Le Hot Cache (Alpha, Paramètres → Bibliothèque) précharge les prochains morceaux de la file sur le disque pour une lecture instantanée. Psysonic conserve le morceau actuel et les 5 suivants et évince les anciens quand la limite est atteinte.',
|
||||
q47: 'Puis-je mettre en cache une playlist hors ligne ?',
|
||||
a47: 'Oui. Dans les détails d\'une playlist, cliquez sur l\'icône de téléchargement. Une fois entièrement mis en cache, l\'icône se transforme en corbeille rouge ; cliquer dessus supprime la playlist du cache hors ligne.',
|
||||
},
|
||||
queue: {
|
||||
title: 'File d\'attente',
|
||||
@@ -652,6 +804,7 @@ export const frTranslation = {
|
||||
genreDistribution: 'Répartition par genre (Top 20)',
|
||||
loadMore: 'Charger plus',
|
||||
statArtists: 'Artistes',
|
||||
statArtistsTooltip: 'Artistes d\'album uniquement — les artistes apparaissant seulement sur des pistes (featuring, invité, etc.) sans album propre ne sont pas comptés.',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Morceaux',
|
||||
statGenres: 'Genres',
|
||||
@@ -685,6 +838,10 @@ export const frTranslation = {
|
||||
lfmMinutesAgo: 'il y a {{n}} min',
|
||||
lfmHoursAgo: 'il y a {{n}}h',
|
||||
lfmDaysAgo: 'il y a {{n}}j',
|
||||
topRatedSongs: 'Morceaux les mieux notés',
|
||||
topRatedArtists: 'Artistes les mieux notés',
|
||||
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
||||
noRatedArtists: 'Aucun artiste noté.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Lecteur de musique',
|
||||
@@ -711,6 +868,7 @@ export const frTranslation = {
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
lyricsSourceServer: 'Source : Serveur',
|
||||
lyricsSourceLrclib: 'Source : LRCLIB',
|
||||
lyricsSourceNetease: 'Source : Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
@@ -762,8 +920,10 @@ export const frTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
addSelected: 'Ajouter la sélection',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
removeOffline: 'Retirer du cache hors ligne',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Publique',
|
||||
privateLabel: 'Privée',
|
||||
@@ -779,6 +939,33 @@ export const frTranslation = {
|
||||
coverUpdated: 'Pochette mise à jour',
|
||||
metaSaved: 'Playlist mise à jour',
|
||||
downloadZip: 'Télécharger (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
|
||||
addPartial: '{{added}} ajoutés, {{skipped}} ignorés (doublons)',
|
||||
addAllSkipped: 'Tous ignorés ({{count}} doublons)',
|
||||
addError: 'Erreur lors de l\'ajout des morceaux',
|
||||
createAndAddSuccess: 'Playlist "{{playlist}}" créée avec {{count}} morceaux',
|
||||
createError: 'Erreur lors de la création de la playlist',
|
||||
deleteSuccess: '{{count}} playlists supprimées',
|
||||
deleteFailed: 'Erreur lors de la suppression de {{name}}',
|
||||
deleteSelected: 'Supprimer la sélection',
|
||||
mergeSuccess: '{{count}} morceaux fusionnés dans {{playlist}}',
|
||||
mergeNoNewSongs: 'Aucun nouveau morceau à ajouter',
|
||||
mergeError: 'Erreur lors de la fusion des playlists',
|
||||
mergeInto: 'Fusionner dans',
|
||||
selectionCount: '{{count}} sélectionnés',
|
||||
select: 'Sélectionner',
|
||||
startSelect: 'Activer la sélection',
|
||||
cancelSelect: 'Annuler',
|
||||
loadingAlbums: 'Résolution de {{count}} albums…',
|
||||
loadingArtists: 'Résolution de {{count}} artistes…',
|
||||
myPlaylists: 'Mes Playlists',
|
||||
noOtherPlaylists: 'Aucune autre playlist disponible',
|
||||
addToPlaylistSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
|
||||
addToPlaylistNoNew: 'Aucun nouveau morceau pour {{playlist}}',
|
||||
addToPlaylistError: 'Erreur lors de l\'ajout à la playlist',
|
||||
removeSuccess: 'Morceau retiré de la playlist',
|
||||
removeError: 'Erreur lors du retrait de la playlist',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Les plus joués',
|
||||
@@ -789,6 +976,9 @@ export const frTranslation = {
|
||||
sortLeast: 'Moins joués en premier',
|
||||
loadMore: 'Charger plus d\'albums',
|
||||
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
|
||||
noArtists: 'Tous les artistes filtrés.',
|
||||
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Masquer les compilations',
|
||||
},
|
||||
radio: {
|
||||
title: 'Radio Internet',
|
||||
@@ -820,5 +1010,13 @@ export const frTranslation = {
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
listenerCount_one: '{{count}} auditeur',
|
||||
listenerCount_other: '{{count}} auditeurs',
|
||||
recentlyPlayed: 'Récemment joués',
|
||||
upNext: 'À suivre',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Dossier vide',
|
||||
error: 'Échec du chargement',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const nbTranslation = {
|
||||
newReleases: 'Nye utgivelser',
|
||||
allAlbums: 'Alle album',
|
||||
randomAlbums: 'Tilfeldige album',
|
||||
randomPicker: 'Lag en miks',
|
||||
artists: 'Artister',
|
||||
randomMix: 'Tilfeldig miks',
|
||||
favorites: 'Favoritter',
|
||||
@@ -16,13 +17,17 @@ export const nbTranslation = {
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
cancelDownload: 'Avbryt nedlasting',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
folderBrowser: 'Mappeleser',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
expandPlaylists: 'Utvid spillelister',
|
||||
collapsePlaylists: 'Skjul spillelister',
|
||||
},
|
||||
home: {
|
||||
hero: 'Utvalgt',
|
||||
@@ -92,6 +97,8 @@ export const nbTranslation = {
|
||||
addToQueue: 'Legg til i kø',
|
||||
enqueueAlbum: 'Legg albumet i kø',
|
||||
startRadio: 'Start radio',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||
lfmLove: 'Lik på Last.fm',
|
||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
||||
favorite: 'Favoritt',
|
||||
@@ -101,10 +108,14 @@ export const nbTranslation = {
|
||||
unfavoriteArtist: 'Fjern artist fra favoritter',
|
||||
unfavoriteAlbum: 'Fjern album fra favoritter',
|
||||
removeFromQueue: 'Fjern fra kø',
|
||||
removeFromPlaylist: 'Fjern fra spilleliste',
|
||||
openAlbum: 'Åpne album',
|
||||
goToArtist: 'Gå til artist',
|
||||
download: 'Last ned (ZIP)',
|
||||
addToPlaylist: 'Legg til i spilleliste',
|
||||
selectedPlaylists: '{{count}} spillelister valgt',
|
||||
selectedAlbums: '{{count}} album valgt',
|
||||
selectedArtists: '{{count}} artister valgt',
|
||||
songInfo: 'Sanginfo',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -131,6 +142,7 @@ export const nbTranslation = {
|
||||
goToArtist: 'Gå til {{artist}}',
|
||||
moreLabelAlbums: 'Flere album på {{label}}',
|
||||
trackTitle: 'Tittel',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artist',
|
||||
trackGenre: 'Sjanger',
|
||||
trackFormat: 'Format',
|
||||
@@ -144,6 +156,18 @@ export const nbTranslation = {
|
||||
bioClose: 'Lukk',
|
||||
ratingLabel: 'Vurdering',
|
||||
enlargeCover: 'Forstørr',
|
||||
filterSongs: 'Filtrer sanger…',
|
||||
sortNatural: 'Naturlig',
|
||||
sortByTitle: 'A–Å (Tittel)',
|
||||
sortByArtist: 'A–Å (Artist)',
|
||||
sortByAlbum: 'A–Å (Album)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumvurdering',
|
||||
artistShort: 'Artistvurdering',
|
||||
albumAriaLabel: 'Albumvurdering',
|
||||
artistAriaLabel: 'Artistvurdering',
|
||||
saveFailed: 'Kunne ikke lagre vurderingen.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Tilbake',
|
||||
@@ -186,6 +210,13 @@ export const nbTranslation = {
|
||||
removeSong: 'Fjern fra favoritter',
|
||||
stations: 'Radiostasjoner',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Lag en miks',
|
||||
mixByTracks: 'Miks etter spor',
|
||||
mixByTracksDesc: 'Tilfeldig utvalg av spor fra hele biblioteket ditt',
|
||||
mixByAlbums: 'Miks etter album',
|
||||
mixByAlbumsDesc: 'Tilfeldige album for nye oppdagelser',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Tilfeldige album',
|
||||
refresh: 'Oppdater',
|
||||
@@ -206,6 +237,8 @@ export const nbTranslation = {
|
||||
title: 'Tilfeldig miks',
|
||||
remix: 'Remiks',
|
||||
remixTooltip: 'Last inn nye tilfeldige sanger',
|
||||
remixGenre: 'Remiks {{genre}}',
|
||||
remixTooltipGenre: 'Last inn nye {{genre}}-sanger',
|
||||
playAll: 'Spill alt',
|
||||
trackTitle: 'Tittel',
|
||||
trackArtist: 'Artist',
|
||||
@@ -227,6 +260,7 @@ export const nbTranslation = {
|
||||
blacklistToggle: 'Nøkkelordfilter',
|
||||
genreMixTitle: 'Sjangermiks',
|
||||
genreMixDesc: 'Topp 20 sjangre etter antall sanger - klikk for å laste en tilfeldig miks',
|
||||
genreMixAll: 'Alle sanger',
|
||||
genreMixLoadMore: 'Last 10 til',
|
||||
genreMixNoGenres: 'Ingen sjangre funnet på tjeneren.',
|
||||
shuffleGenres: 'Vis andre sjangre',
|
||||
@@ -243,7 +277,18 @@ export const nbTranslation = {
|
||||
yearFrom: 'Fra',
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
yearFilterLabel: 'År',
|
||||
select: 'Multivalg',
|
||||
startSelect: 'Aktiver multivalg',
|
||||
cancelSelect: 'Avbryt',
|
||||
selectionCount: '{{count}} valgt',
|
||||
downloadZips: 'Last ned ZIPs',
|
||||
addOffline: 'Legg til offline',
|
||||
downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) lastet ned',
|
||||
downloadZipFailed: 'Kunne ikke laste ned {{name}}',
|
||||
offlineQueuing: 'Legger {{count}} album i kø for offline…',
|
||||
offlineFailed: 'Kunne ikke legge til {{name}} offline',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artister',
|
||||
@@ -257,13 +302,18 @@ export const nbTranslation = {
|
||||
notFound: 'Ingen artister funnet.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} album',
|
||||
selectionCount: '{{count}} valgt',
|
||||
select: 'Multivalg',
|
||||
startSelect: 'Aktiver multivalg',
|
||||
cancelSelect: 'Avbryt',
|
||||
addToPlaylist: 'Legg til i spilleliste',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Din Navidrome-mediaspiller',
|
||||
serverName: 'Tjenernavn (valgfritt)',
|
||||
serverNamePlaceholder: 'Mitt mediabibliotek',
|
||||
serverUrl: 'Tjener-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 eller feks. musikk.eksempel.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
|
||||
username: 'Brukernavn',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Passord',
|
||||
@@ -287,6 +337,7 @@ export const nbTranslation = {
|
||||
offlineTitle: 'Ingen tjenertilkobling',
|
||||
offlineSubtitle: 'Kan ikke nå {{server}}. Sjekk nettverket eller tjeneren din.',
|
||||
offlineModeBanner: 'Frakoblet modus - spiller fra lokal hurtigbuffer',
|
||||
offlineNoCacheBanner: 'Ingen servertilkobling — kan ikke nå {{server}}',
|
||||
offlineLibraryTitle: 'Frakoblet bibliotek',
|
||||
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
@@ -296,6 +347,7 @@ export const nbTranslation = {
|
||||
offlineFilterPlaylists: 'Spillelister',
|
||||
offlineFilterArtists: 'Diskografier',
|
||||
retry: 'Prøv igjen',
|
||||
serverSettings: 'Serverinnstillinger',
|
||||
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
|
||||
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
|
||||
},
|
||||
@@ -324,12 +376,27 @@ export const nbTranslation = {
|
||||
filterNoGenres: 'Ingen sjangre samsvarer',
|
||||
filterClear: 'Tøm',
|
||||
bulkSelected: '{{count}} valgt',
|
||||
clearSelection: 'Fjern utvalg',
|
||||
bulkAddToPlaylist: 'Legg til i spilleliste',
|
||||
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
|
||||
bulkClear: 'Tøm utvalg',
|
||||
updaterAvailable: 'Ny versjon er tilgjengelig',
|
||||
updaterVersion: 'v{{version}} er tilgjengelig',
|
||||
updaterWebsite: 'Nettsted',
|
||||
updaterModalTitle: 'Ny versjon tilgjengelig',
|
||||
updaterChangelog: 'Hva er nytt',
|
||||
updaterDownloadBtn: 'Last ned nå',
|
||||
updaterSkipBtn: 'Hopp over denne versjonen',
|
||||
updaterRemindBtn: 'Påminn meg senere',
|
||||
updaterDone: 'Nedlasting fullført',
|
||||
updaterShowFolder: 'Vis i mappe',
|
||||
updaterInstallHint: 'Lukk Psysonic og kjør installasjonsprogrammet manuelt.',
|
||||
updaterAurHint: 'Installer oppdateringen via AUR:',
|
||||
updaterErrorMsg: 'Nedlasting mislyktes',
|
||||
updaterRetryBtn: 'Prøv igjen',
|
||||
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Åpne på GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
@@ -347,6 +414,7 @@ export const nbTranslation = {
|
||||
servers: 'Tjenere',
|
||||
serverName: 'Tjenernavn',
|
||||
serverUrl: 'Tjener-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
|
||||
serverUsername: 'Brukernavn',
|
||||
serverPassword: 'Passord',
|
||||
addServer: 'Legg til tjener',
|
||||
@@ -362,6 +430,11 @@ export const nbTranslation = {
|
||||
testBtn: 'Test tilkobling',
|
||||
testingBtn: 'Tester…',
|
||||
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
|
||||
audiomuseIssueHint:
|
||||
'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.',
|
||||
connected: 'Tilkoblet',
|
||||
failed: 'Mislyktes',
|
||||
eqTitle: 'Jevnstiller',
|
||||
@@ -427,6 +500,13 @@ export const nbTranslation = {
|
||||
hotCacheDebounceImmediate: 'Umiddelbart',
|
||||
hotCacheDebounceSeconds: '{{n}} sek',
|
||||
hotCacheClearBtn: 'Tøm varm buffer',
|
||||
audioOutputDevice: 'Lydutgangsenhet',
|
||||
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
|
||||
audioOutputDeviceDefault: 'Systemstandard',
|
||||
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
|
||||
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
|
||||
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
|
||||
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
|
||||
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
|
||||
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
|
||||
@@ -442,6 +522,13 @@ export const nbTranslation = {
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
|
||||
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
|
||||
lyricsSourcesTitle: 'Sangtekstkilder',
|
||||
lyricsSourcesDesc: 'Velg hvilke kilder som skal brukes og i hvilken rekkefølge. Dra for å sortere. Deaktiverte kilder hoppes over.',
|
||||
lyricsSourceServer: 'Tjener',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'ZIP Eksport & Arkivering',
|
||||
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
|
||||
downloadsDefault: 'Standard nedlastingsmappe',
|
||||
@@ -451,7 +538,6 @@ export const nbTranslation = {
|
||||
logout: 'Logg ut',
|
||||
aboutTitle: 'Om Psysonic',
|
||||
aboutDesc: 'En moderne musikkspiller for Subsonic-kompatible tjenere (Navidrome, Gonic og andre). Bygget på Tauri v2 med en innebygd Rust-lydmotor - lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
|
||||
aboutFeatures: 'Multi-tjener · Last.fm scrobbling og liker · Fullskjerm Ambient Stage · Bølgeformsøk · Synkroniserte tekster · 10-bånds jevnstiller · Crossfade og gapless · Replay Gain · Sjangre · 63 temaer · 6 språk',
|
||||
aboutLicense: 'Lisens',
|
||||
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
|
||||
aboutRepo: 'Kildekode på GitHub',
|
||||
@@ -482,6 +568,18 @@ export const nbTranslation = {
|
||||
tabServer: 'Tjener',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Generelt',
|
||||
ratingsSectionTitle: 'Vurderinger',
|
||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||
ratingsSkipStarDesc:
|
||||
'Etter flere hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||
ratingsSkipStarThresholdLabel: 'Hopp',
|
||||
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
|
||||
ratingsMixMinSong: 'Spor',
|
||||
ratingsMixMinAlbum: 'Album',
|
||||
ratingsMixMinArtist: 'Artister',
|
||||
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
|
||||
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
|
||||
backupExport: 'Eksporter innstillinger',
|
||||
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.',
|
||||
@@ -505,6 +603,7 @@ export const nbTranslation = {
|
||||
shortcutSeekForward: 'Søk fremover 10 sekunder',
|
||||
shortcutSeekBackward: 'Søk bakover 10 sekunder',
|
||||
shortcutToggleQueue: 'Veksle kø',
|
||||
shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Fullskjermsspiller',
|
||||
shortcutNativeFullscreen: 'Naturlig fullskjerm',
|
||||
playbackTitle: 'Avspilling',
|
||||
@@ -513,6 +612,8 @@ export const nbTranslation = {
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Spor',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (taggede filer)',
|
||||
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Tone mellom spor',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
@@ -525,10 +626,39 @@ export const nbTranslation = {
|
||||
experimental: 'Eksperimentell',
|
||||
preloadMode: 'Forhåndslast neste spor',
|
||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
||||
nextTrackBufferingTitle: 'Neste spor – bufring',
|
||||
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål – bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
|
||||
preloadOff: 'Av',
|
||||
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
fsPlayerSection: 'Fullskjermspiller',
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
seekbarWaveform: 'Bølgeform',
|
||||
seekbarLinedot: 'Linje & punkt',
|
||||
seekbarBar: 'Linje',
|
||||
seekbarThick: 'Tykk linje',
|
||||
seekbarSegmented: 'Segmentert',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsbølge',
|
||||
seekbarParticletrail: 'Partikkelspor',
|
||||
seekbarLiquidfill: 'Væskerør',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
themeSchedulerTitle: 'Tidsplanlagt tema',
|
||||
themeSchedulerEnable: 'Aktiver temaplanlegger',
|
||||
themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt',
|
||||
themeSchedulerDayTheme: 'Dagtema',
|
||||
themeSchedulerDayStart: 'Dag starter kl.',
|
||||
themeSchedulerNightTheme: 'Natttema',
|
||||
themeSchedulerNightStart: 'Natt starter kl.',
|
||||
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
|
||||
uiScaleTitle: 'Grensesnittskala',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
@@ -611,6 +741,28 @@ export const nbTranslation = {
|
||||
a35: 'Åpne et hvilket som helst album og klikk på nedlastingsikonet i albumoverskriften. Psysonic laster ned alle spor i bakgrunnen. Fremdrift vises på knappen. Når bufren er lagret, blir ikonet grønt. Du kan vise og fjerne bufrede album på siden Offline bibliotek (sidefelt).',
|
||||
q36: 'Hvor mye lagringsplass kan frakoblet mellomlagring bruke?',
|
||||
a36: 'Du kan angi en maksimal mellomlagringsstørrelse i Innstillinger → Bibliotek. Når grensen er nådd, vises et advarselsbanner på albumsiden. Du kan slette individuelle album fra frakoblet bibliotek for å frigjøre plass.',
|
||||
q37: 'Hvordan vurderer jeg sanger, album og artister?',
|
||||
a37: 'Psysonic støtter 1–5 stjernevurderinger via OpenSubsonic API (krever Navidrome ≥ 0.53). Vurder sanger i sporlistene, via høyreklikk-menyen eller i spillerlinjen. Album vurderes på detaljsiden, artister på artistsiden.',
|
||||
q38: 'Kan jeg fjerne eller endre en vurdering?',
|
||||
a38: 'Ja. Klikk på den aktive stjernen igjen for å slette vurderingen — et andre klikk på samme stjerne fjerner den. Klikk på en annen stjerne for å endre vurderingen.',
|
||||
q39: 'Hva er Skip-to-1★ og vurderingsfilter?',
|
||||
a39: 'Skip-to-1★ tildeler automatisk en 1-stjernevurdering etter et visst antall påfølgende manuelle hopp. Aktiver det i Innstillinger → Vurderinger. Der kan du også angi minimum stjerner for tilfeldige mikser.',
|
||||
q40: 'Hva er mappebrowseren?',
|
||||
a40: 'Mappebrowseren (sidepanel) lar deg navigere i serverens musikkmappe med Miller-kolonner. Klikk på en mappe for å utforske innholdet; bruk avspillingsikonen for å spille eller legge til i køen.',
|
||||
q41: 'Hva er temaplanleggeren?',
|
||||
a41: 'Innstillinger → Utseende → Bytt tema automatisk: angi et dagstema og et nattstema med starttider. Psysonic bytter automatisk på de angitte tidspunktene.',
|
||||
q42: 'Kan jeg skalere grensesnittet?',
|
||||
a42: 'Ja. Innstillinger → Utseende → Grensesnittskala lar deg skalere hele grensesnittet mellom 80 % og 125 %.',
|
||||
q43: 'Kan jeg endre søkelinjesstilen?',
|
||||
a43: 'Ja. Innstillinger → Utseende → Søkelinjestil tilbyr 10 stiler: Bølgeform, Linje & Punkt, Stolpe, Tykk stolpe, Segmentert, Neon Glow, Pulsbølge, Partikkelspor, Væskefylling og Retro Tape.',
|
||||
q44: 'Hva er AutoEQ?',
|
||||
a44: '10-bånds equalizer i Innstillinger → Lyd inkluderer AutoEQ-oppslag. Skriv inn hodetelefon-modellen for å laste inn en korreksjonsprofil automatisk.',
|
||||
q45: 'Hva er Replay Gain?',
|
||||
a45: 'Replay Gain normaliserer volumet slik at høye og stille album spilles av på et konsistent nivå. Aktiver det i Innstillinger → Lyd → Replay Gain; velg modus Spor eller Album.',
|
||||
q46: 'Hva er hurtigbufferen?',
|
||||
a46: 'Hurtigbuffer (Alpha, Innstillinger → Bibliotek) forhåndslaster de neste sporene i køen til disk for umiddelbar avspilling. Psysonic beholder gjeldende spor og de neste 5 og fjerner eldre innslag når grensen nås.',
|
||||
q47: 'Kan jeg mellomlagre en spilleliste frakoblet?',
|
||||
a47: 'Ja. Åpne spillelistedetaljene og klikk på nedlastingsikonen. Når alle spor er mellomlagret, endres ikonet til en rød søppelbøtte; klikk på den for å fjerne spillelisten fra frakoblet hurtigbuffer.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Kø',
|
||||
@@ -651,6 +803,7 @@ export const nbTranslation = {
|
||||
genreDistribution: 'Sjangerfordeling (Topp 20)',
|
||||
loadMore: 'Last inn mer',
|
||||
statArtists: 'Artister',
|
||||
statArtistsTooltip: 'Kun albumartister — artister som bare opptrer som sporartist (featuring, gjest, osv.) uten eget album telles ikke med.',
|
||||
statAlbums: 'Album',
|
||||
statSongs: 'Sanger',
|
||||
statGenres: 'Sjangere',
|
||||
@@ -684,6 +837,10 @@ export const nbTranslation = {
|
||||
lfmMinutesAgo: 'For {{n}}m siden',
|
||||
lfmHoursAgo: 'For {{n}}t siden',
|
||||
lfmDaysAgo: 'For {{n}}d siden',
|
||||
topRatedSongs: 'Høyest vurderte spor',
|
||||
topRatedArtists: 'Høyest vurderte artister',
|
||||
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
||||
noRatedArtists: 'Ingen vurderte artister ennå.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikkspiller',
|
||||
@@ -710,6 +867,7 @@ export const nbTranslation = {
|
||||
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
|
||||
lyricsSourceServer: 'Kilde: Server',
|
||||
lyricsSourceLrclib: 'Kilde: LRCLIB',
|
||||
lyricsSourceNetease: 'Kilde: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Sanginfo',
|
||||
@@ -761,8 +919,10 @@ export const nbTranslation = {
|
||||
titleBadge: 'Spilleliste',
|
||||
refreshSuggestions: 'Nye forslag',
|
||||
addSong: 'Legg til i spilleliste',
|
||||
addSelected: 'Legg til valgte',
|
||||
cacheOffline: 'Bufre spilleliste offline',
|
||||
offlineCached: 'Spilleliste bufret',
|
||||
removeOffline: 'Fjern fra offline-buffer',
|
||||
offlineDownloading: 'Bufre… ({{done}}/{{total}} album)',
|
||||
publicLabel: 'Offentlig',
|
||||
privateLabel: 'Privat',
|
||||
@@ -778,6 +938,33 @@ export const nbTranslation = {
|
||||
coverUpdated: 'Omslag oppdatert',
|
||||
metaSaved: 'Spillelisten er oppdatert',
|
||||
downloadZip: 'Last ned (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} sanger lagt til i {{playlist}}',
|
||||
addPartial: '{{added}} lagt til, {{skipped}} hoppet over (duplikater)',
|
||||
addAllSkipped: 'Alle hoppet over ({{count}} duplikater)',
|
||||
addError: 'Feil ved å legge til sanger',
|
||||
createAndAddSuccess: 'Spilleliste "{{playlist}}" opprettet med {{count}} sanger',
|
||||
createError: 'Feil ved oppretting av spilleliste',
|
||||
deleteSuccess: '{{count}} spillelister slettet',
|
||||
deleteFailed: 'Feil ved sletting av {{name}}',
|
||||
deleteSelected: 'Slett valgte',
|
||||
mergeSuccess: '{{count}} sanger slått sammen i {{playlist}}',
|
||||
mergeNoNewSongs: 'Ingen nye sanger å legge til',
|
||||
mergeError: 'Feil ved sammenslåing av spillelister',
|
||||
mergeInto: 'Slå sammen i',
|
||||
selectionCount: '{{count}} valgt',
|
||||
select: 'Velg',
|
||||
startSelect: 'Aktiver valg',
|
||||
cancelSelect: 'Avbryt',
|
||||
loadingAlbums: 'Løser {{count}} album…',
|
||||
loadingArtists: 'Løser {{count}} artister…',
|
||||
myPlaylists: 'Mine spillelister',
|
||||
noOtherPlaylists: 'Ingen andre spillelister tilgjengelig',
|
||||
addToPlaylistSuccess: '{{count}} sanger lagt til i {{playlist}}',
|
||||
addToPlaylistNoNew: 'Ingen nye sanger for {{playlist}}',
|
||||
addToPlaylistError: 'Feil ved å legge til i spilleliste',
|
||||
removeSuccess: 'Sang fjernet fra spilleliste',
|
||||
removeError: 'Feil ved fjerning fra spilleliste',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Mest spilt',
|
||||
@@ -788,6 +975,9 @@ export const nbTranslation = {
|
||||
sortLeast: 'Minst spilt først',
|
||||
loadMore: 'Last inn flere album',
|
||||
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
|
||||
noArtists: 'Alle artister filtrert bort.',
|
||||
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Skjul kompilasjoner',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internettradio',
|
||||
@@ -819,5 +1009,13 @@ export const nbTranslation = {
|
||||
favorite: 'Legg til i favoritter',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
noFavorites: 'Ingen favorittstasjoner.',
|
||||
}
|
||||
listenerCount_one: '{{count}} lytter',
|
||||
listenerCount_other: '{{count}} lyttere',
|
||||
recentlyPlayed: 'Nylig spilt',
|
||||
upNext: 'Neste ut',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Tom mappe',
|
||||
error: 'Kunne ikke laste',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const nlTranslation = {
|
||||
newReleases: 'Nieuw',
|
||||
allAlbums: 'Alle albums',
|
||||
randomAlbums: 'Willekeurige albums',
|
||||
randomPicker: 'Mix samenstellen',
|
||||
artists: 'Artiesten',
|
||||
randomMix: 'Willekeurige mix',
|
||||
favorites: 'Favorieten',
|
||||
@@ -16,13 +17,17 @@ export const nlTranslation = {
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
cancelDownload: 'Download annuleren',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
expandPlaylists: 'Afspeellijsten uitklappen',
|
||||
collapsePlaylists: 'Afspeellijsten inklappen',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
@@ -92,6 +97,8 @@ export const nlTranslation = {
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
enqueueAlbum: 'Album in wachtrij',
|
||||
startRadio: 'Radio starten',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||
lfmLove: 'Liken op Last.fm',
|
||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
||||
favorite: 'Favoriet',
|
||||
@@ -105,6 +112,9 @@ export const nlTranslation = {
|
||||
goToArtist: 'Naar artiest',
|
||||
download: 'Downloaden (ZIP)',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
selectedPlaylists: '{{count}} playlists geselecteerd',
|
||||
selectedAlbums: '{{count}} albums geselecteerd',
|
||||
selectedArtists: '{{count}} artiesten geselecteerd',
|
||||
songInfo: 'Nummerinfo',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -131,6 +141,7 @@ export const nlTranslation = {
|
||||
goToArtist: 'Naar {{artist}}',
|
||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artiest',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Formaat',
|
||||
@@ -144,6 +155,18 @@ export const nlTranslation = {
|
||||
bioClose: 'Sluiten',
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
filterSongs: 'Filteren…',
|
||||
sortNatural: 'Natuurlijk',
|
||||
sortByTitle: 'A–Z (Titel)',
|
||||
sortByArtist: 'A–Z (Artiest)',
|
||||
sortByAlbum: 'A–Z (Album)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Albumbeoordeling',
|
||||
artistShort: 'Artiestbeoordeling',
|
||||
albumAriaLabel: 'Albumbeoordeling',
|
||||
artistAriaLabel: 'Artiestbeoordeling',
|
||||
saveFailed: 'Beoordeling opslaan mislukt.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
@@ -186,6 +209,13 @@ export const nlTranslation = {
|
||||
removeSong: 'Verwijderen uit favorieten',
|
||||
stations: 'Radiostations',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix samenstellen',
|
||||
mixByTracks: 'Mix op nummers',
|
||||
mixByTracksDesc: 'Willekeurige selectie uit je volledige mediatheek',
|
||||
mixByAlbums: 'Mix op albums',
|
||||
mixByAlbumsDesc: 'Willekeurige albums voor nieuwe ontdekkingen',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Willekeurige albums',
|
||||
refresh: 'Vernieuwen',
|
||||
@@ -206,6 +236,8 @@ export const nlTranslation = {
|
||||
title: 'Willekeurige mix',
|
||||
remix: 'Opnieuw mixen',
|
||||
remixTooltip: 'Nieuwe willekeurige nummers laden',
|
||||
remixGenre: '{{genre}} mixen',
|
||||
remixTooltipGenre: 'Nieuwe {{genre}}-nummers laden',
|
||||
playAll: 'Alles afspelen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Artiest',
|
||||
@@ -227,6 +259,7 @@ export const nlTranslation = {
|
||||
blacklistToggle: 'Trefwoordfilter',
|
||||
genreMixTitle: 'Genremix',
|
||||
genreMixDesc: 'Top 20 genres op aantal nummers — klik voor een willekeurige mix',
|
||||
genreMixAll: 'Alle nummers',
|
||||
genreMixLoadMore: '10 meer laden',
|
||||
genreMixNoGenres: 'Geen genres gevonden op server.',
|
||||
shuffleGenres: 'Andere genres tonen',
|
||||
@@ -244,6 +277,17 @@ export const nlTranslation = {
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
select: 'Meervoudige selectie',
|
||||
startSelect: 'Meervoudige selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
selectionCount: '{{count}} geselecteerd',
|
||||
downloadZips: 'ZIPs downloaden',
|
||||
addOffline: 'Offline toevoegen',
|
||||
downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP(s) gedownload',
|
||||
downloadZipFailed: 'Downloaden van {{name}} mislukt',
|
||||
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
|
||||
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artiesten',
|
||||
@@ -257,13 +301,18 @@ export const nlTranslation = {
|
||||
notFound: 'Geen artiesten gevonden.',
|
||||
albumCount_one: '{{count}} album',
|
||||
albumCount_other: '{{count}} albums',
|
||||
selectionCount: '{{count}} geselecteerd',
|
||||
select: 'Meervoudige selectie',
|
||||
startSelect: 'Meervoudige selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Jouw Navidrome-desktopspeler',
|
||||
serverName: 'Servernaam (optioneel)',
|
||||
serverNamePlaceholder: 'Mijn Navidrome',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 of music.voorbeeld.nl',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
|
||||
username: 'Gebruikersnaam',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Wachtwoord',
|
||||
@@ -287,6 +336,7 @@ export const nlTranslation = {
|
||||
offlineTitle: 'Geen serververbinding',
|
||||
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
|
||||
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
|
||||
offlineNoCacheBanner: 'Geen serververbinding — {{server}} niet bereikbaar',
|
||||
offlineLibraryTitle: 'Offline bibliotheek',
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
@@ -296,6 +346,7 @@ export const nlTranslation = {
|
||||
offlineFilterPlaylists: 'Afspeellijsten',
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
serverSettings: 'Serverinstellingen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
},
|
||||
@@ -324,12 +375,27 @@ export const nlTranslation = {
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
clearSelection: 'Selectie wissen',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||
bulkClear: 'Selectie wissen',
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} beschikbaar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Nieuwe versie beschikbaar',
|
||||
updaterChangelog: 'Wat is er nieuw',
|
||||
updaterDownloadBtn: 'Nu downloaden',
|
||||
updaterSkipBtn: 'Deze versie overslaan',
|
||||
updaterRemindBtn: 'Later herinneren',
|
||||
updaterDone: 'Download voltooid',
|
||||
updaterShowFolder: 'Tonen in map',
|
||||
updaterInstallHint: 'Sluit Psysonic en voer het installatieprogramma handmatig uit.',
|
||||
updaterAurHint: 'Update installeren via AUR:',
|
||||
updaterErrorMsg: 'Downloaden mislukt',
|
||||
updaterRetryBtn: 'Opnieuw proberen',
|
||||
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Openen op GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -347,6 +413,7 @@ export const nlTranslation = {
|
||||
servers: 'Servers',
|
||||
serverName: 'Servernaam',
|
||||
serverUrl: 'Server-URL',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
|
||||
serverUsername: 'Gebruikersnaam',
|
||||
serverPassword: 'Wachtwoord',
|
||||
addServer: 'Server toevoegen',
|
||||
@@ -362,6 +429,11 @@ export const nlTranslation = {
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpagina’s.',
|
||||
audiomuseIssueHint:
|
||||
'Instant Mix is onlangs mislukt — controleer de Navidrome-plugin en AudioMuse API. Zonder serverresultaten worden vergelijkbare artiesten via Last.fm geladen.',
|
||||
connected: 'Verbonden',
|
||||
failed: 'Mislukt',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -426,6 +498,13 @@ export const nlTranslation = {
|
||||
hotCacheDebounceImmediate: 'Direct',
|
||||
hotCacheDebounceSeconds: '{{n}} s',
|
||||
hotCacheClearBtn: 'Warme cache wissen',
|
||||
audioOutputDevice: 'Audio-uitvoerapparaat',
|
||||
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
|
||||
audioOutputDeviceDefault: 'Systeemstandaard',
|
||||
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
|
||||
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
|
||||
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
|
||||
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
|
||||
hiResTitle: 'Natieve hi-res-weergave',
|
||||
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
||||
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
|
||||
@@ -443,6 +522,13 @@ export const nlTranslation = {
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music songteksten',
|
||||
enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.',
|
||||
lyricsSourcesTitle: 'Songtekstbronnen',
|
||||
lyricsSourcesDesc: 'Kies welke bronnen worden geraadpleegd en in welke volgorde. Sleep om te sorteren. Uitgeschakelde bronnen worden overgeslagen.',
|
||||
lyricsSourceServer: 'Server',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
@@ -452,7 +538,6 @@ export const nlTranslation = {
|
||||
logout: 'Uitloggen',
|
||||
aboutTitle: 'Over Psysonic',
|
||||
aboutDesc: 'Een moderne desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Gebouwd op Tauri v2 met een native Rust audio-engine — licht en snel, maar boordevol functies: golfvorm-zoekbalk, gesynchroniseerde songteksten, Last.fm-integratie, 10-bands equalizer, crossfade, naadloos afspelen, Replay Gain, genres en een uitgebreide themabibliotheek.',
|
||||
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Syncteksten · 10-bands EQ · Crossfade & Gapless · Replay Gain · Genres · 63 thema\'s · 5 talen',
|
||||
aboutLicense: 'Licentie',
|
||||
aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.',
|
||||
aboutRepo: 'Broncode op GitHub',
|
||||
@@ -495,10 +580,23 @@ export const nlTranslation = {
|
||||
shortcutSeekForward: '10s vooruitspoelen',
|
||||
shortcutSeekBackward: '10s terugspoelen',
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutOpenFolderBrowser: '{{folderBrowser}} openen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
ratingsSectionTitle: 'Beoordelingen',
|
||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||
ratingsSkipStarDesc:
|
||||
'Na meerdere overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||
ratingsSkipStarThresholdLabel: 'Overslagen',
|
||||
ratingsMixFilterTitle: 'Filter op beoordeling',
|
||||
ratingsMixFilterDesc:
|
||||
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
|
||||
ratingsMixMinSong: 'Nummers',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artiesten',
|
||||
ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
@@ -514,6 +612,8 @@ export const nlTranslation = {
|
||||
replayGainMode: 'Modus',
|
||||
replayGainTrack: 'Nummer',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getagde bestanden)',
|
||||
replayGainFallback: 'Terugval (zonder tags / radio)',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
@@ -523,6 +623,9 @@ export const nlTranslation = {
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
nextTrackBufferingTitle: 'Volgend nummer – buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
|
||||
preloadOff: 'Uit',
|
||||
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||
preloadEarly: 'Vroeg (na 5 s afspelen)',
|
||||
preloadCustom: 'Aangepast',
|
||||
@@ -530,6 +633,32 @@ export const nlTranslation = {
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
fsPlayerSection: 'Volledig scherm speler',
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
seekbarWaveform: 'Golfvorm',
|
||||
seekbarLinedot: 'Lijn & punt',
|
||||
seekbarBar: 'Balk',
|
||||
seekbarThick: 'Dikke balk',
|
||||
seekbarSegmented: 'Gesegmenteerd',
|
||||
seekbarNeon: 'Neon',
|
||||
seekbarPulsewave: 'Pulsgolf',
|
||||
seekbarParticletrail: 'Deeltjesspoor',
|
||||
seekbarLiquidfill: 'Vloeistofbuis',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
themeSchedulerTitle: 'Thema-planner',
|
||||
themeSchedulerEnable: 'Thema-planner inschakelen',
|
||||
themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd',
|
||||
themeSchedulerDayTheme: 'Dagthema',
|
||||
themeSchedulerDayStart: 'Dag begint om',
|
||||
themeSchedulerNightTheme: 'Nachtthema',
|
||||
themeSchedulerNightStart: 'Nacht begint om',
|
||||
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
|
||||
uiScaleTitle: 'Interface schaal',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Wat is nieuw',
|
||||
@@ -612,6 +741,28 @@ export const nlTranslation = {
|
||||
a35: 'Open een album en klik op het downloadpictogram in de albumkoptekst. Psysonic downloadt alle nummers op de achtergrond. De voortgang wordt op de knop weergegeven. Na het cachen wordt het pictogram groen. Gecachte albums zijn te bekijken en verwijderen via de Offline bibliotheek (zijbalk).',
|
||||
q36: 'Hoeveel opslagruimte mag de offlinecache gebruiken?',
|
||||
a36: 'Je kunt een maximale cachegrootte instellen via Instellingen → Bibliotheek. Als de limiet is bereikt, verschijnt er een waarschuwingsbanner op de albumpagina. Je kunt afzonderlijke albums verwijderen in de Offline bibliotheek om ruimte vrij te maken.',
|
||||
q37: 'Hoe beoordeel ik nummers, albums en artiesten?',
|
||||
a37: 'Psysonic ondersteunt 1–5 sterrenwaarderingen via de OpenSubsonic API (vereist Navidrome ≥ 0.53). Beoordeel nummers in de tracklist van een album of afspeellijst, via het rechtsklikmenu of in de spelersbalk. Albums worden beoordeeld op hun detailpagina, artiesten op hun artiestenpagina.',
|
||||
q38: 'Kan ik een beoordeling verwijderen of wijzigen?',
|
||||
a38: 'Ja. Klik opnieuw op de actieve ster om de beoordeling volledig te verwijderen — een tweede klik op dezelfde ster wist hem. Klik op een andere ster om de beoordeling te wijzigen.',
|
||||
q39: 'Wat is Skip-to-1★ en het beoordelingsfilter?',
|
||||
a39: 'Skip-to-1★ kent automatisch een 1-sterrenbeoordeling toe na een bepaald aantal opeenvolgende skips. Activeer het in Instellingen → Beoordelingen. Stel daar ook een minimale sterrenbeoordeling in voor willekeurige mixen.',
|
||||
q40: 'Wat is de Mappenverkenner?',
|
||||
a40: 'De Mappenverkenner (zijbalk) laat je de muziekmap van de server navigeren via Miller-kolommen. Klik op een map om de inhoud te bekijken; gebruik het afspeelpictogram om de inhoud direct af te spelen of toe te voegen aan de wachtrij.',
|
||||
q41: 'Wat is de themasplanner?',
|
||||
a41: 'Instellingen → Uiterlijk → Thema automatisch wisselen: stel een dagthema en een nachtthema in met starttijden. Psysonic wisselt automatisch op de ingestelde tijden.',
|
||||
q42: 'Kan ik de interface schalen?',
|
||||
a42: 'Ja. Instellingen → Uiterlijk → Interfaceschaal laat je de volledige UI schalen tussen 80 % en 125 %.',
|
||||
q43: 'Kan ik de zoekbalkstijl wijzigen?',
|
||||
a43: 'Ja. Instellingen → Uiterlijk → Zoekbalkstijl biedt 10 stijlen: Golfvorm, Lijn & Punt, Balk, Dikke balk, Gesegmenteerd, Neon Glow, Pulsgolf, Partikelspoor, Vloeistofvulling en Retro Tape.',
|
||||
q44: 'Wat is AutoEQ?',
|
||||
a44: 'De 10-bands equalizer in Instellingen → Audio bevat een AutoEQ-zoekopdracht. Voer je hoofdtelefoonmodel in om automatisch een correctieprofiel te laden.',
|
||||
q45: 'Wat is Replay Gain?',
|
||||
a45: 'Replay Gain normaliseert het volume zodat harde en stille albums op een consistent niveau worden afgespeeld. Activeer het in Instellingen → Audio → Replay Gain; kies modus Track of Album.',
|
||||
q46: 'Wat is de Hot Cache?',
|
||||
a46: 'De Hot Cache (Alpha, Instellingen → Bibliotheek) laadt de volgende nummers in de wachtrij vooraf naar de schijf voor directe weergave. Psysonic bewaart het huidige nummer en de volgende 5 en verwijdert oudere items wanneer de limiet is bereikt.',
|
||||
q47: 'Kan ik een afspeellijst offline cachen?',
|
||||
a47: 'Ja. Open de afspeellijstdetails en klik op het downloadpictogram. Zodra alle tracks zijn gecached, verandert het pictogram in een rode prullenbak; klik erop om de afspeellijst uit de offlinecache te verwijderen.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Wachtrij',
|
||||
@@ -652,6 +803,7 @@ export const nlTranslation = {
|
||||
genreDistribution: 'Genreverdeling (Top 20)',
|
||||
loadMore: 'Meer laden',
|
||||
statArtists: 'Artiesten',
|
||||
statArtistsTooltip: 'Alleen albumartiesten — artiesten die enkel als trackartiest voorkomen (featuring, gast, enz.) zonder eigen album worden niet meegeteld.',
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Nummers',
|
||||
statGenres: 'Genres',
|
||||
@@ -685,6 +837,10 @@ export const nlTranslation = {
|
||||
lfmMinutesAgo: '{{n}} min geleden',
|
||||
lfmHoursAgo: '{{n}} uur geleden',
|
||||
lfmDaysAgo: '{{n}} dagen geleden',
|
||||
topRatedSongs: 'Best beoordeelde nummers',
|
||||
topRatedArtists: 'Best beoordeelde artiesten',
|
||||
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
||||
noRatedArtists: 'Nog geen beoordeelde artiesten.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Muziekspeler',
|
||||
@@ -711,6 +867,7 @@ export const nlTranslation = {
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
lyricsSourceServer: 'Bron: Server',
|
||||
lyricsSourceLrclib: 'Bron: LRCLIB',
|
||||
lyricsSourceNetease: 'Bron: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
@@ -762,8 +919,10 @@ export const nlTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
addSelected: 'Geselecteerde toevoegen',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
removeOffline: 'Verwijder uit offline cache',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Openbaar',
|
||||
privateLabel: 'Privé',
|
||||
@@ -779,6 +938,31 @@ export const nlTranslation = {
|
||||
coverUpdated: 'Omslag bijgewerkt',
|
||||
metaSaved: 'Playlist bijgewerkt',
|
||||
downloadZip: 'Downloaden (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
|
||||
addPartial: '{{added}} toegevoegd, {{skipped}} overgeslagen (duplicaten)',
|
||||
addAllSkipped: 'Alles overgeslagen ({{count}} duplicaten)',
|
||||
addError: 'Fout bij toevoegen van nummers',
|
||||
createAndAddSuccess: 'Playlist "{{playlist}}" aangemaakt met {{count}} nummers',
|
||||
createError: 'Fout bij aanmaken van playlist',
|
||||
deleteSuccess: '{{count}} playlists verwijderd',
|
||||
deleteFailed: 'Fout bij verwijderen van {{name}}',
|
||||
deleteSelected: 'Geselecteerde verwijderen',
|
||||
mergeSuccess: '{{count}} nummers samengevoegd in {{playlist}}',
|
||||
mergeNoNewSongs: 'Geen nieuwe nummers om toe te voegen',
|
||||
mergeError: 'Fout bij samenvoegen van playlists',
|
||||
mergeInto: 'Samenvoegen in',
|
||||
selectionCount: '{{count}} geselecteerd',
|
||||
select: 'Selecteren',
|
||||
startSelect: 'Selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
loadingAlbums: '{{count}} albums oplossen…',
|
||||
loadingArtists: '{{count}} artiesten oplossen…',
|
||||
myPlaylists: 'Mijn playlists',
|
||||
noOtherPlaylists: 'Geen andere playlists beschikbaar',
|
||||
addToPlaylistSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
|
||||
addToPlaylistNoNew: 'Geen nieuwe nummers voor {{playlist}}',
|
||||
addToPlaylistError: 'Fout bij toevoegen aan playlist',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Meest gespeeld',
|
||||
@@ -789,6 +973,9 @@ export const nlTranslation = {
|
||||
sortLeast: 'Minst gespeeld eerst',
|
||||
loadMore: 'Meer albums laden',
|
||||
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
|
||||
noArtists: 'Alle artiesten gefilterd.',
|
||||
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
|
||||
filterCompilationsShort: 'Compilaties verbergen',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -820,5 +1007,13 @@ export const nlTranslation = {
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
listenerCount_one: '{{count}} luisteraar',
|
||||
listenerCount_other: '{{count}} luisteraars',
|
||||
recentlyPlayed: 'Recent gespeeld',
|
||||
upNext: 'Volgende',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Lege map',
|
||||
error: 'Laden mislukt',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ruTranslation = {
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
randomPicker: 'Собрать микс',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
@@ -17,19 +18,23 @@ export const ruTranslation = {
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
cancelDownload: 'Отменить загрузку',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
starred: 'Личное избранное',
|
||||
recent: 'Недавно добавлено',
|
||||
mostPlayed: 'Чаще всего',
|
||||
mostPlayed: 'Популярное',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
discover: 'Обзор',
|
||||
loadMore: 'Ещё',
|
||||
@@ -93,6 +98,8 @@ export const ruTranslation = {
|
||||
addToQueue: 'В конец очереди',
|
||||
enqueueAlbum: 'Альбом в очередь',
|
||||
startRadio: 'Радио по похожим',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
@@ -102,10 +109,14 @@ export const ruTranslation = {
|
||||
unfavoriteArtist: 'Убрать исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Убрать альбом из избранного',
|
||||
removeFromQueue: 'Убрать из очереди',
|
||||
removeFromPlaylist: 'Убрать из плейлиста',
|
||||
openAlbum: 'Открыть альбом',
|
||||
goToArtist: 'К исполнителю',
|
||||
download: 'Скачать (ZIP)',
|
||||
addToPlaylist: 'В плейлист',
|
||||
selectedPlaylists: '{{count}} плейлистов выбрано',
|
||||
selectedAlbums: '{{count}} альбомов выбрано',
|
||||
selectedArtists: '{{count}} исполнителей выбрано',
|
||||
songInfo: 'Сведения о треке',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -133,6 +144,7 @@ export const ruTranslation = {
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackGenre: 'Жанр',
|
||||
trackFormat: 'Формат',
|
||||
@@ -146,6 +158,18 @@ export const ruTranslation = {
|
||||
bioClose: 'Закрыть',
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
filterSongs: 'Фильтр треков…',
|
||||
sortNatural: 'По умолчанию',
|
||||
sortByTitle: 'А–Я (название)',
|
||||
sortByArtist: 'А–Я (исполнитель)',
|
||||
sortByAlbum: 'А–Я (альбом)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Оценка альбома',
|
||||
artistShort: 'Оценка исполнителя',
|
||||
albumAriaLabel: 'Оценка альбома',
|
||||
artistAriaLabel: 'Оценка исполнителя',
|
||||
saveFailed: 'Не удалось сохранить оценку.',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Назад',
|
||||
@@ -190,6 +214,13 @@ export const ruTranslation = {
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Собрать микс',
|
||||
mixByTracks: 'Микс по трекам',
|
||||
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
|
||||
mixByAlbums: 'Микс по альбомам',
|
||||
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
refresh: 'Обновить',
|
||||
@@ -212,6 +243,8 @@ export const ruTranslation = {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Новый микс',
|
||||
remixTooltip: 'Подобрать другие случайные треки',
|
||||
remixGenre: 'Новый микс: {{genre}}',
|
||||
remixTooltipGenre: 'Подобрать другие треки жанра {{genre}}',
|
||||
playAll: 'Воспроизвести всё',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
@@ -234,6 +267,7 @@ export const ruTranslation = {
|
||||
blacklistToggle: 'Фильтр по словам',
|
||||
genreMixTitle: 'Микс по жанру',
|
||||
genreMixDesc: 'Топ-20 жанров по числу треков — нажмите, чтобы собрать микс',
|
||||
genreMixAll: 'Все треки',
|
||||
genreMixLoadMore: 'Ещё 10 жанров',
|
||||
genreMixNoGenres: 'На сервере нет данных о жанрах.',
|
||||
shuffleGenres: 'Другие жанры',
|
||||
@@ -253,6 +287,17 @@ export const ruTranslation = {
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
select: 'Множественный выбор',
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
selectionCount: '{{count}} выбрано',
|
||||
downloadZips: 'Скачать ZIP-архивы',
|
||||
addOffline: 'Добавить офлайн',
|
||||
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
|
||||
downloadZipFailed: 'Не удалось скачать {{name}}',
|
||||
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
|
||||
offlineFailed: 'Не удалось добавить {{name}} офлайн',
|
||||
},
|
||||
artists: {
|
||||
title: 'Исполнители',
|
||||
@@ -268,13 +313,18 @@ export const ruTranslation = {
|
||||
albumCount_few: '{{count}} альбома',
|
||||
albumCount_many: '{{count}} альбомов',
|
||||
albumCount_other: '{{count}} альбомов',
|
||||
selectionCount: '{{count}} выбрано',
|
||||
select: 'Множественный выбор',
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
addToPlaylist: 'В плейлист',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Десктопный клиент для Navidrome',
|
||||
serverName: 'Название сервера (необязательно)',
|
||||
serverNamePlaceholder: 'Мой Navidrome',
|
||||
serverUrl: 'Адрес сервера',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
|
||||
username: 'Логин',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: 'Пароль',
|
||||
@@ -298,6 +348,7 @@ export const ruTranslation = {
|
||||
offlineTitle: 'Нет связи с сервером',
|
||||
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
|
||||
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
|
||||
offlineNoCacheBanner: 'Нет соединения с сервером — {{server}} недоступен',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
@@ -310,6 +361,7 @@ export const ruTranslation = {
|
||||
offlineFilterPlaylists: 'Плейлисты',
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
serverSettings: 'Настройки сервера',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
@@ -338,12 +390,27 @@ export const ruTranslation = {
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
updaterModalTitle: 'Доступна новая версия',
|
||||
updaterChangelog: 'Что нового',
|
||||
updaterDownloadBtn: 'Скачать сейчас',
|
||||
updaterSkipBtn: 'Пропустить эту версию',
|
||||
updaterRemindBtn: 'Напомнить позже',
|
||||
updaterDone: 'Загрузка завершена',
|
||||
updaterShowFolder: 'Показать в папке',
|
||||
updaterInstallHint: 'Закройте Psysonic и запустите установщик вручную.',
|
||||
updaterAurHint: 'Установить обновление через AUR:',
|
||||
updaterErrorMsg: 'Ошибка загрузки',
|
||||
updaterRetryBtn: 'Повторить',
|
||||
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||
durationMinutesOnly: '{{minutes}}мин',
|
||||
updaterOpenGitHub: 'Открыть на GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
@@ -362,6 +429,7 @@ export const ruTranslation = {
|
||||
servers: 'Серверы',
|
||||
serverName: 'Название',
|
||||
serverUrl: 'Адрес',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
|
||||
serverUsername: 'Логин',
|
||||
serverPassword: 'Пароль',
|
||||
addServer: 'Добавить сервер',
|
||||
@@ -377,6 +445,11 @@ export const ruTranslation = {
|
||||
testBtn: 'Проверить',
|
||||
testingBtn: 'Проверка…',
|
||||
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseDesc:
|
||||
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
|
||||
audiomuseIssueHint:
|
||||
'Недавно не удалось собрать Instant Mix — проверьте плагин Navidrome и API AudioMuse. Похожие исполнители подтянутся с Last.fm, если сервер ничего не вернёт.',
|
||||
connected: 'Подключено',
|
||||
failed: 'Ошибка',
|
||||
eqTitle: 'Эквалайзер',
|
||||
@@ -443,9 +516,16 @@ export const ruTranslation = {
|
||||
hotCacheDebounceImmediate: 'Сразу',
|
||||
hotCacheDebounceSeconds: '{{n}} с',
|
||||
hotCacheClearBtn: 'Очистить горячий кэш',
|
||||
audioOutputDevice: 'Устройство вывода звука',
|
||||
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
|
||||
audioOutputDeviceDefault: 'Системное по умолчанию',
|
||||
audioOutputDeviceRefresh: 'Обновить список устройств',
|
||||
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
|
||||
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
|
||||
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
|
||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||
showArtistImages: 'Фото исполнителей',
|
||||
showArtistImagesDesc:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
@@ -453,15 +533,27 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
lyricsServerFirst: 'Предпочитать тексты с сервера',
|
||||
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы сначала использовать LRCLIB.',
|
||||
enableNeteaselyrics: 'Тексты из Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда остальные способы не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
|
||||
lyricsSourcesTitle: 'Источники текстов',
|
||||
lyricsSourcesDesc: 'Выберите источники для поиска текстов и их порядок. Перетаскивайте для сортировки. Отключённые источники пропускаются.',
|
||||
lyricsSourceServer: 'Сервер',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: 'Netease Cloud Music',
|
||||
downloadsTitle: 'Экспорт ZIP и архивов',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
pickFolder: 'Выбрать',
|
||||
@@ -471,8 +563,6 @@ export const ruTranslation = {
|
||||
aboutTitle: 'О Psysonic',
|
||||
aboutDesc:
|
||||
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
|
||||
aboutFeatures:
|
||||
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
|
||||
aboutLicense: 'Лицензия',
|
||||
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
|
||||
aboutRepo: 'Исходный код на GitHub',
|
||||
@@ -504,6 +594,18 @@ export const ruTranslation = {
|
||||
tabServer: 'Сервер',
|
||||
tabSystem: 'Система',
|
||||
tabGeneral: 'Общие',
|
||||
ratingsSectionTitle: 'Рейтинги',
|
||||
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
|
||||
ratingsSkipStarDesc:
|
||||
'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
|
||||
ratingsSkipStarThresholdLabel: 'Скипов',
|
||||
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
|
||||
ratingsMixFilterDesc:
|
||||
'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
|
||||
ratingsMixMinSong: 'Песни',
|
||||
ratingsMixMinAlbum: 'Альбомы',
|
||||
ratingsMixMinArtist: 'Исполнители',
|
||||
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
|
||||
backupTitle: 'Резервная копия',
|
||||
backupExport: 'Экспорт настроек',
|
||||
backupExportDesc:
|
||||
@@ -529,6 +631,7 @@ export const ruTranslation = {
|
||||
shortcutSeekForward: 'Вперёд на 10 с',
|
||||
shortcutSeekBackward: 'Назад на 10 с',
|
||||
shortcutToggleQueue: 'Показать / скрыть очередь',
|
||||
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Системный полный экран',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
@@ -537,6 +640,8 @@ export const ruTranslation = {
|
||||
replayGainMode: 'Режим',
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
replayGainFallback: 'Резерв (без тегов / радио)',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
@@ -546,6 +651,9 @@ export const ruTranslation = {
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
nextTrackBufferingTitle: 'Буферизация следующего трека',
|
||||
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
|
||||
preloadOff: 'Выкл.',
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
preloadEarly: 'Рано (через 5 с после старта)',
|
||||
preloadCustom: 'Свой интервал',
|
||||
@@ -553,6 +661,32 @@ export const ruTranslation = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
fsPlayerSection: 'Полноэкранный плеер',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
seekbarLinedot: 'Линия и точка',
|
||||
seekbarBar: 'Полоса',
|
||||
seekbarThick: 'Толстая полоса',
|
||||
seekbarSegmented: 'Сегменты',
|
||||
seekbarNeon: 'Неон',
|
||||
seekbarPulsewave: 'Пульс-волна',
|
||||
seekbarParticletrail: 'Частицы',
|
||||
seekbarLiquidfill: 'Жидкость',
|
||||
seekbarRetrotape: 'Ретро-лента',
|
||||
themeSchedulerTitle: 'Расписание тем',
|
||||
themeSchedulerEnable: 'Включить расписание тем',
|
||||
themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток',
|
||||
themeSchedulerDayTheme: 'Дневная тема',
|
||||
themeSchedulerDayStart: 'День начинается в',
|
||||
themeSchedulerNightTheme: 'Ночная тема',
|
||||
themeSchedulerNightStart: 'Ночь начинается в',
|
||||
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
|
||||
uiScaleTitle: 'Масштаб интерфейса',
|
||||
uiScaleLabel: 'Масштаб',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
@@ -658,6 +792,28 @@ export const ruTranslation = {
|
||||
q36: 'Сколько места занимает офлайн?',
|
||||
a36:
|
||||
'Лимит задаётся в настройках. При переполнении на странице альбома появится предупреждение; удалить можно из офлайн-библиотеки.',
|
||||
q37: 'Как ставить оценки трекам, альбомам и исполнителям?',
|
||||
a37: 'Psysonic поддерживает оценки от 1 до 5 звёзд через OpenSubsonic API (требует Navidrome ≥ 0.53). Треки оцениваются в списке треков альбома или плейлиста, в контекстном меню или прямо в панели плеера. Альбомы — на странице альбома, исполнители — на странице исполнителя.',
|
||||
q38: 'Можно ли удалить или изменить оценку?',
|
||||
a38: 'Да. Повторный клик по активной звезде полностью удаляет оценку — второй клик по той же звезде её стирает. Для изменения оценки просто нажмите другую звезду.',
|
||||
q39: 'Что такое Skip-to-1★ и фильтр по рейтингу?',
|
||||
a39: 'Skip-to-1★ автоматически присваивает оценку 1 звезда после заданного числа последовательных пропусков. Включается в Настройки → Рейтинги. Там же задаётся минимальный рейтинг для случайных миксов.',
|
||||
q40: 'Что такое браузер папок?',
|
||||
a40: 'Браузер папок (боковая панель) позволяет навигировать по музыкальному каталогу сервера в колонках Миллера. Клик по папке открывает содержимое; иконка воспроизведения запускает или добавляет в очередь.',
|
||||
q41: 'Что такое планировщик тем?',
|
||||
a41: 'Настройки → Внешний вид → Авто-смена темы: задайте дневную и ночную тему со временем начала. Psysonic переключается автоматически в указанное время.',
|
||||
q42: 'Можно ли масштабировать интерфейс?',
|
||||
a42: 'Да. Настройки → Внешний вид → Масштаб интерфейса позволяет масштабировать весь UI от 80 % до 125 %.',
|
||||
q43: 'Можно ли изменить стиль seekbar?',
|
||||
a43: 'Да. Настройки → Внешний вид → Стиль seekbar предлагает 10 стилей: Осциллограмма, Линия и точка, Полоса, Толстая полоса, Сегментированная, Неоновое свечение, Пульсовая волна, Частицы, Жидкое заполнение и Ретро кассета.',
|
||||
q44: 'Что такое AutoEQ?',
|
||||
a44: '10-полосный эквалайзер в Настройки → Аудио включает поиск AutoEQ. Введите модель наушников — Psysonic автоматически загрузит и применит профиль коррекции.',
|
||||
q45: 'Что такое Replay Gain?',
|
||||
a45: 'Replay Gain нормализует громкость, чтобы громкие и тихие альбомы воспроизводились на одинаковом уровне. Включается в Настройки → Аудио → Replay Gain; режим трека или альбома.',
|
||||
q46: 'Что такое горячий кэш?',
|
||||
a46: 'Горячий кэш (Alpha, Настройки → Библиотека) предзагружает следующие треки очереди на диск для мгновенного воспроизведения. Psysonic хранит текущий трек и следующие 5, вытесняя старые записи при достижении лимита.',
|
||||
q47: 'Можно ли кэшировать плейлист для офлайн?',
|
||||
a47: 'Да. Откройте детали плейлиста и нажмите иконку загрузки. После полного кэширования иконка сменяется на красную корзину; нажмите её, чтобы удалить плейлист из офлайн-кэша.',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь',
|
||||
@@ -698,11 +854,12 @@ export const ruTranslation = {
|
||||
genreDistribution: 'Жанры (топ-20)',
|
||||
loadMore: 'Ещё',
|
||||
statArtists: 'Исполнители',
|
||||
statArtistsTooltip: 'Только исполнители альбомов — артисты, присутствующие лишь в треках (фичеринг, гость и т. д.) без собственного альбома, не учитываются.',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Треки',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Всего прослушано',
|
||||
genreInsights: 'Жанры подробнее',
|
||||
statPlaytime: 'Время звучания',
|
||||
genreInsights: 'По жанрам',
|
||||
formatDistribution: 'Форматы',
|
||||
formatSample: 'Выборка {{n}} треков',
|
||||
computing: 'Считаем…',
|
||||
@@ -742,6 +899,10 @@ export const ruTranslation = {
|
||||
lfmMinutesAgo: '{{n}} мин назад',
|
||||
lfmHoursAgo: '{{n}} ч назад',
|
||||
lfmDaysAgo: '{{n}} дн. назад',
|
||||
topRatedSongs: 'Лучшие по рейтингу треки',
|
||||
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
||||
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
||||
noRatedArtists: 'Нет оценённых исполнителей.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Плеер',
|
||||
@@ -765,6 +926,7 @@ export const ruTranslation = {
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceNetease: 'Источник: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
@@ -816,8 +978,10 @@ export const ruTranslation = {
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'В плейлист',
|
||||
addSelected: 'Добавить выбранные',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
removeOffline: 'Удалить из офлайн-кэша',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
publicLabel: 'Публичный',
|
||||
privateLabel: 'Личный',
|
||||
@@ -833,16 +997,46 @@ export const ruTranslation = {
|
||||
coverUpdated: 'Обложка обновлена',
|
||||
metaSaved: 'Плейлист сохранён',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} треков добавлено в {{playlist}}',
|
||||
addPartial: '{{added}} добавлено, {{skipped}} пропущено (дубликаты)',
|
||||
addAllSkipped: 'Все пропущены ({{count}} дубликатов)',
|
||||
addError: 'Ошибка при добавлении треков',
|
||||
createAndAddSuccess: 'Плейлист "{{playlist}}" создан с {{count}} треками',
|
||||
createError: 'Ошибка при создании плейлиста',
|
||||
deleteSuccess: '{{count}} плейлистов удалено',
|
||||
deleteFailed: 'Ошибка при удалении {{name}}',
|
||||
deleteSelected: 'Удалить выбранные',
|
||||
mergeSuccess: '{{count}} треков объединено в {{playlist}}',
|
||||
mergeNoNewSongs: 'Нет новых треков для добавления',
|
||||
mergeError: 'Ошибка при объединении плейлистов',
|
||||
mergeInto: 'Объединить в',
|
||||
selectionCount: '{{count}} выбрано',
|
||||
select: 'Выбрать',
|
||||
startSelect: 'Включить выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
loadingAlbums: 'Загрузка {{count}} альбомов…',
|
||||
loadingArtists: 'Загрузка {{count}} исполнителей…',
|
||||
myPlaylists: 'Мои плейлисты',
|
||||
noOtherPlaylists: 'Нет других доступных плейлистов',
|
||||
addToPlaylistSuccess: '{{count}} треков добавлено в {{playlist}}',
|
||||
addToPlaylistNoNew: 'Нет новых треков для {{playlist}}',
|
||||
addToPlaylistError: 'Ошибка при добавлении в плейлист',
|
||||
removeSuccess: 'Трек убран из плейлиста',
|
||||
removeError: 'Ошибка при удалении из плейлиста',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Часто слушаемое',
|
||||
title: 'Популярное',
|
||||
topArtists: 'Топ исполнителей',
|
||||
topAlbums: 'Топ альбомов',
|
||||
plays: '{{n}} прослушиваний',
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
noData: 'Пока нет данных о прослушивании. Начните слушать!',
|
||||
noArtists: 'Все исполнители отфильтрованы.',
|
||||
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
|
||||
filterCompilationsShort: 'Скрыть сборники',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
@@ -874,5 +1068,13 @@ export const ruTranslation = {
|
||||
favorite: 'В избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
listenerCount_one: '{{count}} слушатель',
|
||||
listenerCount_other: '{{count}} слушателей',
|
||||
recentlyPlayed: 'Недавно сыгранное',
|
||||
upNext: 'Следующий',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Папка пуста',
|
||||
error: 'Ошибка загрузки',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const zhTranslation = {
|
||||
newReleases: '新发布',
|
||||
allAlbums: '全部专辑',
|
||||
randomAlbums: '随机专辑',
|
||||
randomPicker: '创建混音',
|
||||
artists: '艺术家',
|
||||
randomMix: '随机混音',
|
||||
favorites: '收藏夹',
|
||||
@@ -16,13 +17,17 @@ export const zhTranslation = {
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
cancelDownload: '取消下载',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
folderBrowser: '文件夹浏览器',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
expandPlaylists: '展开播放列表',
|
||||
collapsePlaylists: '收起播放列表',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -92,6 +97,8 @@ export const zhTranslation = {
|
||||
addToQueue: '添加到队列',
|
||||
enqueueAlbum: '专辑加入队列',
|
||||
startRadio: '开始电台',
|
||||
instantMix: '即时混音',
|
||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
@@ -105,6 +112,9 @@ export const zhTranslation = {
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
selectedPlaylists: '已选择 {{count}} 个播放列表',
|
||||
selectedAlbums: '已选择 {{count}} 个专辑',
|
||||
selectedArtists: '已选择 {{count}} 个艺术家',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -131,6 +141,7 @@ export const zhTranslation = {
|
||||
goToArtist: '前往 {{artist}}',
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackAlbum: '专辑',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
@@ -144,6 +155,18 @@ export const zhTranslation = {
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
filterSongs: '筛选歌曲…',
|
||||
sortNatural: '默认顺序',
|
||||
sortByTitle: 'A–Z(标题)',
|
||||
sortByArtist: 'A–Z(艺术家)',
|
||||
sortByAlbum: 'A–Z(专辑)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: '专辑评分',
|
||||
artistShort: '艺人评分',
|
||||
albumAriaLabel: '专辑评分',
|
||||
artistAriaLabel: '艺人评分',
|
||||
saveFailed: '无法保存评分。',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
@@ -186,6 +209,13 @@ export const zhTranslation = {
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomLanding: {
|
||||
title: '创建混音',
|
||||
mixByTracks: '按曲目混音',
|
||||
mixByTracksDesc: '从整个媒体库随机选取曲目',
|
||||
mixByAlbums: '按专辑混音',
|
||||
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
refresh: '刷新',
|
||||
@@ -206,6 +236,8 @@ export const zhTranslation = {
|
||||
title: '随机混音',
|
||||
remix: '重新混音',
|
||||
remixTooltip: '加载新的随机歌曲',
|
||||
remixGenre: '重混 {{genre}}',
|
||||
remixTooltipGenre: '加载新的{{genre}}歌曲',
|
||||
playAll: '全部播放',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
@@ -227,6 +259,7 @@ export const zhTranslation = {
|
||||
blacklistToggle: '关键词过滤',
|
||||
genreMixTitle: '流派混音',
|
||||
genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音',
|
||||
genreMixAll: '所有歌曲',
|
||||
genreMixLoadMore: '加载 10 首更多',
|
||||
genreMixNoGenres: '服务器上未找到流派。',
|
||||
shuffleGenres: '显示其他流派',
|
||||
@@ -244,6 +277,17 @@ export const zhTranslation = {
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
select: '多选',
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
selectionCount: '{{count}} 已选择',
|
||||
downloadZips: '下载 ZIP',
|
||||
addOffline: '添加离线',
|
||||
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '已下载 {{count}} 个 ZIP',
|
||||
downloadZipFailed: '下载 {{name}} 失败',
|
||||
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
|
||||
offlineFailed: '添加 {{name}} 离线失败',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
@@ -257,13 +301,18 @@ export const zhTranslation = {
|
||||
notFound: '未找到艺术家。',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
albumCount_other: '{{count}} 张专辑',
|
||||
selectionCount: '{{count}} 已选择',
|
||||
select: '多选',
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
},
|
||||
login: {
|
||||
subtitle: '您的 Navidrome 桌面播放器',
|
||||
serverName: '服务器名称(可选)',
|
||||
serverNamePlaceholder: '我的 Navidrome',
|
||||
serverUrl: '服务器地址',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 music.example.com',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
|
||||
username: '用户名',
|
||||
usernamePlaceholder: 'admin',
|
||||
password: '密码',
|
||||
@@ -287,11 +336,13 @@ export const zhTranslation = {
|
||||
offlineTitle: '无服务器连接',
|
||||
offlineSubtitle: '无法连接到 {{server}}。请检查您的网络或服务器。',
|
||||
offlineModeBanner: '离线模式 — 正在从本地缓存播放',
|
||||
offlineNoCacheBanner: '无服务器连接 — 无法访问 {{server}}',
|
||||
offlineLibraryTitle: '离线音乐库',
|
||||
offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。',
|
||||
offlineAlbumCount_one: '{{n}} 张专辑',
|
||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||
retry: '重试',
|
||||
serverSettings: '服务器设置',
|
||||
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
|
||||
lastfmSessionInvalid: '会话无效 — 点击重新连接',
|
||||
},
|
||||
@@ -320,12 +371,27 @@ export const zhTranslation = {
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已发布',
|
||||
updaterWebsite: '官网',
|
||||
updaterModalTitle: '有新版本可用',
|
||||
updaterChangelog: '更新内容',
|
||||
updaterDownloadBtn: '立即下载',
|
||||
updaterSkipBtn: '跳过此版本',
|
||||
updaterRemindBtn: '稍后提醒',
|
||||
updaterDone: '下载完成',
|
||||
updaterShowFolder: '在文件夹中显示',
|
||||
updaterInstallHint: '请关闭 Psysonic 并手动运行安装程序。',
|
||||
updaterAurHint: '通过 AUR 安装更新:',
|
||||
updaterErrorMsg: '下载失败',
|
||||
updaterRetryBtn: '重试',
|
||||
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||
durationMinutesOnly: '{{minutes}}分钟',
|
||||
updaterOpenGitHub: '在 GitHub 上打开',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -343,6 +409,7 @@ export const zhTranslation = {
|
||||
servers: '服务器',
|
||||
serverName: '服务器名称',
|
||||
serverUrl: '服务器地址',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
|
||||
serverUsername: '用户名',
|
||||
serverPassword: '密码',
|
||||
addServer: '添加服务器',
|
||||
@@ -358,6 +425,11 @@ export const zhTranslation = {
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||
audiomuseDesc:
|
||||
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
|
||||
audiomuseIssueHint:
|
||||
'近期即时混音失败 — 请检查 Navidrome 插件与 AudioMuse API。若服务器无结果,将回退使用 Last.fm 的相似艺人。',
|
||||
connected: '已连接',
|
||||
failed: '失败',
|
||||
eqTitle: '均衡器',
|
||||
@@ -422,6 +494,13 @@ export const zhTranslation = {
|
||||
hotCacheDebounceImmediate: '立即',
|
||||
hotCacheDebounceSeconds: '{{n}} 秒',
|
||||
hotCacheClearBtn: '清空热缓存',
|
||||
audioOutputDevice: '音频输出设备',
|
||||
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
|
||||
audioOutputDeviceDefault: '系统默认',
|
||||
audioOutputDeviceRefresh: '刷新设备列表',
|
||||
audioOutputDeviceOsDefaultNow: '当前系统输出',
|
||||
audioOutputDeviceListError: '无法加载音频设备列表。',
|
||||
audioOutputDeviceNotInCurrentList: '不在当前列表中',
|
||||
hiResTitle: '原生高清晰度播放',
|
||||
hiResEnabled: '启用原生高清晰度播放',
|
||||
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||
@@ -439,6 +518,13 @@ export const zhTranslation = {
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
|
||||
enableNeteaselyrics: '网易云音乐歌词',
|
||||
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
|
||||
lyricsSourcesTitle: '歌词来源',
|
||||
lyricsSourcesDesc: '选择要查询的歌词来源及其顺序。拖动以排序。已禁用的来源将被跳过。',
|
||||
lyricsSourceServer: '服务器',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: '网易云音乐',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
@@ -448,7 +534,6 @@ export const zhTranslation = {
|
||||
logout: '退出登录',
|
||||
aboutTitle: '关于 Psysonic',
|
||||
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的现代桌面音乐播放器。基于 Tauri v2 和原生 Rust 音频引擎构建——轻量快速,功能丰富:波形进度条、同步歌词、Last.fm 集成、10 段均衡器、交叉淡入淡出、无缝播放、响度增益、流派浏览以及大量精美主题。',
|
||||
aboutFeatures: '多服务器 · Last.fm 记录 & 喜爱 · 全屏环境舞台 · 波形跳转 · 同步歌词 · 10 段 EQ · 交叉淡入淡出 & 无缝播放 · 响度增益 · 流派 · 63 个主题 · 5 种语言',
|
||||
aboutLicense: '许可证',
|
||||
aboutLicenseText: 'GNU GPL v3 — 在相同许可证下可自由使用、修改和分发。',
|
||||
aboutRepo: 'GitHub 上的源代码',
|
||||
@@ -479,6 +564,18 @@ export const zhTranslation = {
|
||||
tabServer: '服务器',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
ratingsSectionTitle: '评分',
|
||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||
ratingsSkipStarDesc:
|
||||
'连续多次跳过后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||
ratingsSkipStarThresholdLabel: '跳过次数',
|
||||
ratingsMixFilterTitle: '按评分筛选',
|
||||
ratingsMixFilterDesc:
|
||||
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
|
||||
ratingsMixMinSong: '歌曲',
|
||||
ratingsMixMinAlbum: '专辑',
|
||||
ratingsMixMinArtist: '艺人',
|
||||
ratingsMixMinThresholdAria: '最低星数:{{label}}',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
@@ -502,6 +599,7 @@ export const zhTranslation = {
|
||||
shortcutSeekForward: '快进 10 秒',
|
||||
shortcutSeekBackward: '快退 10 秒',
|
||||
shortcutToggleQueue: '切换队列',
|
||||
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: '全屏播放器',
|
||||
shortcutNativeFullscreen: '原生全屏',
|
||||
playbackTitle: '播放',
|
||||
@@ -510,6 +608,8 @@ export const zhTranslation = {
|
||||
replayGainMode: '模式',
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
replayGainFallback: '回退增益(无标签 / 收音机)',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
@@ -519,6 +619,9 @@ export const zhTranslation = {
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
nextTrackBufferingTitle: '下一曲目缓冲',
|
||||
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
|
||||
preloadOff: '关闭',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
preloadEarly: '提前(播放5秒后)',
|
||||
preloadCustom: '自定义',
|
||||
@@ -526,6 +629,32 @@ export const zhTranslation = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
fsPlayerSection: '全屏播放器',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
seekbarLinedot: '线条与点',
|
||||
seekbarBar: '条形',
|
||||
seekbarThick: '粗条形',
|
||||
seekbarSegmented: '分段式',
|
||||
seekbarNeon: '霓虹',
|
||||
seekbarPulsewave: '脉冲波',
|
||||
seekbarParticletrail: '粒子轨迹',
|
||||
seekbarLiquidfill: '液体填充',
|
||||
seekbarRetrotape: '复古磁带',
|
||||
themeSchedulerTitle: '主题定时切换',
|
||||
themeSchedulerEnable: '启用主题定时器',
|
||||
themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换',
|
||||
themeSchedulerDayTheme: '白天主题',
|
||||
themeSchedulerDayStart: '白天开始时间',
|
||||
themeSchedulerNightTheme: '夜晚主题',
|
||||
themeSchedulerNightStart: '夜晚开始时间',
|
||||
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleLabel: '缩放',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: '新功能',
|
||||
@@ -608,6 +737,28 @@ export const zhTranslation = {
|
||||
a35: '打开任意专辑并点击专辑标题栏的下载图标。Psysonic 会在后台下载所有曲目。按钮上会显示进度。缓存完成后,图标变为绿色。您可以在离线音乐库页面(侧边栏)查看和移除缓存的专辑。',
|
||||
q36: '离线缓存可以使用多少存储空间?',
|
||||
a36: '您可以在 设置 → 音乐库 中设置最大缓存大小。达到限制时,专辑页面会显示警告横幅。您可以从离线音乐库中删除单个专辑以释放空间。',
|
||||
q37: '如何对歌曲、专辑和艺术家评分?',
|
||||
a37: 'Psysonic 通过 OpenSubsonic API 支持 1–5 星评分(需要 Navidrome ≥ 0.53)。可在专辑或播放列表曲目列表、右键菜单或播放器栏中对歌曲评分。专辑在详情页评分,艺术家在艺术家页评分。',
|
||||
q38: '可以删除或更改评分吗?',
|
||||
a38: '可以。再次点击当前激活的星星即可完全删除评分——第二次点击同一颗星即可清除。点击其他星星可更改评分。',
|
||||
q39: '什么是 Skip-to-1★ 和评分过滤器?',
|
||||
a39: 'Skip-to-1★ 会在歌曲被连续手动跳过一定次数后自动赋予 1 星评分。在 设置 → 评分 中启用并设置阈值。同样可在此设置随机混音的最低星级过滤条件。',
|
||||
q40: '什么是文件夹浏览器?',
|
||||
a40: '文件夹浏览器(侧边栏)使用 Miller 列布局浏览服务器音乐目录。点击文件夹查看内容;点击播放图标直接播放或添加到队列。',
|
||||
q41: '什么是主题调度器?',
|
||||
a41: '设置 → 外观 → 自动切换主题:设置日间主题和夜间主题及开始时间,Psysonic 将在指定时间自动切换主题。',
|
||||
q42: '可以调整界面缩放吗?',
|
||||
a42: '可以。设置 → 外观 → 界面缩放 可将整个界面缩放至 80%–125%。',
|
||||
q43: '可以更改进度条样式吗?',
|
||||
a43: '可以。设置 → 外观 → 进度条样式 提供 10 种样式:波形、线条与点、条形、粗条形、分段、霓虹辉光、脉冲波、粒子轨迹、液体填充和复古磁带。',
|
||||
q44: '什么是 AutoEQ?',
|
||||
a44: '设置 → 音频 中的 10 频段均衡器包含 AutoEQ 查找功能。输入耳机型号即可自动加载校正配置文件。',
|
||||
q45: '什么是 Replay Gain?',
|
||||
a45: 'Replay Gain 标准化音量,使响亮和安静的专辑以一致的音量播放。在 设置 → 音频 → Replay Gain 中启用;选择曲目模式或专辑模式。',
|
||||
q46: '什么是热缓存?',
|
||||
a46: '热缓存(Alpha,设置 → 音乐库)将队列中后续曲目预加载到磁盘,实现即时播放,对于慢速或远程服务器尤为有用。Psysonic 保留当前曲目和后续 5 首,达到大小限制时淘汰旧条目。',
|
||||
q47: '可以将播放列表缓存到离线吗?',
|
||||
a47: '可以。在播放列表详情中点击下载图标,所有曲目将在后台下载。完全缓存后图标变为红色垃圾桶;再次点击即可从离线缓存中删除该播放列表。',
|
||||
},
|
||||
queue: {
|
||||
title: '队列',
|
||||
@@ -648,10 +799,11 @@ export const zhTranslation = {
|
||||
genreDistribution: '流派分布(前 20)',
|
||||
loadMore: '加载更多',
|
||||
statArtists: '艺术家',
|
||||
statArtistsTooltip: '仅限专辑艺术家——仅作为单曲艺术家出现(合唱、客串等)且无自己专辑的艺术家不计入此处。',
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
statPlaytime: '音频总时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
@@ -681,6 +833,10 @@ export const zhTranslation = {
|
||||
lfmMinutesAgo: '{{n}} 分钟前',
|
||||
lfmHoursAgo: '{{n}} 小时前',
|
||||
lfmDaysAgo: '{{n}} 天前',
|
||||
topRatedSongs: '最高评分歌曲',
|
||||
topRatedArtists: '最高评分艺人',
|
||||
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
||||
noRatedArtists: '暂无已评分艺人。',
|
||||
},
|
||||
player: {
|
||||
regionLabel: '音乐播放器',
|
||||
@@ -707,6 +863,7 @@ export const zhTranslation = {
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
lyricsSourceNetease: '来源:网易云',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
@@ -758,8 +915,10 @@ export const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
addSelected: '添加所选',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
removeOffline: '从离线缓存中移除',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
publicLabel: '公开',
|
||||
privateLabel: '私有',
|
||||
@@ -775,6 +934,31 @@ export const zhTranslation = {
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
|
||||
addPartial: '{{added}} 首已添加,{{skipped}} 首跳过(重复)',
|
||||
addAllSkipped: '全部跳过({{count}} 首重复)',
|
||||
addError: '添加歌曲时出错',
|
||||
createAndAddSuccess: '播放列表 "{{playlist}}" 已创建,包含 {{count}} 首歌曲',
|
||||
createError: '创建播放列表时出错',
|
||||
deleteSuccess: '{{count}} 个播放列表已删除',
|
||||
deleteFailed: '删除 {{name}} 时出错',
|
||||
deleteSelected: '删除所选',
|
||||
mergeSuccess: '{{count}} 首歌曲已合并到 {{playlist}}',
|
||||
mergeNoNewSongs: '没有新歌曲可添加',
|
||||
mergeError: '合并播放列表时出错',
|
||||
mergeInto: '合并到',
|
||||
selectionCount: '{{count}} 已选择',
|
||||
select: '选择',
|
||||
startSelect: '启用选择',
|
||||
cancelSelect: '取消',
|
||||
loadingAlbums: '正在解析 {{count}} 张专辑…',
|
||||
loadingArtists: '正在解析 {{count}} 位艺术家…',
|
||||
myPlaylists: '我的播放列表',
|
||||
noOtherPlaylists: '没有其他可用播放列表',
|
||||
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
|
||||
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
|
||||
addToPlaylistError: '添加到播放列表时出错',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: '最常播放',
|
||||
@@ -784,7 +968,10 @@ export const zhTranslation = {
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
noData: '暂无播放数据。开始收听吧!',
|
||||
noArtists: '所有艺术家已过滤。',
|
||||
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
|
||||
filterCompilationsShort: '隐藏合辑',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
@@ -816,5 +1003,13 @@ export const zhTranslation = {
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
listenerCount_one: '{{count}} 位听众',
|
||||
listenerCount_other: '{{count}} 位听众',
|
||||
recentlyPlayed: '最近播放',
|
||||
upNext: '即将播放',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: '空文件夹',
|
||||
error: '加载失败',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Play, SlidersHorizontal } from 'lucide-react';
|
||||
import { Play, SlidersVertical } from 'lucide-react';
|
||||
import {
|
||||
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
|
||||
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
|
||||
@@ -140,7 +140,7 @@ export default function AdvancedSearch() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<SlidersVertical size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
{t('search.advanced')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,15 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -33,6 +35,7 @@ export default function AlbumDetail() {
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -42,33 +45,49 @@ export default function AlbumDetail() {
|
||||
const [bio, setBio] = useState<string | null>(null);
|
||||
const [bioOpen, setBioOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [offlineStorageFull, setOfflineStorageFull] = useState(false);
|
||||
|
||||
const { downloadAlbum, deleteAlbum } = useOfflineStore();
|
||||
const offlineTracks = useOfflineStore(s => s.tracks);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineJobs = useOfflineStore(s => s.jobs);
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||
|
||||
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
|
||||
if (!album) return 'none';
|
||||
const meta = offlineAlbums[`${serverId}:${album.album.id}`];
|
||||
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]);
|
||||
if (isDownloaded) return 'cached';
|
||||
const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading'));
|
||||
return isDownloading ? 'downloading' : 'none';
|
||||
})();
|
||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [sortClickCount, setSortClickCount] = useState(0);
|
||||
|
||||
const offlineProgress = (() => {
|
||||
if (!album) return null;
|
||||
const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id);
|
||||
if (albumJobs.length === 0) return null;
|
||||
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
|
||||
return { done, total: albumJobs.length };
|
||||
})();
|
||||
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
|
||||
const albumId = album?.album.id ?? '';
|
||||
|
||||
// Selectors return primitives so Zustand only triggers a re-render when the VALUE
|
||||
// actually changes — not on every `jobs` array mutation during batch downloads.
|
||||
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
|
||||
if (!albumId) return 'none';
|
||||
const meta = s.albums[`${serverId}:${albumId}`];
|
||||
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
return isDownloaded ? 'cached' : 'none';
|
||||
});
|
||||
const isOfflineDownloading = useOfflineJobStore(s =>
|
||||
!!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
|
||||
);
|
||||
const offlineProgressDone = useOfflineJobStore(s => {
|
||||
if (!albumId) return 0;
|
||||
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
|
||||
});
|
||||
const offlineProgressTotal = useOfflineJobStore(s => {
|
||||
if (!albumId) return 0;
|
||||
return s.jobs.filter(j => j.albumId === albumId).length;
|
||||
});
|
||||
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
|
||||
const offlineProgress = offlineProgressTotal > 0
|
||||
? { done: offlineProgressDone, total: offlineProgressTotal }
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -90,6 +109,11 @@ export default function AlbumDetail() {
|
||||
}).catch(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||||
}, [id, album?.album.id, album?.album.userRating]);
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
@@ -126,9 +150,37 @@ const handleEnqueueAll = () => {
|
||||
|
||||
const handleRate = async (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
await setRating(songId, rating);
|
||||
};
|
||||
|
||||
const handleAlbumEntityRating = async (rating: number) => {
|
||||
if (!album || album.album.id !== id) return;
|
||||
const albumId = album.album.id;
|
||||
const ratingAtStart = album.album.userRating ?? 0;
|
||||
|
||||
setAlbumEntityRating(rating);
|
||||
|
||||
if (albumEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(albumId, rating);
|
||||
setAlbum(cur =>
|
||||
cur && cur.album.id === albumId
|
||||
? { ...cur, album: { ...cur.album, userRating: rating } }
|
||||
: cur,
|
||||
);
|
||||
} catch (err) {
|
||||
setAlbumEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(serverId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBio = async () => {
|
||||
if (!album) return;
|
||||
if (bio) { setBioOpen(true); return; }
|
||||
@@ -141,45 +193,22 @@ const handleEnqueueAll = () => {
|
||||
if (!album) return;
|
||||
const { name, id: albumId } = album.album;
|
||||
|
||||
// Ask for folder before starting download if not already set
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
|
||||
setDownloadProgress(0);
|
||||
const filename = `${sanitizeFilename(name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
|
||||
if (total && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setDownloadProgress(Math.round((received / total) * 100));
|
||||
}
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer() as ArrayBuffer;
|
||||
chunks.push(new Uint8Array(buffer));
|
||||
setDownloadProgress(100);
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,6 +263,65 @@ const handleEnqueueAll = () => {
|
||||
deleteAlbum(album.album.id, serverId);
|
||||
};
|
||||
|
||||
const handleSort = (key: typeof sortKey) => {
|
||||
if (key === 'natural') return;
|
||||
if (sortKey === key) {
|
||||
const nextCount = sortClickCount + 1;
|
||||
if (nextCount >= 3) {
|
||||
setSortKey('natural');
|
||||
setSortDir('asc');
|
||||
setSortClickCount(0);
|
||||
} else {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
setSortClickCount(nextCount);
|
||||
}
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
setSortClickCount(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Must be before early returns — hooks must be called unconditionally.
|
||||
const mergedStarredSongs = useMemo(() => new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||
]), [starredSongs, starredOverrides]);
|
||||
|
||||
const displayedSongs = useMemo(() => {
|
||||
if (!album) return [];
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q && sortKey === 'natural') return album.songs;
|
||||
let result = [...album.songs];
|
||||
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
||||
if (sortKey !== 'natural') {
|
||||
result.sort((a, b) => {
|
||||
let av: string | number;
|
||||
let bv: string | number;
|
||||
switch (sortKey) {
|
||||
case 'title': av = a.title; bv = b.title; break;
|
||||
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
||||
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
||||
case 'favorite':
|
||||
av = mergedStarredSongs.has(a.id) ? 1 : 0;
|
||||
bv = mergedStarredSongs.has(b.id) ? 1 : 0;
|
||||
break;
|
||||
case 'rating':
|
||||
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
|
||||
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
||||
break;
|
||||
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
||||
default: av = a.title; bv = b.title;
|
||||
}
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
return sortDir === 'asc' ? av - bv : bv - av;
|
||||
}
|
||||
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]);
|
||||
|
||||
// Hooks must be called unconditionally — derive from nullable album state.
|
||||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||||
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
|
||||
@@ -257,7 +345,7 @@ const handleEnqueueAll = () => {
|
||||
coverKey={coverKey}
|
||||
resolvedCoverUrl={resolvedCoverUrl}
|
||||
isStarred={isStarred}
|
||||
downloadProgress={downloadProgress}
|
||||
downloadProgress={null}
|
||||
bio={bio}
|
||||
bioOpen={bioOpen}
|
||||
onToggleStar={toggleStar}
|
||||
@@ -266,10 +354,13 @@ const handleEnqueueAll = () => {
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
offlineStatus={offlineStatus}
|
||||
offlineStatus={resolvedOfflineStatus}
|
||||
offlineProgress={offlineProgress}
|
||||
onCacheOffline={handleCacheOffline}
|
||||
onRemoveOffline={handleRemoveOffline}
|
||||
entityRatingValue={albumEntityRating}
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
/>
|
||||
{offlineStorageFull && (
|
||||
<div className="offline-storage-full-banner" role="alert">
|
||||
@@ -284,20 +375,42 @@ const handleEnqueueAll = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||
placeholder={t('albumDetail.filterSongs')}
|
||||
value={filterText}
|
||||
onChange={e => setFilterText(e.target.value)}
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
|
||||
onClick={() => setFilterText('')}
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlbumTrackList
|
||||
songs={songs}
|
||||
songs={displayedSongs}
|
||||
sorted={sortKey !== 'natural' || !!filterText.trim()}
|
||||
hasVariousArtists={hasVariousArtists}
|
||||
currentTrack={currentTrack}
|
||||
isPlaying={isPlaying}
|
||||
ratings={ratings}
|
||||
starredSongs={new Set([
|
||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||
])}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
starredSongs={mergedStarredSongs}
|
||||
onPlaySong={handlePlaySong}
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
onContextMenu={openContextMenu}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { X } from 'lucide-react';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
@@ -20,6 +31,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
export default function Albums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [sort, setSort] = useState<SortType>('alphabeticalByName');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,6 +46,71 @@ export default function Albums() {
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
clearSelection();
|
||||
for (const album of selectedAlbums) {
|
||||
const downloadId = crypto.randomUUID();
|
||||
const filename = `${sanitizeFilename(album.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(album.id);
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed for', album.name, e);
|
||||
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOffline = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
const fromNum = parseInt(yearFrom, 10);
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
@@ -108,58 +189,86 @@ export default function Albums() {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<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>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('albums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{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} />
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 14, 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: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
<span style={{ fontSize: 14, 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: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +279,16 @@ export default function Albums() {
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -179,6 +297,7 @@ export default function Albums() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
@@ -8,12 +8,14 @@ import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveD
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -55,6 +57,7 @@ export default function ArtistDetail() {
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
@@ -68,32 +71,88 @@ 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 downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setInfo(null);
|
||||
setTopSongs([]);
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
]);
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
// Render the page immediately from local data
|
||||
setLoading(false);
|
||||
|
||||
getTopSongs(artistData.artist.name).then(songsData => {
|
||||
if (!cancelled) setTopSongs(songsData ?? []);
|
||||
}).catch(() => {});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
if (!cancelled) { console.error(err); setLoading(false); }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setArtistInfoLoading(true);
|
||||
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(artistInfo => {
|
||||
if (!cancelled) setInfo(artistInfo ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInfo(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setArtistInfoLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id, audiomuseNavidromeEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
}, [id, artist?.id, artist?.userRating]);
|
||||
|
||||
const handleArtistEntityRating = async (rating: number) => {
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
@@ -130,7 +189,7 @@ export default function ArtistDetail() {
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
@@ -153,7 +212,52 @@ export default function ArtistDetail() {
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [artist?.id, musicLibraryFilterVersion]);
|
||||
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
|
||||
|
||||
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||
useEffect(() => {
|
||||
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
||||
if (artistInfoLoading) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [
|
||||
artist?.id,
|
||||
artist?.name,
|
||||
musicLibraryFilterVersion,
|
||||
audiomuseNavidromeEnabled,
|
||||
artistInfoLoading,
|
||||
info?.similarArtist?.length,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audiomuseNavidromeEnabled) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) {
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(false);
|
||||
}
|
||||
}, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
@@ -287,6 +391,18 @@ export default function ArtistDetail() {
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
albumCount: sa.albumCount,
|
||||
}));
|
||||
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
|
||||
const showLastfmSimilar =
|
||||
lastfmIsConfigured() &&
|
||||
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
|
||||
(similarLoading || similarArtists.length > 0);
|
||||
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
@@ -351,6 +467,16 @@ export default function ArtistDetail() {
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
@@ -510,20 +636,19 @@ export default function ArtistDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Similar Artists (Last.fm) */}
|
||||
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
||||
{showSimilarSection && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{similarLoading ? (
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{similarArtists.map(a => (
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
@@ -538,7 +663,7 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
|
||||
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -81,25 +81,55 @@ export default function Artists() {
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleCount(prev => prev + 50);
|
||||
}, []);
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setVisibleCount(prev => prev + PAGE_SIZE);
|
||||
setTimeout(() => setLoadingMore(false), 100);
|
||||
}, [loadingMore, PAGE_SIZE]);
|
||||
|
||||
// Reset infinite scroll when filters change
|
||||
// Reset infinite scroll when filters or image setting change
|
||||
useEffect(() => {
|
||||
setVisibleCount(50);
|
||||
}, [filter, letterFilter, viewMode]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
||||
|
||||
// Filter pipeline
|
||||
let filtered = artists;
|
||||
@@ -120,6 +150,16 @@ export default function Artists() {
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
// Intersection Observer for infinite scroll (after hasMore declaration)
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore]);
|
||||
|
||||
// Group by first letter (for list view)
|
||||
const groups: Record<string, SubsonicArtist[]> = {};
|
||||
visible.forEach(a => {
|
||||
@@ -134,7 +174,11 @@ export default function Artists() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('artists.selectionCount', { count: selectedIds.size })
|
||||
: t('artists.title')}
|
||||
</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
@@ -146,30 +190,43 @@ export default function Artists() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
<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')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<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
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,17 +236,7 @@ export default function Artists() {
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
style={{
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: letterFilter === l ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: letterFilter === l ? 'var(--ctp-crust)' : 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
||||
>
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l}
|
||||
</button>
|
||||
@@ -203,13 +250,33 @@ export default function Artists() {
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
navigate(`/artist/${artist.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : {}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
@@ -231,13 +298,27 @@ export default function Artists() {
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
navigate(`/artist/${artist.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
background: 'var(--accent-dim)',
|
||||
color: 'var(--accent)'
|
||||
} : {}}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
@@ -255,10 +336,8 @@ export default function Artists() {
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<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 ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import {
|
||||
getStarred, getInternetRadioStations,
|
||||
getStarred, getInternetRadioStations, setRating,
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
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';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
|
||||
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: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
@@ -39,14 +43,32 @@ export default function Favorites() {
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||||
const selectedIds = useSelectionStore(s => s.selectedIds);
|
||||
const inSelectMode = selectedCount > 0;
|
||||
const lastSelectedIdxRef = useRef<number | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const playRadio = usePlayerStore(s => s.playRadio);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
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 userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
@@ -66,6 +88,44 @@ export default function Favorites() {
|
||||
const navigate = useNavigate();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
// Clear selection when song list changes
|
||||
useEffect(() => {
|
||||
useSelectionStore.getState().clearAll();
|
||||
lastSelectedIdxRef.current = null;
|
||||
}, [songs]);
|
||||
|
||||
// Clear selection on click outside tracklist
|
||||
useEffect(() => {
|
||||
if (!inSelectMode) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [inSelectMode]);
|
||||
|
||||
const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => {
|
||||
useSelectionStore.getState().setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (shift && lastSelectedIdxRef.current !== null) {
|
||||
const from = Math.min(lastSelectedIdxRef.current, idx);
|
||||
const to = Math.max(lastSelectedIdxRef.current, idx);
|
||||
// we need visibleSongs here — read from latest closure via ref trick
|
||||
// Instead, just toggle range based on idx into songs array
|
||||
for (let j = from; j <= to; j++) {
|
||||
const sid = songs[j]?.id;
|
||||
if (sid) next.add(sid);
|
||||
}
|
||||
} else {
|
||||
if (next.has(id)) { next.delete(id); }
|
||||
else { next.add(id); lastSelectedIdxRef.current = idx; }
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [songs]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAll = async () => {
|
||||
const [starredResult] = await Promise.allSettled([
|
||||
@@ -159,14 +219,68 @@ export default function Favorites() {
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
}}>
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{inSelectMode && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.bulkClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 === 'num') {
|
||||
const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0;
|
||||
return (
|
||||
<div key="num" className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (allSelected) {
|
||||
useSelectionStore.getState().clearAll();
|
||||
} else {
|
||||
useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
@@ -179,11 +293,20 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
const isCentered = key === 'duration';
|
||||
const isCentered = key === 'duration' || key === 'rating';
|
||||
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
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
@@ -213,14 +336,21 @@ export default function Favorites() {
|
||||
</div>
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
const isSelected = selectedIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
toggleSelect(song.id, i, false);
|
||||
} else if (inSelectMode) {
|
||||
toggleSelect(song.id, i, e.shiftKey);
|
||||
} else {
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
@@ -232,7 +362,13 @@ export default function Favorites() {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
|
||||
const { selectedIds: selIds } = useSelectionStore.getState();
|
||||
if (selIds.has(song.id) && selIds.size > 1) {
|
||||
const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
|
||||
} else {
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
@@ -244,6 +380,7 @@ export default function Favorites() {
|
||||
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)); }}>
|
||||
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, 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">{i + 1}</span>
|
||||
@@ -255,6 +392,13 @@ export default function Favorites() {
|
||||
<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 'rating': return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
case 'duration': return (
|
||||
<div key="duration" className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getMusicFolders,
|
||||
getMusicDirectory,
|
||||
getMusicIndexes,
|
||||
SubsonicDirectoryEntry,
|
||||
SubsonicArtist,
|
||||
SubsonicAlbum,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
type ColumnKind = 'roots' | 'indexes' | 'directory';
|
||||
type NavPos = { colIndex: number; rowIndex: number };
|
||||
let persistedPlayingPathIds: string[] = [];
|
||||
|
||||
type Column = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: SubsonicDirectoryEntry[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
kind: ColumnKind;
|
||||
};
|
||||
|
||||
/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */
|
||||
function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null {
|
||||
if (!item.isDir) return null;
|
||||
const albumId = item.albumId ?? (item.album ? item.id : undefined);
|
||||
if (!albumId) return null;
|
||||
return {
|
||||
id: albumId,
|
||||
name: item.album ?? item.title,
|
||||
artist: item.artist ?? '',
|
||||
artistId: item.artistId ?? '',
|
||||
coverArt: item.coverArt,
|
||||
year: item.year,
|
||||
genre: item.genre,
|
||||
starred: item.starred,
|
||||
userRating: item.userRating,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function entryToTrack(e: SubsonicDirectoryEntry): Track {
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
artist: e.artist ?? '',
|
||||
album: e.album ?? '',
|
||||
albumId: e.albumId ?? '',
|
||||
artistId: e.artistId,
|
||||
coverArt: e.coverArt,
|
||||
duration: e.duration ?? 0,
|
||||
track: e.track,
|
||||
year: e.year,
|
||||
bitRate: e.bitRate,
|
||||
suffix: e.suffix,
|
||||
genre: e.genre,
|
||||
starred: e.starred,
|
||||
userRating: e.userRating,
|
||||
};
|
||||
}
|
||||
|
||||
function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean {
|
||||
return (
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight' ||
|
||||
e.code === 'ArrowUp' ||
|
||||
e.code === 'ArrowDown' ||
|
||||
e.code === 'ArrowLeft' ||
|
||||
e.code === 'ArrowRight'
|
||||
);
|
||||
}
|
||||
|
||||
/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */
|
||||
function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean {
|
||||
const n = e.nativeEvent;
|
||||
if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true;
|
||||
if (typeof n.getModifierState === 'function') {
|
||||
return (
|
||||
n.getModifierState('Control') ||
|
||||
n.getModifierState('Alt') ||
|
||||
n.getModifierState('Shift') ||
|
||||
n.getModifierState('Meta') ||
|
||||
n.getModifierState('OS')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function FolderBrowser() {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column[]>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
||||
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
|
||||
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
|
||||
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
const pendingNavColRef = useRef<number | null>(null);
|
||||
const autoResolvedTrackRef = useRef<string | null>(null);
|
||||
const prevTrackIdRef = useRef<string | null>(null);
|
||||
const lastHotkeyRevealTsRef = useRef<number | null>(null);
|
||||
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
|
||||
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(null);
|
||||
const [columnsViewportWidth, setColumnsViewportWidth] = useState(0);
|
||||
const location = useLocation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
const placeholder: Column = {
|
||||
id: 'root',
|
||||
name: '',
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: 'roots',
|
||||
};
|
||||
setColumns([placeholder]);
|
||||
getMusicFolders()
|
||||
.then(folders => {
|
||||
const items: SubsonicDirectoryEntry[] = folders.map(f => ({
|
||||
id: f.id,
|
||||
title: f.name,
|
||||
isDir: true,
|
||||
}));
|
||||
setColumns([{ ...placeholder, items, loading: false }]);
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
}, [columns.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
setColumnsViewportWidth(el.clientWidth);
|
||||
const observer = new ResizeObserver(() => {
|
||||
setColumnsViewportWidth(el.clientWidth);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
columns.forEach((col, colIndex) => {
|
||||
const selectedId = col.selectedId;
|
||||
if (!selectedId) return;
|
||||
const row = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
`.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
if (keyboardPos) {
|
||||
const kbdRow = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
`.folder-col[data-folder-col-index="${keyboardPos.colIndex}"] .folder-col-row[data-row-index="${keyboardPos.rowIndex}"]`,
|
||||
);
|
||||
kbdRow?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
const fallbackColIndex = [...columns]
|
||||
.map((c, i) => (c.selectedId ? i : -1))
|
||||
.filter(i => i >= 0)
|
||||
.pop();
|
||||
const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1);
|
||||
const focusColIndex = Math.min(Math.max(0, columns.length - 1), baseColIndex + 1);
|
||||
const focusCol = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
`.folder-col[data-folder-col-index="${focusColIndex}"]`,
|
||||
);
|
||||
focusCol?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
}, [columns, keyboardPos]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const hasRows = columns.some(c => !c.loading && !c.error && c.items.length > 0);
|
||||
if (!hasRows) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus({ preventScroll: true });
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyboardNavActive) return;
|
||||
const onMouseMove = () => setKeyboardNavActive(false);
|
||||
window.addEventListener('mousemove', onMouseMove, { once: true });
|
||||
return () => window.removeEventListener('mousemove', onMouseMove);
|
||||
}, [keyboardNavActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnFilters(prev => {
|
||||
const next: Record<number, string> = {};
|
||||
let changed = false;
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const idx = Number(k);
|
||||
if (idx < columns.length) next[idx] = v;
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setFilterFocusCol(prev => (prev !== null && prev >= columns.length ? null : prev));
|
||||
}, [columns.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isContextMenuOpen) setContextAnchorPos(null);
|
||||
}, [isContextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.id) {
|
||||
setPlayingPathIds([]);
|
||||
return;
|
||||
}
|
||||
setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : []));
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !currentTrack?.id) return;
|
||||
const selectedChain = columns
|
||||
.map(c => c.selectedId)
|
||||
.filter((id): id is string => !!id);
|
||||
if (selectedChain.length === 0) return;
|
||||
|
||||
const lastSelectedId = selectedChain[selectedChain.length - 1];
|
||||
const leafColumn = [...columns].reverse().find(c => c.selectedId);
|
||||
const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId);
|
||||
if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return;
|
||||
|
||||
setPlayingPathIds(prev => {
|
||||
if (
|
||||
prev.length === selectedChain.length &&
|
||||
prev.every((id, idx) => id === selectedChain[idx])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return selectedChain;
|
||||
});
|
||||
}, [columns, currentTrack?.id, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
persistedPlayingPathIds = playingPathIds;
|
||||
}, [playingPathIds]);
|
||||
|
||||
const filteredItemsByCol = useMemo(() => {
|
||||
return columns.map((col, colIndex) => {
|
||||
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
|
||||
if (!query) return col.items;
|
||||
return col.items.filter(item => {
|
||||
const haystack = `${item.title} ${item.artist ?? ''} ${item.album ?? ''}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
});
|
||||
}, [columns, columnFilters]);
|
||||
|
||||
const preferredRowIndex = useCallback((colIndex: number): number => {
|
||||
const items = filteredItemsByCol[colIndex] ?? [];
|
||||
if (items.length === 0) return -1;
|
||||
const selectedId = columns[colIndex]?.selectedId;
|
||||
if (selectedId) {
|
||||
const selectedIdx = items.findIndex(it => it.id === selectedId);
|
||||
if (selectedIdx >= 0) return selectedIdx;
|
||||
}
|
||||
return 0;
|
||||
}, [filteredItemsByCol, columns]);
|
||||
|
||||
const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => {
|
||||
for (let c = 0; c < cols.length; c++) {
|
||||
const rowIndex = preferredRowIndex(c);
|
||||
if (rowIndex >= 0) return { colIndex: c, rowIndex };
|
||||
}
|
||||
return null;
|
||||
}, [preferredRowIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNavColRef.current !== null) {
|
||||
const targetColIndex = pendingNavColRef.current;
|
||||
const targetCol = columns[targetColIndex];
|
||||
const targetItems = filteredItemsByCol[targetColIndex] ?? [];
|
||||
if (targetCol && targetItems.length > 0 && !targetCol.loading && !targetCol.error) {
|
||||
const rowIndex = preferredRowIndex(targetColIndex);
|
||||
const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1);
|
||||
const targetItem = targetItems[safeRowIndex];
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
|
||||
);
|
||||
setKeyboardPos({
|
||||
colIndex: targetColIndex,
|
||||
rowIndex: safeRowIndex,
|
||||
});
|
||||
pendingNavColRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setKeyboardPos(prev => {
|
||||
if (!prev) return fallbackNavPos(columns);
|
||||
if (prev.colIndex >= columns.length) return fallbackNavPos(columns);
|
||||
const col = columns[prev.colIndex];
|
||||
const visibleItems = filteredItemsByCol[prev.colIndex] ?? [];
|
||||
if (col.loading || col.error || visibleItems.length === 0) return fallbackNavPos(columns);
|
||||
if (prev.rowIndex >= visibleItems.length) {
|
||||
return { colIndex: prev.colIndex, rowIndex: visibleItems.length - 1 };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [columns, fallbackNavPos, preferredRowIndex, filteredItemsByCol]);
|
||||
|
||||
const clearFiltersRightOf = useCallback((colIndex: number) => {
|
||||
setColumnFilters(prev => {
|
||||
const next: Record<number, string> = {};
|
||||
let changed = false;
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const idx = Number(k);
|
||||
if (idx <= colIndex) next[idx] = v;
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setFilterFocusCol(prev => (prev !== null && prev > colIndex ? null : prev));
|
||||
}, []);
|
||||
|
||||
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
clearFiltersRightOf(colIndex);
|
||||
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
|
||||
setColumns(prev => [
|
||||
...prev.slice(0, colIndex + 1).map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
),
|
||||
{
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: nextKind,
|
||||
},
|
||||
]);
|
||||
|
||||
const fetchItems =
|
||||
colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child);
|
||||
|
||||
fetchItems
|
||||
.then(items => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], items, loading: false };
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], loading: false, error: true };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [clearFiltersRightOf]);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: item.id } : c)),
|
||||
);
|
||||
const path = [
|
||||
...columns.slice(0, colIndex).map(c => c.selectedId).filter((id): id is string => !!id),
|
||||
item.id,
|
||||
];
|
||||
setPlayingPathIds(path);
|
||||
const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? [];
|
||||
const queue = visibleItems.filter(it => !it.isDir).map(entryToTrack);
|
||||
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
||||
},
|
||||
[columns, filteredItemsByCol, playTrack],
|
||||
);
|
||||
|
||||
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
|
||||
setColumns(prev => {
|
||||
const prevSelectedId = prev[colIndex]?.selectedId ?? null;
|
||||
if (prevSelectedId !== itemId) {
|
||||
clearFiltersRightOf(colIndex);
|
||||
}
|
||||
return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c));
|
||||
});
|
||||
}, [clearFiltersRightOf]);
|
||||
|
||||
const clearSelectedInColumn = useCallback((colIndex: number) => {
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: null } : c)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
if (item.isDir) {
|
||||
handleDirClick(colIndex, item);
|
||||
pendingNavColRef.current = colIndex + 1;
|
||||
return;
|
||||
}
|
||||
handleFileClick(colIndex, item);
|
||||
}, [handleDirClick, handleFileClick]);
|
||||
|
||||
const openContextMenuForEntry = useCallback(
|
||||
(col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => {
|
||||
if (item.isDir) {
|
||||
if (col.kind === 'indexes') {
|
||||
const artist: SubsonicArtist = { id: item.id, name: item.title, coverArt: item.coverArt };
|
||||
openContextMenu(x, y, artist, 'artist');
|
||||
return;
|
||||
}
|
||||
const album = entryToAlbumIfPresent(item);
|
||||
if (album) {
|
||||
openContextMenu(x, y, album, 'album');
|
||||
return;
|
||||
}
|
||||
if (item.artistId) {
|
||||
const artist: SubsonicArtist = {
|
||||
id: item.artistId,
|
||||
name: item.artist ?? item.title,
|
||||
coverArt: item.coverArt,
|
||||
};
|
||||
openContextMenu(x, y, artist, 'artist');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
openContextMenu(x, y, entryToTrack(item), 'song');
|
||||
},
|
||||
[openContextMenu],
|
||||
);
|
||||
|
||||
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isContextMenuOpen) return;
|
||||
const target = e.target as HTMLElement;
|
||||
const inFilterInput =
|
||||
target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true';
|
||||
if (inFilterInput) return;
|
||||
const key = e.key;
|
||||
if (e.ctrlKey && e.code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
const current = keyboardPos ?? fallbackNavPos(columns);
|
||||
if (!current) return;
|
||||
const colIndex = current.colIndex;
|
||||
setFilterFocusCol(colIndex);
|
||||
requestAnimationFrame(() => {
|
||||
const input = filterInputRefs.current[colIndex];
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isFolderBrowserArrowKey(e) && folderBrowserHasKeyModifiers(e)) return;
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return;
|
||||
setKeyboardNavActive(true);
|
||||
const current = keyboardPos ?? fallbackNavPos(columns);
|
||||
if (!current) return;
|
||||
|
||||
const col = columns[current.colIndex];
|
||||
const visibleItems = filteredItemsByCol[current.colIndex] ?? [];
|
||||
const item = visibleItems[current.rowIndex];
|
||||
if (!col || !item) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (key === 'Enter' && e.ctrlKey) {
|
||||
setContextAnchorPos(current);
|
||||
const rowEl = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
`.folder-col-row[data-col-index="${current.colIndex}"][data-row-index="${current.rowIndex}"]`,
|
||||
);
|
||||
const rect = rowEl?.getBoundingClientRect();
|
||||
const x = rect ? rect.left + 24 : 24;
|
||||
const y = rect ? rect.top + rect.height / 2 : 24;
|
||||
openContextMenuForEntry(col, item, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
if (current.rowIndex > 0) {
|
||||
const nextRowIndex = current.rowIndex - 1;
|
||||
const nextItem = visibleItems[nextRowIndex];
|
||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||
} else if (
|
||||
current.rowIndex === 0 &&
|
||||
(filterFocusCol === current.colIndex || !!columnFilters[current.colIndex])
|
||||
) {
|
||||
setFilterFocusCol(current.colIndex);
|
||||
requestAnimationFrame(() => {
|
||||
const input = filterInputRefs.current[current.colIndex];
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowDown') {
|
||||
if (current.rowIndex < visibleItems.length - 1) {
|
||||
const nextRowIndex = current.rowIndex + 1;
|
||||
const nextItem = visibleItems[nextRowIndex];
|
||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowLeft') {
|
||||
if (current.colIndex > 0) {
|
||||
clearSelectedInColumn(current.colIndex);
|
||||
const nextColIndex = current.colIndex - 1;
|
||||
clearFiltersRightOf(nextColIndex);
|
||||
const rowIndex = preferredRowIndex(nextColIndex);
|
||||
if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'ArrowRight') {
|
||||
const nextColIndex = current.colIndex + 1;
|
||||
if (nextColIndex < columns.length) {
|
||||
const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? [];
|
||||
const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1);
|
||||
if (rowIndex >= 0) {
|
||||
const nextItem = nextVisibleItems[rowIndex];
|
||||
setSelectedInColumn(nextColIndex, nextItem.id);
|
||||
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (item.isDir) handleActivate(current.colIndex, item);
|
||||
return;
|
||||
}
|
||||
if (key === 'Enter') {
|
||||
if (e.shiftKey && !item.isDir) {
|
||||
const toAppend = (filteredItemsByCol[current.colIndex] ?? [])
|
||||
.filter(it => !it.isDir)
|
||||
.map(entryToTrack);
|
||||
if (toAppend.length > 0) enqueue(toAppend);
|
||||
return;
|
||||
}
|
||||
handleActivate(current.colIndex, item);
|
||||
}
|
||||
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]);
|
||||
|
||||
const onRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextAnchorPos({ colIndex, rowIndex });
|
||||
openContextMenuForEntry(col, item, e.clientX, e.clientY);
|
||||
},
|
||||
[openContextMenuForEntry],
|
||||
);
|
||||
|
||||
const resolveColumnsForTrack = useCallback(async (
|
||||
track: Track,
|
||||
roots: SubsonicDirectoryEntry[],
|
||||
): Promise<Column[] | null> => {
|
||||
for (const root of roots) {
|
||||
let indexes: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
indexes = await getMusicIndexes(root.id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const artistEntry =
|
||||
indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ??
|
||||
indexes.find(it => it.isDir && it.title === track.artist);
|
||||
if (!artistEntry) continue;
|
||||
|
||||
let artistChildren: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
artistChildren = (await getMusicDirectory(artistEntry.id)).child;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const albumEntry = artistChildren.find(it =>
|
||||
it.isDir &&
|
||||
(
|
||||
(!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) ||
|
||||
(!!track.album && (it.album === track.album || it.title === track.album))
|
||||
),
|
||||
);
|
||||
if (!albumEntry) continue;
|
||||
|
||||
let albumChildren: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
albumChildren = (await getMusicDirectory(albumEntry.id)).child;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id);
|
||||
if (!songEntry) continue;
|
||||
|
||||
return [
|
||||
{ id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' },
|
||||
{ id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' },
|
||||
{ id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' },
|
||||
{ id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const isSelectedPathForCurrentTrack =
|
||||
isPlaying && currentTrack && playingPathIds[playingPathIds.length - 1] === currentTrack.id;
|
||||
|
||||
const activeColIndex = useMemo(() => {
|
||||
if (keyboardPos) return keyboardPos.colIndex;
|
||||
const fromSelection = [...columns]
|
||||
.map((c, i) => (c.selectedId ? i : -1))
|
||||
.filter(i => i >= 0);
|
||||
if (fromSelection.length > 0) return fromSelection[fromSelection.length - 1];
|
||||
return Math.max(0, columns.length - 1);
|
||||
}, [columns, keyboardPos]);
|
||||
|
||||
const visibleAnchorColIndex = useMemo(
|
||||
() => Math.min(Math.max(0, columns.length - 1), activeColIndex + 1),
|
||||
[activeColIndex, columns.length],
|
||||
);
|
||||
|
||||
const compactColumnsEnabled = useMemo(() => {
|
||||
if (columns.length < 4 || columnsViewportWidth <= 0) return false;
|
||||
const expandedColumnWidth = 220;
|
||||
return columns.length * expandedColumnWidth > columnsViewportWidth;
|
||||
}, [columns.length, columnsViewportWidth]);
|
||||
|
||||
const isColumnCompact = useCallback((col: Column, colIndex: number) => {
|
||||
if (!compactColumnsEnabled) return false;
|
||||
if (col.loading || col.error || col.items.length === 0) return false;
|
||||
return Math.abs(colIndex - visibleAnchorColIndex) > 1;
|
||||
}, [compactColumnsEnabled, visibleAnchorColIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.id) {
|
||||
autoResolvedTrackRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const hotkeyRevealTs = (location.state as { folderBrowserRevealTs?: number } | null)?.folderBrowserRevealTs ?? null;
|
||||
const hotkeyRevealRequested = hotkeyRevealTs !== null && hotkeyRevealTs !== lastHotkeyRevealTsRef.current;
|
||||
const forceReveal = hotkeyRevealRequested;
|
||||
if (autoResolvedTrackRef.current === currentTrack.id && !forceReveal) return;
|
||||
|
||||
const rootCol = columns[0];
|
||||
if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return;
|
||||
|
||||
const selectedLeafId =
|
||||
[...columns].reverse().find(c => c.selectedId)?.selectedId ?? null;
|
||||
const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current;
|
||||
if (selectedLeafId === currentTrack.id) {
|
||||
autoResolvedTrackRef.current = currentTrack.id;
|
||||
if (hotkeyRevealRequested) {
|
||||
lastHotkeyRevealTsRef.current = hotkeyRevealTs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!forceReveal && !wasOnPreviousTrackPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => {
|
||||
if (cancelled || !resolved) return;
|
||||
setColumns(resolved);
|
||||
const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id);
|
||||
setPlayingPathIds(path);
|
||||
const leafColIndex = resolved.length - 1;
|
||||
const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id);
|
||||
if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex });
|
||||
autoResolvedTrackRef.current = currentTrack.id;
|
||||
if (hotkeyRevealRequested) {
|
||||
lastHotkeyRevealTsRef.current = hotkeyRevealTs;
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [columns, currentTrack, resolveColumnsForTrack, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
prevTrackIdRef.current = currentTrack?.id ?? null;
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
return (
|
||||
<div className="folder-browser">
|
||||
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
|
||||
<div
|
||||
className={`folder-browser-columns${keyboardNavActive ? ' keyboard-nav-active' : ''}${compactColumnsEnabled ? ' folder-browser-columns--compact' : ''}`}
|
||||
ref={wrapperRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onColumnsKeyDown}
|
||||
>
|
||||
{columns.map((col, colIndex) => (
|
||||
<div
|
||||
key={`${col.id}-${colIndex}`}
|
||||
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
|
||||
data-folder-col-index={colIndex}
|
||||
>
|
||||
{(filterFocusCol === colIndex || !!columnFilters[colIndex]) && (
|
||||
<div className="folder-col-filter">
|
||||
<input
|
||||
ref={el => { filterInputRefs.current[colIndex] = el; }}
|
||||
data-folder-filter-input="true"
|
||||
className="folder-col-filter-input"
|
||||
value={columnFilters[colIndex] ?? ''}
|
||||
placeholder={t('playlists.searchPlaceholder')}
|
||||
onFocus={() => setFilterFocusCol(colIndex)}
|
||||
onBlur={() => {
|
||||
if (!(columnFilters[colIndex] ?? '').trim()) {
|
||||
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
|
||||
setFilterFocusCol(null);
|
||||
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rowIndex = preferredRowIndex(colIndex);
|
||||
if (rowIndex >= 0) {
|
||||
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
|
||||
if (nextItem) {
|
||||
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
|
||||
else setSelectedInColumn(colIndex, nextItem.id);
|
||||
}
|
||||
setKeyboardPos({ colIndex, rowIndex });
|
||||
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
|
||||
setKeyboardPos(prev => {
|
||||
if (!prev || prev.colIndex !== colIndex) return prev;
|
||||
return { colIndex, rowIndex: 0 };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{col.loading ? (
|
||||
<div className="folder-col-status">
|
||||
<div className="spinner" style={{ width: 20, height: 20 }} />
|
||||
</div>
|
||||
) : col.error ? (
|
||||
<div className="folder-col-status folder-col-error">
|
||||
{t('folderBrowser.error')}
|
||||
</div>
|
||||
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
|
||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||
) : (
|
||||
(filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => {
|
||||
const isSelected = col.selectedId === item.id;
|
||||
const isContextRow =
|
||||
contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex;
|
||||
const isKeyboardRow =
|
||||
keyboardPos?.colIndex === colIndex && keyboardPos?.rowIndex === rowIndex;
|
||||
const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id;
|
||||
const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.title}
|
||||
data-col-index={colIndex}
|
||||
data-row-index={rowIndex}
|
||||
data-item-id={item.id}
|
||||
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
|
||||
onClick={() => {
|
||||
setKeyboardPos({ colIndex, rowIndex });
|
||||
if (item.isDir) handleDirClick(colIndex, item);
|
||||
else handleFileClick(colIndex, item);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
setKeyboardPos({ colIndex, rowIndex });
|
||||
onRowContextMenu(e, colIndex, rowIndex, col, item);
|
||||
}}
|
||||
>
|
||||
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
|
||||
{item.isDir ? (
|
||||
isSelected ? (
|
||||
<FolderOpen size={14} />
|
||||
) : (
|
||||
<Folder size={14} />
|
||||
)
|
||||
) : (
|
||||
<Music size={14} strokeWidth={isNowPlayingTrack ? 2.5 : 2} className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className="folder-col-name">{item.title}</span>
|
||||
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,10 @@ export default function Help() {
|
||||
items: [
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q37'), a: t('help.a37') },
|
||||
{ q: t('help.q38'), a: t('help.a38') },
|
||||
{ q: t('help.q39'), a: t('help.a39') },
|
||||
{ q: t('help.q40'), a: t('help.a40') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
],
|
||||
@@ -63,10 +67,16 @@ export default function Help() {
|
||||
title: t('help.s4'),
|
||||
items: [
|
||||
{ q: t('help.q12'), a: t('help.a12') },
|
||||
{ q: t('help.q41'), a: t('help.a41') },
|
||||
{ q: t('help.q42'), a: t('help.a42') },
|
||||
{ q: t('help.q43'), a: t('help.a43') },
|
||||
{ q: t('help.q13'), a: t('help.a13') },
|
||||
{ q: t('help.q15'), a: t('help.a15') },
|
||||
{ q: t('help.q31'), a: t('help.a31') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
{ q: t('help.q44'), a: t('help.a44') },
|
||||
{ q: t('help.q45'), a: t('help.a45') },
|
||||
{ q: t('help.q46'), a: t('help.a46') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -92,6 +102,7 @@ export default function Help() {
|
||||
items: [
|
||||
{ q: t('help.q34'), a: t('help.a34') },
|
||||
{ q: t('help.q35'), a: t('help.a35') },
|
||||
{ q: t('help.q47'), a: t('help.a47') },
|
||||
{ q: t('help.q36'), a: t('help.a36') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
|
||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
const HOME_HERO_COUNT = 8;
|
||||
const HOME_DISCOVER_SLICE = 20;
|
||||
|
||||
export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -23,30 +32,50 @@ export default function Home() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]).then(([s, n, r, f, rp, artists]) => {
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, 8));
|
||||
setRandom(r.slice(8));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
// Pick 16 random artists via Fisher-Yates shuffle
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||
const [s, n, rRaw, f, rp, artists] = await Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', randomSize).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
|
||||
setStarred(s);
|
||||
setRecent(n);
|
||||
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||
setMostPlayed(f);
|
||||
setRecentlyPlayed(rp);
|
||||
const shuffled = [...artists];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
setRandomArtists(shuffled.slice(0, 16));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion, homeSections]);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
musicLibraryFilterVersion,
|
||||
homeSections,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
@@ -55,7 +84,10 @@ export default function Home() {
|
||||
) => {
|
||||
try {
|
||||
const more = await getAlbumList(type, 12, currentList.length);
|
||||
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const batch =
|
||||
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
|
||||
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
|
||||
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more', e);
|
||||
@@ -79,7 +111,7 @@ export default function Home() {
|
||||
{isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
titleLink="/albums"
|
||||
titleLink="/new-releases"
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
@@ -88,7 +120,7 @@ export default function Home() {
|
||||
{isVisible('discover') && (
|
||||
<AlbumRow
|
||||
title={t('home.discover')}
|
||||
titleLink="/random-albums"
|
||||
titleLink="/random/albums"
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
moreText={t('home.discoverMore')}
|
||||
@@ -134,6 +166,7 @@ export default function Home() {
|
||||
{isVisible('mostPlayed') && (
|
||||
<AlbumRow
|
||||
title={t('home.mostPlayed')}
|
||||
titleLink="/most-played"
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
|
||||
@@ -19,7 +19,10 @@ import { showToast } from '../utils/toast';
|
||||
|
||||
export default function InternetRadio() {
|
||||
const { t } = useTranslation();
|
||||
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
|
||||
const playRadio = usePlayerStore(s => s.playRadio);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
const [stations, setStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
@@ -36,16 +36,16 @@ export default function Login() {
|
||||
|
||||
// Test connection directly with entered credentials — don't touch the store yet.
|
||||
// This avoids any race condition with Zustand's async store rehydration.
|
||||
let ok = false;
|
||||
let ping: Awaited<ReturnType<typeof pingWithCredentials>> = { ok: false };
|
||||
try {
|
||||
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
|
||||
} catch {
|
||||
ok = false;
|
||||
ping = { ok: false };
|
||||
}
|
||||
|
||||
setConnecting(false);
|
||||
|
||||
if (ok) {
|
||||
if (ping.ok) {
|
||||
// Connection succeeded — now persist to store
|
||||
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
|
||||
let serverId: string;
|
||||
@@ -63,6 +63,19 @@ export default function Login() {
|
||||
password: profile.password,
|
||||
});
|
||||
}
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
};
|
||||
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
|
||||
scheduleInstantMixProbeForServer(
|
||||
serverId,
|
||||
profile.url.trim(),
|
||||
profile.username.trim(),
|
||||
profile.password,
|
||||
identity,
|
||||
);
|
||||
setActiveServer(serverId);
|
||||
setLoggedIn(true);
|
||||
setStatus('ok');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp } from 'lucide-react';
|
||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react';
|
||||
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -16,11 +16,24 @@ interface ArtistEntry {
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
function deriveTopArtists(albums: SubsonicAlbum[]): ArtistEntry[] {
|
||||
const COMPILATION_NAMES = new Set([
|
||||
'various artists', 'various', 'va', 'v.a.', 'v.a',
|
||||
'diverse artister', 'diversos artistas', 'artistes variés',
|
||||
'vários artistas', 'verschiedene künstler', 'verscheidene artiesten',
|
||||
'compilations', 'soundtrack', 'original soundtrack', 'ost',
|
||||
'original motion picture soundtrack', 'original score',
|
||||
]);
|
||||
|
||||
function isCompilation(name: string): boolean {
|
||||
return COMPILATION_NAMES.has(name.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function deriveTopArtists(albums: SubsonicAlbum[], filterCompilations: boolean): ArtistEntry[] {
|
||||
const map = new Map<string, ArtistEntry>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (plays === 0) continue;
|
||||
if (filterCompilations && isCompilation(a.artist ?? '')) continue;
|
||||
const entry = map.get(a.artistId);
|
||||
if (entry) {
|
||||
entry.totalPlays += plays;
|
||||
@@ -46,8 +59,9 @@ export default function MostPlayed() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [sortAsc, setSortAsc] = useState(false); // false = most plays first
|
||||
const [filterCompilations, setFilterCompilations] = useState(false);
|
||||
|
||||
const topArtists = deriveTopArtists(albums).slice(0, 10);
|
||||
const topArtists = deriveTopArtists(albums, filterCompilations).slice(0, 10);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -96,9 +110,23 @@ export default function MostPlayed() {
|
||||
</div>
|
||||
|
||||
{/* ── Top Artists ── */}
|
||||
{!loading && topArtists.length > 0 && (
|
||||
{!loading && (
|
||||
<section className="mp-section">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||
<div className="mp-section-header">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||
<button
|
||||
className={`btn btn-ghost mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
|
||||
onClick={() => setFilterCompilations(v => !v)}
|
||||
data-tooltip={t('mostPlayed.filterCompilations')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<UsersRound size={14} />
|
||||
{t('mostPlayed.filterCompilationsShort')}
|
||||
</button>
|
||||
</div>
|
||||
{topArtists.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>{t('mostPlayed.noArtists')}</div>
|
||||
)}
|
||||
<div className="mp-artist-grid">
|
||||
{topArtists.map((artist, i) => (
|
||||
<button
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
@@ -17,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -25,6 +42,56 @@ export default function NewReleases() {
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||
}, []);
|
||||
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
clearSelection();
|
||||
for (const album of selectedAlbums) {
|
||||
const downloadId = crypto.randomUUID();
|
||||
const filename = `${sanitizeFilename(album.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(album.id);
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed for', album.name, e);
|
||||
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOffline = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const load = useCallback(async (offset: number, append = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -71,8 +138,37 @@ export default function NewReleases() {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<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('sidebar.newReleases')}</h1>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('sidebar.newReleases')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
@@ -82,7 +178,16 @@ export default function NewReleases() {
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!filtered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import {
|
||||
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
SubsonicSong, SubsonicArtistInfo,
|
||||
} from '../api/subsonic';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -211,14 +213,22 @@ export default function NowPlaying() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
|
||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||
|
||||
// Radio metadata (ICY or AzuraCast)
|
||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||
|
||||
// Extra song metadata
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -230,8 +240,10 @@ export default function NowPlaying() {
|
||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
|
||||
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId]);
|
||||
getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(setArtistInfo)
|
||||
.catch(() => setArtistInfo(null));
|
||||
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
|
||||
|
||||
// Album tracks
|
||||
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
|
||||
@@ -258,15 +270,136 @@ export default function NowPlaying() {
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
|
||||
// Radio cover
|
||||
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : '';
|
||||
const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey);
|
||||
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
// ── Radio now-playing section ────────────────────────────────────────────────
|
||||
const radioNowPlaying = currentRadio && !currentTrack && (
|
||||
<div className="np-radio-section">
|
||||
|
||||
{/* Station hero */}
|
||||
<div className="np-hero-card">
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title" style={{ color: 'var(--accent)' }}>
|
||||
{currentRadio.name}
|
||||
</div>
|
||||
{radioMeta.currentTitle && (
|
||||
<div className="np-artist-album">
|
||||
{radioMeta.currentArtist && (
|
||||
<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>
|
||||
)}
|
||||
<span>{radioMeta.currentTitle}</span>
|
||||
{radioMeta.currentAlbum && (
|
||||
<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="np-tech-row">
|
||||
<span className="np-badge np-badge-live">
|
||||
<Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}
|
||||
</span>
|
||||
{radioMeta.source === 'azuracast' && (
|
||||
<span className="np-badge np-badge-azuracast">AzuraCast</span>
|
||||
)}
|
||||
{radioMeta.listeners != null && (
|
||||
<span className="np-badge">
|
||||
<Users size={10} style={{ marginRight: 3 }} />
|
||||
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AzuraCast progress bar */}
|
||||
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
|
||||
<div className="np-radio-progress-wrap">
|
||||
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
|
||||
<div className="np-radio-progress-bar">
|
||||
<div
|
||||
className="np-radio-progress-fill"
|
||||
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedRadioCover
|
||||
? <img src={resolvedRadioCover} alt={currentRadio.name} className="np-cover" />
|
||||
: radioMeta.currentArt
|
||||
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Placeholder to keep 3-column layout */}
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* Upcoming track */}
|
||||
{radioMeta.nextSong && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">
|
||||
<SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="np-radio-next-track">
|
||||
{radioMeta.nextSong.art && (
|
||||
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<div className="np-radio-track-info">
|
||||
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
|
||||
{radioMeta.nextSong.artist && (
|
||||
<span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Song history */}
|
||||
{radioMeta.history.length > 0 && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">
|
||||
<Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{radioMeta.history.map((item, idx) => (
|
||||
<div key={idx} className="np-album-track">
|
||||
{item.song.art && (
|
||||
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<span className="np-album-track-title truncate">
|
||||
{item.song.artist ? `${item.song.artist} — ${item.song.title}` : item.song.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
|
||||
<div className="np-main">
|
||||
{currentTrack ? (
|
||||
{radioNowPlaying ? (
|
||||
radioNowPlaying
|
||||
) : currentTrack ? (
|
||||
<>
|
||||
{/* ── Hero Card ── */}
|
||||
<div className="np-hero-card">
|
||||
@@ -292,7 +425,7 @@ export default function NowPlaying() {
|
||||
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
|
||||
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
|
||||
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
|
||||
{renderStars(currentTrack.userRating)}
|
||||
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
|
||||
<button onClick={toggleStar} className="np-star-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
|
||||
@@ -12,15 +12,19 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
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';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import StarRating from '../components/StarRating';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -38,9 +42,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
function codecLabel(song: SubsonicSong): string {
|
||||
@@ -50,31 +52,15 @@ function codecLabel(song: SubsonicSong): string {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
|
||||
const [hover, setHover] = React.useState(0);
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => onChange(n)}
|
||||
>★</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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: 'album', i18nKey: 'trackAlbum', 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: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
@@ -85,7 +71,7 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
@@ -94,12 +80,29 @@ export default function PlaylistDetail() {
|
||||
isPlaying: s.isPlaying,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
}))
|
||||
);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const { startDrag, isDragging } = useDragDrop();
|
||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||
const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist);
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const isDownloading = useOfflineJobStore(s =>
|
||||
!!id && s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading'))
|
||||
);
|
||||
const isCached = useOfflineStore(s => {
|
||||
if (!id) return false;
|
||||
const meta = s.albums[`${activeServerId}:${id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${activeServerId}:${tid}`]);
|
||||
});
|
||||
const offlineProgressDone = useOfflineJobStore(s => {
|
||||
if (!id) return 0;
|
||||
return s.jobs.filter(j => j.albumId === id && (j.status === 'done' || j.status === 'error')).length;
|
||||
});
|
||||
const offlineProgressTotal = useOfflineJobStore(s => (!id ? 0 : s.jobs.filter(j => j.albumId === id).length));
|
||||
const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : null;
|
||||
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
@@ -111,11 +114,17 @@ export default function PlaylistDetail() {
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [editingMeta, setEditingMeta] = useState(false);
|
||||
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [sortClickCount, setSortClickCount] = useState(0);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const zipDownloads = useZipDownloadStore(s => s.downloads);
|
||||
const [zipDownloadId, setZipDownloadId] = useState<string | null>(null);
|
||||
const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined;
|
||||
|
||||
// ── Bulk select ───────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -203,6 +212,8 @@ export default function PlaylistDetail() {
|
||||
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [selectedSearchIds, setSelectedSearchIds] = useState<Set<string>>(new Set());
|
||||
const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
|
||||
|
||||
// Suggestions
|
||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||
@@ -223,6 +234,8 @@ export default function PlaylistDetail() {
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
@@ -242,7 +255,7 @@ export default function PlaylistDetail() {
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
}, [id, lastModified]);
|
||||
|
||||
// ── Suggestions ───────────────────────────────────────────────
|
||||
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
|
||||
@@ -314,38 +327,21 @@ export default function PlaylistDetail() {
|
||||
if (!playlist || !id) return;
|
||||
const folder = downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
setDownloadProgress(0);
|
||||
|
||||
const filename = `${sanitizeFilename(playlist.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(id);
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
start(downloadId, filename);
|
||||
setZipDownloadId(downloadId);
|
||||
try {
|
||||
const url = buildDownloadUrl(id);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
if (total && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setDownloadProgress(Math.round((received / total) * 100));
|
||||
}
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer() as ArrayBuffer;
|
||||
chunks.push(new Uint8Array(buffer));
|
||||
setDownloadProgress(100);
|
||||
}
|
||||
const blob = new Blob(chunks);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
setDownloadProgress(null);
|
||||
} finally {
|
||||
setTimeout(() => setDownloadProgress(null), 60000);
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -370,6 +366,7 @@ export default function PlaylistDetail() {
|
||||
// ── Rating / Star ─────────────────────────────────────────────
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -454,10 +451,21 @@ export default function PlaylistDetail() {
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
|
||||
me.clientX, me.clientY
|
||||
);
|
||||
if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) {
|
||||
const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack);
|
||||
startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
|
||||
} else if (!isFiltered) {
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
|
||||
me.clientX, me.clientY
|
||||
);
|
||||
} else {
|
||||
// filtered view: single-song drag to queue
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' },
|
||||
me.clientX, me.clientY
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
@@ -472,6 +480,40 @@ export default function PlaylistDetail() {
|
||||
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
|
||||
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
|
||||
|
||||
const displayedSongs = useMemo(() => {
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q && sortKey === 'natural') return songs;
|
||||
let result = [...songs];
|
||||
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
||||
if (sortKey !== 'natural') {
|
||||
result.sort((a, b) => {
|
||||
let av: string | number;
|
||||
let bv: string | number;
|
||||
const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0;
|
||||
const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0;
|
||||
switch (sortKey) {
|
||||
case 'title': av = a.title; bv = b.title; break;
|
||||
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
||||
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
||||
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
|
||||
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
|
||||
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
||||
default: av = a.title; bv = b.title;
|
||||
}
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
return sortDir === 'asc' ? av - bv : bv - av;
|
||||
}
|
||||
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]);
|
||||
const displayedTracks = useMemo(
|
||||
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
|
||||
[displayedSongs, songs, tracks],
|
||||
);
|
||||
const isFiltered = displayedSongs !== songs;
|
||||
|
||||
// ── Drag-over visual feedback ─────────────────────────────────
|
||||
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
@@ -597,37 +639,39 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); }}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
{/* search close resets selection */}
|
||||
{songs.length > 0 && id && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
disabled={isDownloading}
|
||||
onClick={() => {
|
||||
if (isCached) {
|
||||
deleteAlbum(id, activeServerId);
|
||||
} else if (playlist) {
|
||||
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isDownloading
|
||||
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
||||
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{songs.length > 0 && (
|
||||
downloadProgress !== null ? (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
|
||||
@@ -662,16 +706,98 @@ export default function PlaylistDetail() {
|
||||
{!searching && searchQuery && searchResults.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||
)}
|
||||
{searchResults.map(song => (
|
||||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
{selectedSearchIds.size > 0 && (
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.75rem', background: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderRadius: 'var(--radius-sm)', margin: '0.25rem 0' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, flex: 1 }}>
|
||||
{t('common.bulkSelected', { count: selectedSearchIds.size })}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setSelectedSearchIds(new Set())}
|
||||
>
|
||||
{t('common.clearSelection')}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setSearchPlPickerOpen(v => !v)}
|
||||
>
|
||||
<ListPlus size={13} /> {t('contextMenu.addToPlaylist')}
|
||||
</button>
|
||||
{searchPlPickerOpen && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...selectedSearchIds]}
|
||||
dropDown
|
||||
onDone={() => { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => {
|
||||
searchResults
|
||||
.filter(s => selectedSearchIds.has(s.id))
|
||||
.forEach(s => addSong(s));
|
||||
setSelectedSearchIds(new Set());
|
||||
}}
|
||||
>
|
||||
<Check size={13} /> {t('playlists.addSelected')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{searchResults.map(song => {
|
||||
const isSelected = selectedSearchIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`playlist-search-row${isSelected ? ' playlist-search-row--selected' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => addSong(song)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="playlist-search-checkbox"
|
||||
checked={isSelected}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={() => setSelectedSearchIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(song.id) ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
||||
<div className="playlist-search-info">
|
||||
<span className="playlist-search-title">{song.title}</span>
|
||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||
</div>
|
||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filter / sort toolbar ── */}
|
||||
{songs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||
placeholder={t('albumDetail.filterSongs')}
|
||||
value={filterText}
|
||||
onChange={e => setFilterText(e.target.value)}
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
|
||||
onClick={() => setFilterText('')}
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -726,6 +852,38 @@ export default function PlaylistDetail() {
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = PL_CENTERED.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
|
||||
const canSort = sortableCols.has(key);
|
||||
const isSortActive = canSort && sortKey === key;
|
||||
|
||||
const handleSortClick = () => {
|
||||
if (!canSort) return;
|
||||
if (sortKey === key) {
|
||||
const nextCount = sortClickCount + 1;
|
||||
if (nextCount >= 3) {
|
||||
setSortKey('natural');
|
||||
setSortDir('asc');
|
||||
setSortClickCount(0);
|
||||
} else {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
setSortClickCount(nextCount);
|
||||
}
|
||||
} else {
|
||||
setSortKey(key as typeof sortKey);
|
||||
setSortDir('asc');
|
||||
setSortClickCount(1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSortIndicator = () => {
|
||||
if (!isSortActive) return null;
|
||||
return (
|
||||
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||
{sortDir === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (key === 'num') return (
|
||||
<div key="num" className="track-num">
|
||||
<span
|
||||
@@ -739,9 +897,23 @@ export default function PlaylistDetail() {
|
||||
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
|
||||
key="title"
|
||||
onClick={handleSortClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
className={isSortActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<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>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && renderSortIndicator()}
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
@@ -749,9 +921,32 @@ export default function PlaylistDetail() {
|
||||
}
|
||||
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
|
||||
key={key}
|
||||
onClick={handleSortClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canSort ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
className={isSortActive ? 'tracklist-header-cell-active' : ''}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCentered ? 'center' : 'flex-start',
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
|
||||
{canSort && renderSortIndicator()}
|
||||
</div>
|
||||
{!isLastCol && key !== 'delete' && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
@@ -800,40 +995,44 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{songs.map((song, idx) => (
|
||||
<React.Fragment key={song.id + idx}>
|
||||
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
|
||||
{displayedSongs.map((song, i) => {
|
||||
const realIdx = isFiltered ? songs.indexOf(song) : i;
|
||||
return (
|
||||
<React.Fragment key={song.id + i}>
|
||||
{!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
data-track-idx={realIdx}
|
||||
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)}
|
||||
onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, realIdx)}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (selectedIds.size > 0) {
|
||||
toggleSelect(song.id, idx, e.shiftKey);
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
toggleSelect(song.id, i, false);
|
||||
} else if (selectedIds.size > 0) {
|
||||
toggleSelect(song.id, i, e.shiftKey);
|
||||
} else {
|
||||
playTrack(tracks[idx], tracks);
|
||||
playTrack(displayedTracks[i], displayedTracks);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
|
||||
}}
|
||||
>
|
||||
{visibleCols.map(colDef => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
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); }} />
|
||||
<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(displayedTracks[i], displayedTracks); }}>
|
||||
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, 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>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return (
|
||||
@@ -844,6 +1043,11 @@ export default function PlaylistDetail() {
|
||||
<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 'album': return (
|
||||
<div key="album" className="track-artist-cell">
|
||||
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</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))' }}>
|
||||
@@ -851,7 +1055,7 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[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">
|
||||
@@ -860,7 +1064,7 @@ export default function PlaylistDetail() {
|
||||
);
|
||||
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">
|
||||
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -869,22 +1073,14 @@ export default function PlaylistDetail() {
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
{!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* Total row */}
|
||||
{songs.length > 0 && (
|
||||
<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>
|
||||
|
||||
{/* ── Suggestions ── */}
|
||||
@@ -917,7 +1113,7 @@ export default function PlaylistDetail() {
|
||||
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>;
|
||||
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
|
||||
import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
}
|
||||
|
||||
export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack } = usePlayerStore();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const removeId = usePlaylistStore((s) => s.removeId);
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
|
||||
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
|
||||
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -29,24 +31,45 @@ export default function Playlists() {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists().finally(() => setLoading(false));
|
||||
}, [fetchPlaylists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) nameInputRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
await createPlaylist(name);
|
||||
const updated = await getPlaylists();
|
||||
setPlaylists(updated);
|
||||
} catch {}
|
||||
await createPlaylist(name);
|
||||
// Refresh playlists from API to get the new one
|
||||
await fetchPlaylists();
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
};
|
||||
@@ -75,11 +98,67 @@ export default function Playlists() {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
|
||||
} catch {}
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.filter((p) => p.id !== pl.id),
|
||||
}));
|
||||
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||
}
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedPlaylists.length === 0) return;
|
||||
let deleted = 0;
|
||||
for (const pl of selectedPlaylists) {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
deleted++;
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
usePlaylistStore.setState((s) => ({
|
||||
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
|
||||
}));
|
||||
clearSelection();
|
||||
if (deleted > 0) {
|
||||
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
|
||||
if (selectedPlaylists.length === 0) return;
|
||||
try {
|
||||
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
|
||||
const targetIds = new Set(targetSongs.map(s => s.id));
|
||||
let totalAdded = 0;
|
||||
|
||||
for (const pl of selectedPlaylists) {
|
||||
if (pl.id === targetPlaylist.id) continue;
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const newSongs = songs.filter(s => !targetIds.has(s.id));
|
||||
if (newSongs.length > 0) {
|
||||
newSongs.forEach(s => targetIds.add(s.id));
|
||||
totalAdded += newSongs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
|
||||
touchPlaylist(targetPlaylist.id);
|
||||
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
|
||||
} else {
|
||||
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||
}
|
||||
clearSelection();
|
||||
} catch {
|
||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -93,34 +172,51 @@ export default function Playlists() {
|
||||
|
||||
{/* ── Header row ── */}
|
||||
<div className="playlists-header">
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('playlists.selectionCount', { count: selectedIds.size })
|
||||
: t('playlists.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
{t('playlists.create')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
{t('playlists.create')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,10 +228,34 @@ export default function Playlists() {
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="album-card"
|
||||
onClick={() => navigate(`/playlists/${pl.id}`)}
|
||||
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(pl.id);
|
||||
} else {
|
||||
navigate(`/playlists/${pl.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||
style={selectionMode && selectedIds.has(pl.id) ? {
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : {}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
{/* Cover area — server collage or fallback icon */}
|
||||
<div className="album-card-cover">
|
||||
{pl.coverArt ? (
|
||||
@@ -165,15 +285,6 @@ export default function Playlists() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button — top-right corner */}
|
||||
<button
|
||||
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="album-card-info">
|
||||
|
||||
@@ -1,42 +1,118 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
||||
const ALBUM_FETCH_OVERSHOOT = 100;
|
||||
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
|
||||
const GENRE_UNION_PREFILTER_CAP = 250;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
}
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
const seen = new Set<string>();
|
||||
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = union.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[union[i], union[j]] = [union[j], union[i]];
|
||||
}
|
||||
return union.slice(0, ALBUM_COUNT);
|
||||
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
|
||||
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
|
||||
return filtered.slice(0, ALBUM_COUNT);
|
||||
}
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
|
||||
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
|
||||
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
|
||||
const mixMinRatingArtist = auth.mixMinRatingArtist;
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const loadingRef = useRef(false);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||
}, []);
|
||||
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
if (!folder) return;
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
clearSelection();
|
||||
for (const album of selectedAlbums) {
|
||||
const downloadId = crypto.randomUUID();
|
||||
const filename = `${sanitizeFilename(album.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(album.id);
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||||
complete(downloadId);
|
||||
} catch (e) {
|
||||
fail(downloadId);
|
||||
console.error('ZIP download failed for', album.name, e);
|
||||
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOffline = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const load = useCallback(async (genres: string[]) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMixActive =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
||||
const data = genres.length > 0
|
||||
? await fetchByGenres(genres)
|
||||
: await getAlbumList('random', ALBUM_COUNT);
|
||||
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -44,24 +120,58 @@ export default function RandomAlbums() {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [musicLibraryFilterVersion]);
|
||||
}, [
|
||||
musicLibraryFilterVersion,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('randomAlbums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => load(selectedGenres)}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => load(selectedGenres)}
|
||||
disabled={loading}
|
||||
data-tooltip={t('randomAlbums.refresh')}
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('randomAlbums.refresh')}
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +182,15 @@ export default function RandomAlbums() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shuffle, Dices } from 'lucide-react';
|
||||
|
||||
interface MixCard {
|
||||
icon: React.ElementType;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const CARDS: MixCard[] = [
|
||||
{
|
||||
icon: Shuffle,
|
||||
labelKey: 'randomLanding.mixByTracks',
|
||||
descKey: 'randomLanding.mixByTracksDesc',
|
||||
to: '/random/mix',
|
||||
},
|
||||
{
|
||||
icon: Dices,
|
||||
labelKey: 'randomLanding.mixByAlbums',
|
||||
descKey: 'randomLanding.mixByAlbumsDesc',
|
||||
to: '/random/albums',
|
||||
},
|
||||
];
|
||||
|
||||
export default function RandomLanding() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="random-landing">
|
||||
<div className="random-landing-grid">
|
||||
{CARDS.map(({ icon: Icon, labelKey, descKey, to }) => (
|
||||
<button
|
||||
key={to}
|
||||
className="mix-pick-card"
|
||||
onClick={() => navigate(to)}
|
||||
>
|
||||
<Icon className="mix-pick-card-bg-icon" strokeWidth={1} aria-hidden />
|
||||
<div className="mix-pick-card-content">
|
||||
<Icon size={28} strokeWidth={1.5} className="mix-pick-card-icon" aria-hidden />
|
||||
<span className="mix-pick-card-label">{t(labelKey)}</span>
|
||||
<span className="mix-pick-card-desc">{t(descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import {
|
||||
fetchRandomMixSongsUntilFull,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
passesMixMinRatings,
|
||||
} from '../utils/mixRatingFilter';
|
||||
|
||||
const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
@@ -35,7 +40,26 @@ export default function RandomMix() {
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
|
||||
const {
|
||||
excludeAudiobooks,
|
||||
setExcludeAudiobooks,
|
||||
customGenreBlacklist,
|
||||
setCustomGenreBlacklist,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingSong,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
} = useAuthStore();
|
||||
|
||||
const mixRatingCfg = useMemo(
|
||||
() => ({
|
||||
enabled: mixMinRatingFilterEnabled,
|
||||
minSong: mixMinRatingSong,
|
||||
minAlbum: mixMinRatingAlbum,
|
||||
minArtist: mixMinRatingArtist,
|
||||
}),
|
||||
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||
@@ -56,11 +80,11 @@ export default function RandomMix() {
|
||||
const fetchSongs = () => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
getRandomSongs(50)
|
||||
.then(fetched => {
|
||||
setSongs(fetched);
|
||||
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
|
||||
.then(list => {
|
||||
setSongs(list);
|
||||
const st = new Set<string>();
|
||||
fetched.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
list.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
setStarredSongs(st);
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -97,6 +121,7 @@ export default function RandomMix() {
|
||||
if (song.title && checkText(song.title)) return false;
|
||||
if (song.album && checkText(song.album)) return false;
|
||||
if (song.artist && checkText(song.artist)) return false;
|
||||
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -132,8 +157,11 @@ export default function RandomMix() {
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
try {
|
||||
const fetched = await getRandomSongs(50, genre, 45000);
|
||||
setGenreMixSongs(fetched);
|
||||
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
|
||||
genre,
|
||||
timeout: 45000,
|
||||
});
|
||||
setGenreMixSongs(list);
|
||||
} catch {}
|
||||
setGenreMixLoading(false);
|
||||
setGenreMixComplete(true);
|
||||
@@ -154,8 +182,17 @@ export default function RandomMix() {
|
||||
<h1 className="page-title">{t('randomMix.title')}</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
|
||||
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : fetchSongs}
|
||||
disabled={selectedGenre ? genreMixLoading : loading}
|
||||
data-tooltip={selectedGenre
|
||||
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
|
||||
: t('randomMix.remixTooltip')
|
||||
}
|
||||
>
|
||||
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
|
||||
{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
|
||||
</button>
|
||||
{(() => {
|
||||
const isGenreLoading = selectedGenre && !genreMixComplete;
|
||||
@@ -285,6 +322,14 @@ export default function RandomMix() {
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`btn ${selectedGenre === null ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
onClick={() => { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }}
|
||||
disabled={genreMixLoading}
|
||||
>
|
||||
{t('randomMix.genreMixAll')}
|
||||
</button>
|
||||
{displayedGenres.map(genre => (
|
||||
<button
|
||||
key={genre}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import {
|
||||
fetchStatisticsFormatSample,
|
||||
fetchStatisticsLibraryAggregates,
|
||||
fetchStatisticsOverview,
|
||||
getAlbumList,
|
||||
SubsonicAlbum,
|
||||
SubsonicGenre,
|
||||
} from '../api/subsonic';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -14,13 +23,6 @@ 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,6 +34,7 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -57,70 +60,59 @@ export default function Statistics() {
|
||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getAlbumList('recent', 20).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
getGenres().catch(() => []),
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setArtistCount(a.length);
|
||||
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));
|
||||
fetchStatisticsOverview()
|
||||
.then(d => {
|
||||
setRecent(d.recent);
|
||||
setFrequent(d.frequent);
|
||||
setHighest(d.highest);
|
||||
setArtistCount(d.artistCount);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
|
||||
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTotalPlaytime(null);
|
||||
setTotalAlbums(null);
|
||||
setTotalSongs(null);
|
||||
setPlaytimeCapped(false);
|
||||
setGenres([]);
|
||||
(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;
|
||||
try {
|
||||
const agg = await fetchStatisticsLibraryAggregates();
|
||||
if (cancelled) return;
|
||||
setTotalPlaytime(agg.playtimeSec);
|
||||
setTotalAlbums(agg.albumsCounted);
|
||||
setTotalSongs(agg.songsCounted);
|
||||
setPlaytimeCapped(agg.capped);
|
||||
setGenres(agg.genres);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(0);
|
||||
setTotalAlbums(0);
|
||||
setTotalSongs(0);
|
||||
setPlaytimeCapped(false);
|
||||
setGenres([]);
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(total);
|
||||
setPlaytimeCapped(capped);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// Background fetch: format distribution (sample of 500 random songs)
|
||||
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
||||
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(() => {});
|
||||
setFormatData(null);
|
||||
setFormatSampleSize(0);
|
||||
fetchStatisticsFormatSample()
|
||||
.then(s => {
|
||||
if (cancelled) return;
|
||||
setFormatData(s.rows);
|
||||
setFormatSampleSize(s.sampleSize);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
@@ -163,12 +155,15 @@ export default function Statistics() {
|
||||
|
||||
const playtimeDisplay = totalPlaytime === null
|
||||
? t('statistics.computing')
|
||||
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
||||
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
|
||||
|
||||
const countDisplay = (n: number | null) =>
|
||||
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—', tooltip: t('statistics.statArtistsTooltip') },
|
||||
{ label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) },
|
||||
{ label: t('statistics.statSongs'), value: countDisplay(totalSongs) },
|
||||
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
|
||||
];
|
||||
|
||||
@@ -188,7 +183,7 @@ export default function Statistics() {
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="stats-card">
|
||||
<span className="stats-card-value">{s.value}</span>
|
||||
<span className="stats-card-label">{s.label}</span>
|
||||
<span className="stats-card-label" data-tooltip={s.tooltip} data-tooltip-wrap="true">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -204,10 +199,10 @@ export default function Statistics() {
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{topGenres.map(g => (
|
||||
<div key={g.value}>
|
||||
<div key={g.value || '__genre_unknown__'}>
|
||||
<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}
|
||||
{g.value.trim() ? g.value : t('statistics.decadeUnknown')}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
{g.songCount.toLocaleString()}
|
||||
@@ -282,6 +277,7 @@ export default function Statistics() {
|
||||
albums={highest}
|
||||
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
||||
moreText={t('statistics.loadMore')}
|
||||
showRating
|
||||
/>
|
||||
|
||||
{/* Last.fm Stats */}
|
||||
@@ -363,7 +359,7 @@ export default function Statistics() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{lfmRecentTracks.map((track, i) => (
|
||||
{lfmRecentTracks.slice(0, 3).map((track, i) => (
|
||||
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||
import {
|
||||
isNavidromeAudiomuseSoftwareEligible,
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/subsonicServerIdentity';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
|
||||
export interface ServerProfile {
|
||||
@@ -11,6 +17,17 @@ export interface ServerProfile {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
|
||||
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||
|
||||
const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
|
||||
{ id: 'server', enabled: true },
|
||||
{ id: 'lrclib', enabled: true },
|
||||
{ id: 'netease', enabled: false },
|
||||
];
|
||||
|
||||
interface AuthState {
|
||||
// Multi-server
|
||||
servers: ServerProfile[];
|
||||
@@ -31,10 +48,12 @@ interface AuthState {
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
replayGainMode: 'track' | 'album';
|
||||
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
|
||||
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
preloadMode: 'balanced' | 'early' | 'custom';
|
||||
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
|
||||
preloadCustomSeconds: number;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
@@ -42,14 +61,24 @@ interface AuthState {
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
enableAppleMusicCoversDiscord: boolean;
|
||||
useCustomTitlebar: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
lyricsServerFirst: boolean;
|
||||
enableNeteaselyrics: boolean;
|
||||
lyricsSources: LyricsSourceConfig[];
|
||||
showFullscreenLyrics: boolean;
|
||||
showFsArtistPortrait: boolean;
|
||||
/** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */
|
||||
fsPortraitDim: number;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
|
||||
seekbarStyle: SeekbarStyle;
|
||||
|
||||
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||
enableHiRes: boolean;
|
||||
/** Selected audio output device name. null = system default. */
|
||||
audioOutputDevice: string | null;
|
||||
|
||||
/** Alpha: ephemeral queue prefetch cache on disk */
|
||||
hotCacheEnabled: boolean;
|
||||
@@ -58,6 +87,29 @@ interface AuthState {
|
||||
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
|
||||
hotCacheDownloadDir: string;
|
||||
|
||||
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
|
||||
skipStarOnManualSkipsEnabled: boolean;
|
||||
/** Manual skips per track before applying rating 1 (when enabled). */
|
||||
skipStarManualSkipThreshold: number;
|
||||
/**
|
||||
* Manual Next-count per track for skip→1★. Key = `${serverId}\\u001f${trackId}`
|
||||
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
|
||||
*/
|
||||
skipStarManualSkipCountsByKey: Record<string, number>;
|
||||
/** Increment skip count for current server + track; clears stored count when threshold reached. */
|
||||
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
|
||||
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => void;
|
||||
|
||||
/** Random mixes, random albums, home hero: drop non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */
|
||||
mixMinRatingFilterEnabled: boolean;
|
||||
/** 0 = ignore; 1–3 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
|
||||
mixMinRatingSong: number;
|
||||
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
|
||||
mixMinRatingAlbum: number;
|
||||
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
|
||||
mixMinRatingArtist: number;
|
||||
|
||||
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
|
||||
musicFolders: Array<{ id: string; name: string }>;
|
||||
/**
|
||||
@@ -68,6 +120,34 @@ interface AuthState {
|
||||
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
|
||||
musicLibraryFilterVersion: number;
|
||||
|
||||
/**
|
||||
* Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style).
|
||||
* Absent key = not probed yet (`unknown` in UI).
|
||||
*/
|
||||
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
|
||||
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
|
||||
|
||||
/**
|
||||
* Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and
|
||||
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
|
||||
*/
|
||||
audiomuseNavidromeByServer: Record<string, boolean>;
|
||||
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
|
||||
|
||||
/** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */
|
||||
subsonicServerIdentityByServer: Record<string, SubsonicServerIdentity>;
|
||||
setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void;
|
||||
|
||||
/** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */
|
||||
audiomuseNavidromeIssueByServer: Record<string, boolean>;
|
||||
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
|
||||
|
||||
/**
|
||||
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
|
||||
*/
|
||||
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
|
||||
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
|
||||
|
||||
// Status
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
@@ -94,10 +174,12 @@ interface AuthState {
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
setReplayGainMode: (v: 'track' | 'album') => void;
|
||||
setReplayGainPreGainDb: (v: number) => void;
|
||||
setReplayGainFallbackDb: (v: number) => void;
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadCustomSeconds: (v: number) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
@@ -105,16 +187,29 @@ interface AuthState {
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
setEnableNeteaselyrics: (v: boolean) => void;
|
||||
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||
setEnableHiRes: (v: boolean) => void;
|
||||
setAudioOutputDevice: (v: string | null) => void;
|
||||
setHotCacheEnabled: (v: boolean) => void;
|
||||
setHotCacheMaxMb: (v: number) => void;
|
||||
setHotCacheDebounceSec: (v: number) => void;
|
||||
setHotCacheDownloadDir: (v: string) => void;
|
||||
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
|
||||
setSkipStarManualSkipThreshold: (v: number) => void;
|
||||
setMixMinRatingFilterEnabled: (v: boolean) => void;
|
||||
setMixMinRatingSong: (v: number) => void;
|
||||
setMixMinRatingAlbum: (v: number) => void;
|
||||
setMixMinRatingArtist: (v: number) => void;
|
||||
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
|
||||
setMusicLibraryFilter: (folderId: 'all' | string) => void;
|
||||
logout: () => void;
|
||||
@@ -128,6 +223,33 @@ function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
|
||||
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
|
||||
|
||||
function clampMixFilterMinStars(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
|
||||
}
|
||||
|
||||
function clampSkipStarThreshold(v: number): number {
|
||||
if (!Number.isFinite(v)) return 3;
|
||||
return Math.max(1, Math.min(99, Math.round(v)));
|
||||
}
|
||||
|
||||
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
|
||||
return `${serverId ?? ''}\u001f${trackId}`;
|
||||
}
|
||||
|
||||
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const next: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -145,6 +267,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: 0,
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
@@ -156,19 +280,38 @@ export const useAuthStore = create<AuthState>()(
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
enableAppleMusicCoversDiscord: false,
|
||||
useCustomTitlebar: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
enableNeteaselyrics: false,
|
||||
lyricsSources: DEFAULT_LYRICS_SOURCES,
|
||||
showFullscreenLyrics: true,
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
seekbarStyle: 'waveform',
|
||||
enableHiRes: false,
|
||||
audioOutputDevice: null,
|
||||
hotCacheEnabled: false,
|
||||
hotCacheMaxMb: 256,
|
||||
hotCacheDebounceSec: 30,
|
||||
hotCacheDownloadDir: '',
|
||||
skipStarOnManualSkipsEnabled: false,
|
||||
skipStarManualSkipThreshold: 3,
|
||||
skipStarManualSkipCountsByKey: {},
|
||||
mixMinRatingFilterEnabled: false,
|
||||
mixMinRatingSong: 0,
|
||||
mixMinRatingAlbum: 0,
|
||||
mixMinRatingArtist: 0,
|
||||
musicFolders: [],
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
entityRatingSupportByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -190,10 +333,20 @@ export const useAuthStore = create<AuthState>()(
|
||||
set(s => {
|
||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||
const switchedAway = s.activeServerId === id;
|
||||
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
|
||||
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
|
||||
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
|
||||
return {
|
||||
servers: newServers,
|
||||
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
|
||||
isLoggedIn: switchedAway ? false : s.isLoggedIn,
|
||||
entityRatingSupportByServer: entityRatingRest,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
subsonicServerIdentityByServer: identityRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
instantMixProbeByServer: probeRest,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -229,10 +382,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ replayGainMode: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setReplayGainPreGainDb: (v) => {
|
||||
set({ replayGainPreGainDb: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setReplayGainFallbackDb: (v) => {
|
||||
set({ replayGainFallbackDb: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
@@ -240,18 +401,63 @@ export const useAuthStore = create<AuthState>()(
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||
setLyricsSources: (sources) => set({ lyricsSources: sources }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
|
||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
||||
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
|
||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
|
||||
|
||||
setSkipStarOnManualSkipsEnabled: (v) =>
|
||||
set({
|
||||
skipStarOnManualSkipsEnabled: v,
|
||||
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
|
||||
}),
|
||||
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
|
||||
|
||||
recordSkipStarManualAdvance: (trackId: string) => {
|
||||
const s = get();
|
||||
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
|
||||
const threshold = s.skipStarManualSkipThreshold;
|
||||
const next = prev + 1;
|
||||
if (next >= threshold) {
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
return { crossedThreshold: true };
|
||||
}
|
||||
set({
|
||||
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
|
||||
});
|
||||
return { crossedThreshold: false };
|
||||
},
|
||||
|
||||
clearSkipStarManualCountForTrack: (trackId: string) => {
|
||||
const s = get();
|
||||
const key = skipStarCountStorageKey(s.activeServerId, trackId);
|
||||
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
|
||||
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
|
||||
set({ skipStarManualSkipCountsByKey: rest });
|
||||
},
|
||||
|
||||
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
|
||||
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
|
||||
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
|
||||
|
||||
setMusicFolders: (folders) => {
|
||||
const sid = get().activeServerId;
|
||||
set(s => {
|
||||
@@ -275,6 +481,65 @@ export const useAuthStore = create<AuthState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
setEntityRatingSupport: (serverId, level) =>
|
||||
set(s => ({
|
||||
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
|
||||
})),
|
||||
|
||||
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
|
||||
set(s => {
|
||||
const audiomuseNavidromeByServer = enabled
|
||||
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
|
||||
: (() => {
|
||||
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
|
||||
return rest;
|
||||
})();
|
||||
const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest };
|
||||
}),
|
||||
|
||||
setSubsonicServerIdentity: (serverId, identity) =>
|
||||
set(s => {
|
||||
const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } };
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) {
|
||||
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
|
||||
return {
|
||||
subsonicServerIdentityByServer,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
instantMixProbeByServer: probeRest,
|
||||
};
|
||||
}
|
||||
return { subsonicServerIdentityByServer };
|
||||
}),
|
||||
|
||||
setInstantMixProbe: (serverId, result) =>
|
||||
set(s => {
|
||||
const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result };
|
||||
if (result === 'empty') {
|
||||
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
|
||||
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
|
||||
return {
|
||||
instantMixProbeByServer,
|
||||
audiomuseNavidromeByServer: audiomuseRest,
|
||||
audiomuseNavidromeIssueByServer: issueRest,
|
||||
};
|
||||
}
|
||||
return { instantMixProbeByServer };
|
||||
}),
|
||||
|
||||
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
|
||||
set(s =>
|
||||
hasIssue
|
||||
? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } }
|
||||
: (() => {
|
||||
const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer;
|
||||
return { audiomuseNavidromeIssueByServer: rest };
|
||||
})(),
|
||||
),
|
||||
|
||||
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
|
||||
|
||||
getBaseUrl: () => {
|
||||
@@ -296,6 +561,39 @@ export const useAuthStore = create<AuthState>()(
|
||||
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
// If both hot cache and preload were enabled before mutual exclusion was enforced, reset both.
|
||||
const conflictingLegacyState =
|
||||
state.hotCacheEnabled && state.preloadMode !== 'off'
|
||||
? { hotCacheEnabled: false, preloadMode: 'off' as const }
|
||||
: {};
|
||||
|
||||
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
|
||||
let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {};
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record<string, unknown> };
|
||||
if (!raw?.state?.lyricsSources) {
|
||||
const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true;
|
||||
const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false;
|
||||
const migrated: LyricsSourceConfig[] = serverFirst
|
||||
? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }]
|
||||
: [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }];
|
||||
lyricsSourcesMigrated = { lyricsSources: migrated };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
useAuthStore.setState({
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
...conflictingLegacyState,
|
||||
...lyricsSourcesMigrated,
|
||||
});
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'spac
|
||||
interface FontState {
|
||||
font: FontId;
|
||||
setFont: (font: FontId) => void;
|
||||
uiScale: number;
|
||||
setUiScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
export const useFontStore = create<FontState>()(
|
||||
@@ -13,6 +15,8 @@ export const useFontStore = create<FontState>()(
|
||||
(set) => ({
|
||||
font: 'lexend',
|
||||
setFont: (font) => set({ font }),
|
||||
uiScale: 1.0,
|
||||
setUiScale: (uiScale) => set({ uiScale }),
|
||||
}),
|
||||
{ name: 'psysonic_font' }
|
||||
)
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { formatKeyCode } from './keybindingsStore';
|
||||
import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore';
|
||||
|
||||
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
|
||||
|
||||
const MODIFIER_CODES = [
|
||||
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
|
||||
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
|
||||
];
|
||||
|
||||
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
|
||||
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.includes(e.code)) return null;
|
||||
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
|
||||
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
|
||||
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
|
||||
|
||||
@@ -27,13 +22,7 @@ export function buildGlobalShortcut(e: KeyboardEvent): string | null {
|
||||
|
||||
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
|
||||
export function formatGlobalShortcut(shortcut: string): string {
|
||||
return shortcut.split('+').map(part => {
|
||||
if (part === 'ctrl') return 'Ctrl';
|
||||
if (part === 'alt') return 'Alt';
|
||||
if (part === 'shift') return 'Shift';
|
||||
if (part === 'super' || part === 'meta') return 'Super';
|
||||
return formatKeyCode(part);
|
||||
}).join('+');
|
||||
return formatBinding(shortcut);
|
||||
}
|
||||
|
||||
// Module-level guard — prevents double-registration from React StrictMode's
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
||||
import type { Track } from './playerStore';
|
||||
|
||||
const PREFETCH_AHEAD = 5;
|
||||
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
||||
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
||||
|
||||
export interface HotCacheEntry {
|
||||
localPath: string;
|
||||
@@ -22,7 +24,7 @@ interface HotCacheState {
|
||||
touchPlayed: (trackId: string, serverId: string) => void;
|
||||
removeEntry: (trackId: string, serverId: string) => void;
|
||||
totalBytes: () => number;
|
||||
/** Evict until total size ≤ maxBytes. Respects queue tail first, protects current + next N. */
|
||||
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */
|
||||
evictToFit: (
|
||||
queue: Track[],
|
||||
queueIndex: number,
|
||||
@@ -103,7 +105,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
if (maxBytes <= 0) return;
|
||||
|
||||
const protectLo = Math.max(0, queueIndex);
|
||||
const protectHi = Math.min(queue.length - 1, queueIndex + PREFETCH_AHEAD);
|
||||
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
|
||||
const protectedIds = new Set<string>();
|
||||
for (let i = protectLo; i <= protectHi; i++) {
|
||||
protectedIds.add(queue[i].id);
|
||||
@@ -127,6 +129,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
if (!parsed) continue;
|
||||
const { serverId, trackId } = parsed;
|
||||
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
|
||||
if (isHotCachePreviousTrackUnderGrace(trackId, serverId)) continue;
|
||||
|
||||
const meta = entries[key];
|
||||
const lru = lruStamp(meta);
|
||||
|
||||
@@ -10,10 +10,17 @@ export type KeyAction =
|
||||
| 'seek-forward'
|
||||
| 'seek-backward'
|
||||
| 'toggle-queue'
|
||||
| 'open-folder-browser'
|
||||
| 'fullscreen-player'
|
||||
| 'native-fullscreen';
|
||||
|
||||
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
|
||||
/** Physical keys only — ignore for binding capture */
|
||||
export const MODIFIER_KEY_CODES = [
|
||||
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
|
||||
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
|
||||
] as const;
|
||||
|
||||
// key = action, value = plain e.code ("Space", "KeyN") or chord "ctrl+shift+KeyN", null = unbound
|
||||
export type Bindings = Record<KeyAction, string | null>;
|
||||
|
||||
export const DEFAULT_BINDINGS: Bindings = {
|
||||
@@ -25,22 +32,56 @@ export const DEFAULT_BINDINGS: Bindings = {
|
||||
'seek-forward': null,
|
||||
'seek-backward': null,
|
||||
'toggle-queue': null,
|
||||
'open-folder-browser': null,
|
||||
'fullscreen-player': null,
|
||||
'native-fullscreen': 'F11',
|
||||
};
|
||||
|
||||
interface KeybindingsState {
|
||||
bindings: Bindings;
|
||||
setBinding: (action: KeyAction, code: string | null) => void;
|
||||
setBinding: (action: KeyAction, binding: string | null) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
/** Build persisted binding from a keydown: single key or modifier+key chord. */
|
||||
export function buildInAppBinding(e: KeyboardEvent): string | null {
|
||||
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
|
||||
if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) return e.code;
|
||||
const mods: string[] = [];
|
||||
if (e.ctrlKey) mods.push('ctrl');
|
||||
if (e.altKey) mods.push('alt');
|
||||
if (e.shiftKey) mods.push('shift');
|
||||
if (e.metaKey) mods.push('super');
|
||||
return [...mods, e.code].join('+');
|
||||
}
|
||||
|
||||
/** True if the event matches a stored binding (legacy plain codes = no modifiers). */
|
||||
export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boolean {
|
||||
if (!binding) return false;
|
||||
if (!binding.includes('+')) {
|
||||
return (
|
||||
e.code === binding &&
|
||||
!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
|
||||
);
|
||||
}
|
||||
const parts = binding.split('+');
|
||||
const code = parts[parts.length - 1];
|
||||
if (e.code !== code) return false;
|
||||
const mods = new Set(parts.slice(0, -1));
|
||||
return (
|
||||
e.ctrlKey === mods.has('ctrl') &&
|
||||
e.altKey === mods.has('alt') &&
|
||||
e.shiftKey === mods.has('shift') &&
|
||||
e.metaKey === mods.has('super')
|
||||
);
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
bindings: { ...DEFAULT_BINDINGS },
|
||||
setBinding: (action, code) =>
|
||||
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
|
||||
setBinding: (action, binding) =>
|
||||
set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
|
||||
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
|
||||
}),
|
||||
{ name: 'psysonic_keybindings' }
|
||||
@@ -78,3 +119,15 @@ export function formatKeyCode(code: string): string {
|
||||
if (code === 'Slash') return '/';
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Label for settings UI: plain key or chord (same string shape as global shortcuts). */
|
||||
export function formatBinding(binding: string): string {
|
||||
if (!binding.includes('+')) return formatKeyCode(binding);
|
||||
return binding.split('+').map(part => {
|
||||
if (part === 'ctrl') return 'Ctrl';
|
||||
if (part === 'alt') return 'Alt';
|
||||
if (part === 'shift') return 'Shift';
|
||||
if (part === 'super' || part === 'meta') return 'Super';
|
||||
return formatKeyCode(part);
|
||||
}).join('+');
|
||||
}
|
||||
|
||||