mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 08:15:45 +00:00
Compare commits
92 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 |
@@ -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']
|
||||
@@ -125,7 +125,7 @@ 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
|
||||
@@ -148,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:
|
||||
@@ -156,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/
|
||||
|
||||
+162
@@ -5,6 +5,168 @@ 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+26
-23
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.34.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.6",
|
||||
"version": "1.34.9",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -20,8 +20,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",
|
||||
@@ -1605,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": {
|
||||
@@ -1942,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": {
|
||||
@@ -2696,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",
|
||||
@@ -2996,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": {
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.7",
|
||||
"version": "1.34.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,8 +24,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.7
|
||||
pkgver=1.34.9
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+461
-219
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.7"
|
||||
version = "1.34.10"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -47,6 +47,9 @@ 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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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-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"],"platforms":["linux","macOS","windows"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+390
-19
@@ -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};
|
||||
|
||||
@@ -1150,6 +1152,8 @@ impl Iterator for SizedDecoder {
|
||||
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)]
|
||||
@@ -1446,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>,
|
||||
@@ -1509,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.
|
||||
@@ -1518,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();
|
||||
@@ -1606,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())
|
||||
@@ -1625,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
|
||||
@@ -1655,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();
|
||||
}
|
||||
@@ -1669,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,
|
||||
@@ -1684,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()))),
|
||||
@@ -1769,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
|
||||
@@ -1801,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);
|
||||
@@ -1821,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,
|
||||
@@ -1910,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;
|
||||
@@ -1993,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;
|
||||
@@ -2155,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> {
|
||||
@@ -2215,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
|
||||
@@ -2326,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;
|
||||
@@ -2595,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);
|
||||
@@ -2854,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);
|
||||
@@ -2864,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,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.
|
||||
@@ -715,6 +930,53 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) -
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:
|
||||
@@ -1227,6 +1489,16 @@ fn is_tiling_wm() -> bool {
|
||||
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]
|
||||
@@ -1386,6 +1658,13 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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| {
|
||||
@@ -1414,6 +1693,7 @@ pub fn run() {
|
||||
greet,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
@@ -1433,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,
|
||||
@@ -1444,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,
|
||||
@@ -1458,6 +1745,7 @@ pub fn run() {
|
||||
download_update,
|
||||
open_folder,
|
||||
get_embedded_lyrics,
|
||||
fetch_netease_lyrics,
|
||||
#[cfg(target_os = "windows")]
|
||||
taskbar_win::update_taskbar_icon,
|
||||
])
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.7",
|
||||
"version": "1.34.10",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+76
-19
@@ -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';
|
||||
@@ -44,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';
|
||||
@@ -65,7 +65,7 @@ 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';
|
||||
@@ -87,6 +87,13 @@ function AppShell() {
|
||||
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).
|
||||
@@ -333,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 />} />
|
||||
@@ -400,6 +401,7 @@ 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);
|
||||
@@ -413,6 +415,42 @@ function TauriEventBridge() {
|
||||
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);
|
||||
@@ -425,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();
|
||||
@@ -446,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();
|
||||
@@ -535,6 +583,7 @@ export default function App() {
|
||||
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(() => {
|
||||
@@ -545,6 +594,14 @@ export default function App() {
|
||||
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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+348
-9
@@ -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 {
|
||||
@@ -265,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();
|
||||
@@ -277,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',
|
||||
@@ -309,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;
|
||||
@@ -425,6 +594,152 @@ export async function prefetchAlbumUserRatings(
|
||||
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(),
|
||||
@@ -439,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 [];
|
||||
}
|
||||
@@ -455,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 [];
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ interface AlbumCardProps {
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: 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 ?? '');
|
||||
@@ -43,9 +44,12 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
if (selectionMode) { e.preventDefault(); return; }
|
||||
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 (selectionMode || e.button !== 0) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -243,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 ? (
|
||||
@@ -313,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 ? (
|
||||
|
||||
+384
-193
@@ -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';
|
||||
@@ -9,6 +9,7 @@ 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);
|
||||
@@ -26,10 +27,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
|
||||
}
|
||||
|
||||
// ── 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 },
|
||||
@@ -43,176 +40,94 @@ const COLUMNS: readonly ColDef[] = [
|
||||
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
// Columns where header label is centred in the cell (matches row controls below)
|
||||
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>;
|
||||
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
|
||||
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,
|
||||
userRatingOverrides,
|
||||
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 (compact controls) or left-aligned label + right-edge divider
|
||||
const isResizable = !isLastCol;
|
||||
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={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{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>
|
||||
@@ -227,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>
|
||||
);
|
||||
@@ -255,7 +179,7 @@ export default function AlbumTrackList({
|
||||
return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
@@ -284,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">
|
||||
@@ -296,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}
|
||||
@@ -329,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
|
||||
@@ -347,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')}
|
||||
@@ -411,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>
|
||||
|
||||
+1487
-165
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -80,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"
|
||||
/>
|
||||
@@ -339,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.
|
||||
@@ -403,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) */}
|
||||
@@ -413,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" />
|
||||
|
||||
@@ -40,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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
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, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
@@ -16,6 +17,7 @@ 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';
|
||||
@@ -24,26 +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,
|
||||
userRatingOverrides, setUserRatingOverride,
|
||||
} = usePlayerStore();
|
||||
} = 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;
|
||||
@@ -129,13 +173,23 @@ 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}`)}
|
||||
@@ -148,6 +202,11 @@ export default function PlayerBar() {
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
)}
|
||||
{isRadio && radioMeta.listeners != null && (
|
||||
<span className="player-radio-listeners">
|
||||
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
@@ -207,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>
|
||||
@@ -224,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' : ''}`}
|
||||
@@ -241,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';
|
||||
@@ -272,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; }
|
||||
})();
|
||||
@@ -315,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) => ({
|
||||
@@ -374,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++) {
|
||||
@@ -551,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 {
|
||||
@@ -647,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);
|
||||
|
||||
+84
-17
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -8,12 +8,14 @@ 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, FolderOpen, X,
|
||||
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.
|
||||
@@ -21,10 +23,9 @@ 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' },
|
||||
@@ -57,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;
|
||||
@@ -114,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')
|
||||
@@ -217,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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ export default function StarRating({
|
||||
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);
|
||||
|
||||
@@ -41,6 +43,38 @@ export default function StarRating({
|
||||
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;
|
||||
|
||||
@@ -49,6 +83,7 @@ export default function StarRating({
|
||||
setSuppressHoverPreview(false);
|
||||
|
||||
const next = cappedValue === n ? 0 : n;
|
||||
internalClickRef.current = true;
|
||||
onChange(next);
|
||||
setHover(0);
|
||||
|
||||
|
||||
@@ -123,6 +123,13 @@ export 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: [
|
||||
|
||||
@@ -784,6 +784,15 @@ export function SeekbarPreview({
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -792,35 +801,48 @@ interface Props {
|
||||
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 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 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]);
|
||||
|
||||
// Static styles: redraw on progress / buffered / track changes
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
useEffect(() => {
|
||||
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;
|
||||
if (canvasRef.current) {
|
||||
drawSeekbar(canvasRef.current, seekbarStyle, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId, seekbarStyle]);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
}, [seekbarStyle, trackId]);
|
||||
|
||||
// Animated styles: rAF loop
|
||||
// rAF loop — animated styles only.
|
||||
useEffect(() => {
|
||||
if (!ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
@@ -829,21 +851,14 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
let rafId: number;
|
||||
const tick = () => {
|
||||
animStateRef.current.time += 0.016;
|
||||
drawSeekbar(
|
||||
canvas,
|
||||
seekbarStyle,
|
||||
heightsRef.current,
|
||||
progressRef.current,
|
||||
bufferedRef.current,
|
||||
animStateRef.current,
|
||||
);
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [seekbarStyle]);
|
||||
|
||||
// Resize observer
|
||||
// Resize observer.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -859,12 +874,23 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
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; };
|
||||
@@ -892,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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+34
-11
@@ -1,13 +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' | 'embedded';
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
@@ -46,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);
|
||||
@@ -142,24 +144,45 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
}
|
||||
};
|
||||
|
||||
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 fetchEmbedded()) return;
|
||||
|
||||
const [first, second] = lyricsServerFirst
|
||||
? [fetchServer, fetchLrclibFn]
|
||||
: [fetchLrclibFn, fetchServer];
|
||||
|
||||
if (cancelled) return;
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) 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;
|
||||
}
|
||||
+143
-18
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
+5
-3
@@ -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 },
|
||||
|
||||
+100
-2
@@ -5,6 +5,7 @@ export const deTranslation = {
|
||||
newReleases: 'Neueste',
|
||||
allAlbums: 'Alle Alben',
|
||||
randomAlbums: 'Zufallsalben',
|
||||
randomPicker: 'Mix erstellen',
|
||||
artists: 'Künstler',
|
||||
randomMix: 'Zufallsmix',
|
||||
favorites: 'Favoriten',
|
||||
@@ -25,6 +26,8 @@ export const deTranslation = {
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
expandPlaylists: 'Playlists ausklappen',
|
||||
collapsePlaylists: 'Playlists einklappen',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -94,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',
|
||||
@@ -103,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: {
|
||||
@@ -133,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',
|
||||
@@ -146,6 +156,11 @@ 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',
|
||||
@@ -195,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',
|
||||
@@ -215,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',
|
||||
@@ -236,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',
|
||||
@@ -277,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',
|
||||
@@ -307,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',
|
||||
@@ -316,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',
|
||||
},
|
||||
@@ -344,6 +376,7 @@ 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',
|
||||
@@ -361,6 +394,8 @@ export const deTranslation = {
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||
},
|
||||
settings: {
|
||||
@@ -379,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',
|
||||
@@ -394,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',
|
||||
@@ -458,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.",
|
||||
@@ -477,6 +525,13 @@ export const deTranslation = {
|
||||
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',
|
||||
@@ -486,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',
|
||||
@@ -529,6 +583,7 @@ 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',
|
||||
@@ -560,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',
|
||||
@@ -569,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',
|
||||
@@ -576,6 +636,10 @@ 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',
|
||||
@@ -742,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',
|
||||
@@ -805,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',
|
||||
@@ -856,6 +922,7 @@ 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',
|
||||
@@ -874,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',
|
||||
@@ -918,6 +1012,10 @@ 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',
|
||||
|
||||
+102
-3
@@ -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',
|
||||
@@ -26,6 +27,8 @@ export const enTranslation = {
|
||||
folderBrowser: 'Folder Browser',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
expandPlaylists: 'Expand playlists',
|
||||
collapsePlaylists: 'Collapse playlists',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -95,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',
|
||||
@@ -104,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: {
|
||||
@@ -134,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',
|
||||
@@ -147,6 +157,11 @@ 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',
|
||||
@@ -196,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',
|
||||
@@ -216,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',
|
||||
@@ -237,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',
|
||||
@@ -278,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',
|
||||
@@ -308,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',
|
||||
@@ -317,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',
|
||||
},
|
||||
@@ -345,6 +377,7 @@ 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',
|
||||
@@ -362,6 +395,8 @@ export const enTranslation = {
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
},
|
||||
settings: {
|
||||
@@ -374,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',
|
||||
@@ -395,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',
|
||||
@@ -459,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+).",
|
||||
@@ -478,6 +527,13 @@ export const enTranslation = {
|
||||
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',
|
||||
@@ -487,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',
|
||||
@@ -553,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',
|
||||
@@ -561,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',
|
||||
@@ -570,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',
|
||||
@@ -577,6 +638,10 @@ 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',
|
||||
@@ -636,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',
|
||||
@@ -743,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',
|
||||
@@ -806,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',
|
||||
@@ -857,6 +924,7 @@ 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',
|
||||
@@ -875,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',
|
||||
@@ -919,6 +1014,10 @@ 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',
|
||||
|
||||
+1027
File diff suppressed because it is too large
Load Diff
+114
-13
@@ -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',
|
||||
@@ -25,6 +26,8 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -94,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',
|
||||
@@ -103,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: {
|
||||
@@ -133,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',
|
||||
@@ -146,6 +156,11 @@ 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',
|
||||
@@ -195,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',
|
||||
@@ -215,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',
|
||||
@@ -236,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',
|
||||
@@ -253,17 +278,17 @@ export const frTranslation = {
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
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',
|
||||
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',
|
||||
@@ -277,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',
|
||||
@@ -307,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',
|
||||
@@ -316,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',
|
||||
},
|
||||
@@ -344,6 +376,7 @@ 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',
|
||||
@@ -361,6 +394,8 @@ export const frTranslation = {
|
||||
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: {
|
||||
@@ -379,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',
|
||||
@@ -394,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',
|
||||
@@ -458,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+).",
|
||||
@@ -475,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',
|
||||
@@ -484,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',
|
||||
@@ -527,6 +581,7 @@ 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',
|
||||
@@ -558,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',
|
||||
@@ -567,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é',
|
||||
@@ -574,6 +634,10 @@ 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',
|
||||
@@ -740,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',
|
||||
@@ -803,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',
|
||||
@@ -854,6 +920,7 @@ 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',
|
||||
@@ -872,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',
|
||||
@@ -882,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',
|
||||
@@ -913,6 +1010,10 @@ 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',
|
||||
|
||||
+114
-13
@@ -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',
|
||||
@@ -25,6 +26,8 @@ export const nbTranslation = {
|
||||
folderBrowser: 'Mappeleser',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
expandPlaylists: 'Utvid spillelister',
|
||||
collapsePlaylists: 'Skjul spillelister',
|
||||
},
|
||||
home: {
|
||||
hero: 'Utvalgt',
|
||||
@@ -94,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',
|
||||
@@ -103,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: {
|
||||
@@ -133,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',
|
||||
@@ -146,6 +156,11 @@ 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',
|
||||
@@ -195,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',
|
||||
@@ -215,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',
|
||||
@@ -236,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',
|
||||
@@ -253,17 +278,17 @@ export const nbTranslation = {
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
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',
|
||||
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',
|
||||
@@ -277,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',
|
||||
@@ -307,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',
|
||||
@@ -316,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',
|
||||
},
|
||||
@@ -344,6 +376,7 @@ 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',
|
||||
@@ -361,6 +394,8 @@ export const nbTranslation = {
|
||||
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: {
|
||||
@@ -379,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',
|
||||
@@ -394,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',
|
||||
@@ -459,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.",
|
||||
@@ -474,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',
|
||||
@@ -483,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',
|
||||
@@ -549,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',
|
||||
@@ -557,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',
|
||||
@@ -569,10 +626,17 @@ 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',
|
||||
@@ -739,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',
|
||||
@@ -802,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',
|
||||
@@ -853,6 +919,7 @@ 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',
|
||||
@@ -871,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',
|
||||
@@ -881,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',
|
||||
@@ -912,6 +1009,10 @@ 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',
|
||||
|
||||
+111
-13
@@ -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',
|
||||
@@ -25,6 +26,8 @@ export const nlTranslation = {
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
expandPlaylists: 'Afspeellijsten uitklappen',
|
||||
collapsePlaylists: 'Afspeellijsten inklappen',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
@@ -94,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',
|
||||
@@ -107,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: {
|
||||
@@ -133,6 +141,7 @@ export const nlTranslation = {
|
||||
goToArtist: 'Naar {{artist}}',
|
||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artiest',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Formaat',
|
||||
@@ -146,6 +155,11 @@ 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',
|
||||
@@ -195,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',
|
||||
@@ -215,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',
|
||||
@@ -236,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',
|
||||
@@ -253,17 +277,17 @@ export const nlTranslation = {
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
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',
|
||||
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',
|
||||
@@ -277,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',
|
||||
@@ -307,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',
|
||||
@@ -316,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',
|
||||
},
|
||||
@@ -344,6 +375,7 @@ 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',
|
||||
@@ -361,6 +393,8 @@ export const nlTranslation = {
|
||||
updaterAurHint: 'Update installeren via AUR:',
|
||||
updaterErrorMsg: 'Downloaden mislukt',
|
||||
updaterRetryBtn: 'Opnieuw proberen',
|
||||
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||
durationMinutesOnly: '{{minutes}} min',
|
||||
updaterOpenGitHub: 'Openen op GitHub',
|
||||
},
|
||||
settings: {
|
||||
@@ -379,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',
|
||||
@@ -394,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',
|
||||
@@ -458,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.",
|
||||
@@ -475,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',
|
||||
@@ -484,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',
|
||||
@@ -527,6 +580,7 @@ export const nlTranslation = {
|
||||
shortcutSeekForward: '10s vooruitspoelen',
|
||||
shortcutSeekBackward: '10s terugspoelen',
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutOpenFolderBrowser: '{{folderBrowser}} openen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabSystem: 'Systeem',
|
||||
@@ -558,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',
|
||||
@@ -567,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',
|
||||
@@ -574,6 +633,10 @@ 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',
|
||||
@@ -740,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',
|
||||
@@ -803,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',
|
||||
@@ -854,6 +919,7 @@ 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',
|
||||
@@ -872,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',
|
||||
@@ -882,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',
|
||||
@@ -913,6 +1007,10 @@ 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',
|
||||
|
||||
+126
-26
@@ -6,6 +6,7 @@ export const ruTranslation = {
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
randomPicker: 'Собрать микс',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
@@ -21,17 +22,19 @@ export const ruTranslation = {
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
mostPlayed: 'Популярное',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
starred: 'Личное избранное',
|
||||
recent: 'Недавно добавлено',
|
||||
mostPlayed: 'Чаще всего',
|
||||
mostPlayed: 'Популярное',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
discover: 'Обзор',
|
||||
loadMore: 'Ещё',
|
||||
@@ -95,6 +98,8 @@ export const ruTranslation = {
|
||||
addToQueue: 'В конец очереди',
|
||||
enqueueAlbum: 'Альбом в очередь',
|
||||
startRadio: 'Радио по похожим',
|
||||
instantMix: 'Instant Mix',
|
||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||
lfmLove: 'Любимое на Last.fm',
|
||||
lfmUnlove: 'Убрать с Last.fm',
|
||||
favorite: 'В избранное',
|
||||
@@ -104,10 +109,14 @@ export const ruTranslation = {
|
||||
unfavoriteArtist: 'Убрать исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Убрать альбом из избранного',
|
||||
removeFromQueue: 'Убрать из очереди',
|
||||
removeFromPlaylist: 'Убрать из плейлиста',
|
||||
openAlbum: 'Открыть альбом',
|
||||
goToArtist: 'К исполнителю',
|
||||
download: 'Скачать (ZIP)',
|
||||
addToPlaylist: 'В плейлист',
|
||||
selectedPlaylists: '{{count}} плейлистов выбрано',
|
||||
selectedAlbums: '{{count}} альбомов выбрано',
|
||||
selectedArtists: '{{count}} исполнителей выбрано',
|
||||
songInfo: 'Сведения о треке',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -135,6 +144,7 @@ export const ruTranslation = {
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackGenre: 'Жанр',
|
||||
trackFormat: 'Формат',
|
||||
@@ -148,6 +158,11 @@ export const ruTranslation = {
|
||||
bioClose: 'Закрыть',
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
filterSongs: 'Фильтр треков…',
|
||||
sortNatural: 'По умолчанию',
|
||||
sortByTitle: 'А–Я (название)',
|
||||
sortByArtist: 'А–Я (исполнитель)',
|
||||
sortByAlbum: 'А–Я (альбом)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Оценка альбома',
|
||||
@@ -199,6 +214,13 @@ export const ruTranslation = {
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Собрать микс',
|
||||
mixByTracks: 'Микс по трекам',
|
||||
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
|
||||
mixByAlbums: 'Микс по альбомам',
|
||||
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
refresh: 'Обновить',
|
||||
@@ -221,6 +243,8 @@ export const ruTranslation = {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Новый микс',
|
||||
remixTooltip: 'Подобрать другие случайные треки',
|
||||
remixGenre: 'Новый микс: {{genre}}',
|
||||
remixTooltipGenre: 'Подобрать другие треки жанра {{genre}}',
|
||||
playAll: 'Воспроизвести всё',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
@@ -243,6 +267,7 @@ export const ruTranslation = {
|
||||
blacklistToggle: 'Фильтр по словам',
|
||||
genreMixTitle: 'Микс по жанру',
|
||||
genreMixDesc: 'Топ-20 жанров по числу треков — нажмите, чтобы собрать микс',
|
||||
genreMixAll: 'Все треки',
|
||||
genreMixLoadMore: 'Ещё 10 жанров',
|
||||
genreMixNoGenres: 'На сервере нет данных о жанрах.',
|
||||
shuffleGenres: 'Другие жанры',
|
||||
@@ -262,17 +287,17 @@ export const ruTranslation = {
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
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',
|
||||
select: 'Множественный выбор',
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
selectionCount: '{{count}} выбрано',
|
||||
downloadZips: 'Скачать ZIP-архивы',
|
||||
addOffline: 'Добавить офлайн',
|
||||
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
|
||||
downloadZipFailed: 'Не удалось скачать {{name}}',
|
||||
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
|
||||
offlineFailed: 'Не удалось добавить {{name}} офлайн',
|
||||
},
|
||||
artists: {
|
||||
title: 'Исполнители',
|
||||
@@ -288,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: 'Пароль',
|
||||
@@ -318,6 +348,7 @@ export const ruTranslation = {
|
||||
offlineTitle: 'Нет связи с сервером',
|
||||
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
|
||||
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
|
||||
offlineNoCacheBanner: 'Нет соединения с сервером — {{server}} недоступен',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
@@ -330,6 +361,7 @@ export const ruTranslation = {
|
||||
offlineFilterPlaylists: 'Плейлисты',
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
serverSettings: 'Настройки сервера',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
@@ -358,6 +390,7 @@ export const ruTranslation = {
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
@@ -375,6 +408,8 @@ export const ruTranslation = {
|
||||
updaterAurHint: 'Установить обновление через AUR:',
|
||||
updaterErrorMsg: 'Ошибка загрузки',
|
||||
updaterRetryBtn: 'Повторить',
|
||||
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||
durationMinutesOnly: '{{minutes}}мин',
|
||||
updaterOpenGitHub: 'Открыть на GitHub',
|
||||
},
|
||||
settings: {
|
||||
@@ -394,6 +429,7 @@ export const ruTranslation = {
|
||||
servers: 'Серверы',
|
||||
serverName: 'Название',
|
||||
serverUrl: 'Адрес',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
|
||||
serverUsername: 'Логин',
|
||||
serverPassword: 'Пароль',
|
||||
addServer: 'Добавить сервер',
|
||||
@@ -409,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: 'Эквалайзер',
|
||||
@@ -475,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:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
@@ -487,18 +535,25 @@ export const ruTranslation = {
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
|
||||
discordRichPresence: 'Статус в Discord',
|
||||
discordRichPresenceDesc:
|
||||
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
|
||||
discordAppleCovers: 'Загружать обложки через Apple Music для Discord',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в API поиска Apple для поиска обложки в профиле Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.',
|
||||
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
lyricsServerFirst: 'Предпочитать серверные тексты',
|
||||
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы использовать LRCLIB сначала.',
|
||||
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: 'Выбрать',
|
||||
@@ -508,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',
|
||||
@@ -578,6 +631,7 @@ export const ruTranslation = {
|
||||
shortcutSeekForward: 'Вперёд на 10 с',
|
||||
shortcutSeekBackward: 'Назад на 10 с',
|
||||
shortcutToggleQueue: 'Показать / скрыть очередь',
|
||||
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Системный полный экран',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
@@ -586,6 +640,8 @@ export const ruTranslation = {
|
||||
replayGainMode: 'Режим',
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
replayGainFallback: 'Резерв (без тегов / радио)',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
@@ -595,6 +651,9 @@ export const ruTranslation = {
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
nextTrackBufferingTitle: 'Буферизация следующего трека',
|
||||
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
|
||||
preloadOff: 'Выкл.',
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
preloadEarly: 'Рано (через 5 с после старта)',
|
||||
preloadCustom: 'Свой интервал',
|
||||
@@ -602,6 +661,10 @@ export const ruTranslation = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
fsPlayerSection: 'Полноэкранный плеер',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
@@ -791,11 +854,12 @@ export const ruTranslation = {
|
||||
genreDistribution: 'Жанры (топ-20)',
|
||||
loadMore: 'Ещё',
|
||||
statArtists: 'Исполнители',
|
||||
statArtistsTooltip: 'Только исполнители альбомов — артисты, присутствующие лишь в треках (фичеринг, гость и т. д.) без собственного альбома, не учитываются.',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Треки',
|
||||
statGenres: 'Жанры',
|
||||
statPlaytime: 'Всего прослушано',
|
||||
genreInsights: 'Жанры подробнее',
|
||||
statPlaytime: 'Время звучания',
|
||||
genreInsights: 'По жанрам',
|
||||
formatDistribution: 'Форматы',
|
||||
formatSample: 'Выборка {{n}} треков',
|
||||
computing: 'Считаем…',
|
||||
@@ -862,6 +926,7 @@ export const ruTranslation = {
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceNetease: 'Источник: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
@@ -913,6 +978,7 @@ export const ruTranslation = {
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'В плейлист',
|
||||
addSelected: 'Добавить выбранные',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
removeOffline: 'Удалить из офлайн-кэша',
|
||||
@@ -931,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: 'Онлайн-радио',
|
||||
@@ -972,6 +1068,10 @@ export const ruTranslation = {
|
||||
favorite: 'В избранное',
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
listenerCount_one: '{{count}} слушатель',
|
||||
listenerCount_other: '{{count}} слушателей',
|
||||
recentlyPlayed: 'Недавно сыгранное',
|
||||
upNext: 'Следующий',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Папка пуста',
|
||||
|
||||
+113
-15
@@ -5,6 +5,7 @@ export const zhTranslation = {
|
||||
newReleases: '新发布',
|
||||
allAlbums: '全部专辑',
|
||||
randomAlbums: '随机专辑',
|
||||
randomPicker: '创建混音',
|
||||
artists: '艺术家',
|
||||
randomMix: '随机混音',
|
||||
favorites: '收藏夹',
|
||||
@@ -25,6 +26,8 @@ export const zhTranslation = {
|
||||
folderBrowser: '文件夹浏览器',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
expandPlaylists: '展开播放列表',
|
||||
collapsePlaylists: '收起播放列表',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -94,6 +97,8 @@ export const zhTranslation = {
|
||||
addToQueue: '添加到队列',
|
||||
enqueueAlbum: '专辑加入队列',
|
||||
startRadio: '开始电台',
|
||||
instantMix: '即时混音',
|
||||
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
|
||||
lfmLove: '在 Last.fm 上标记喜欢',
|
||||
lfmUnlove: '取消 Last.fm 喜欢标记',
|
||||
favorite: '收藏',
|
||||
@@ -107,6 +112,9 @@ export const zhTranslation = {
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
selectedPlaylists: '已选择 {{count}} 个播放列表',
|
||||
selectedAlbums: '已选择 {{count}} 个专辑',
|
||||
selectedArtists: '已选择 {{count}} 个艺术家',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -133,6 +141,7 @@ export const zhTranslation = {
|
||||
goToArtist: '前往 {{artist}}',
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackAlbum: '专辑',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
@@ -146,6 +155,11 @@ export const zhTranslation = {
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
filterSongs: '筛选歌曲…',
|
||||
sortNatural: '默认顺序',
|
||||
sortByTitle: 'A–Z(标题)',
|
||||
sortByArtist: 'A–Z(艺术家)',
|
||||
sortByAlbum: 'A–Z(专辑)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: '专辑评分',
|
||||
@@ -195,6 +209,13 @@ export const zhTranslation = {
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomLanding: {
|
||||
title: '创建混音',
|
||||
mixByTracks: '按曲目混音',
|
||||
mixByTracksDesc: '从整个媒体库随机选取曲目',
|
||||
mixByAlbums: '按专辑混音',
|
||||
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
refresh: '刷新',
|
||||
@@ -215,6 +236,8 @@ export const zhTranslation = {
|
||||
title: '随机混音',
|
||||
remix: '重新混音',
|
||||
remixTooltip: '加载新的随机歌曲',
|
||||
remixGenre: '重混 {{genre}}',
|
||||
remixTooltipGenre: '加载新的{{genre}}歌曲',
|
||||
playAll: '全部播放',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
@@ -236,6 +259,7 @@ export const zhTranslation = {
|
||||
blacklistToggle: '关键词过滤',
|
||||
genreMixTitle: '流派混音',
|
||||
genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音',
|
||||
genreMixAll: '所有歌曲',
|
||||
genreMixLoadMore: '加载 10 首更多',
|
||||
genreMixNoGenres: '服务器上未找到流派。',
|
||||
shuffleGenres: '显示其他流派',
|
||||
@@ -253,17 +277,17 @@ export const zhTranslation = {
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
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',
|
||||
select: '多选',
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
selectionCount: '{{count}} 已选择',
|
||||
downloadZips: '下载 ZIP',
|
||||
addOffline: '添加离线',
|
||||
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
|
||||
downloadZipDone: '已下载 {{count}} 个 ZIP',
|
||||
downloadZipFailed: '下载 {{name}} 失败',
|
||||
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
|
||||
offlineFailed: '添加 {{name}} 离线失败',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
@@ -277,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: '密码',
|
||||
@@ -307,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: '会话无效 — 点击重新连接',
|
||||
},
|
||||
@@ -340,6 +371,7 @@ export const zhTranslation = {
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
@@ -357,6 +389,8 @@ export const zhTranslation = {
|
||||
updaterAurHint: '通过 AUR 安装更新:',
|
||||
updaterErrorMsg: '下载失败',
|
||||
updaterRetryBtn: '重试',
|
||||
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||
durationMinutesOnly: '{{minutes}}分钟',
|
||||
updaterOpenGitHub: '在 GitHub 上打开',
|
||||
},
|
||||
settings: {
|
||||
@@ -375,6 +409,7 @@ export const zhTranslation = {
|
||||
servers: '服务器',
|
||||
serverName: '服务器名称',
|
||||
serverUrl: '服务器地址',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
|
||||
serverUsername: '用户名',
|
||||
serverPassword: '密码',
|
||||
addServer: '添加服务器',
|
||||
@@ -390,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: '均衡器',
|
||||
@@ -454,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+)时启用。",
|
||||
@@ -471,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: '默认下载文件夹',
|
||||
@@ -480,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 上的源代码',
|
||||
@@ -546,6 +599,7 @@ export const zhTranslation = {
|
||||
shortcutSeekForward: '快进 10 秒',
|
||||
shortcutSeekBackward: '快退 10 秒',
|
||||
shortcutToggleQueue: '切换队列',
|
||||
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: '全屏播放器',
|
||||
shortcutNativeFullscreen: '原生全屏',
|
||||
playbackTitle: '播放',
|
||||
@@ -554,6 +608,8 @@ export const zhTranslation = {
|
||||
replayGainMode: '模式',
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
replayGainFallback: '回退增益(无标签 / 收音机)',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
@@ -563,6 +619,9 @@ export const zhTranslation = {
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
nextTrackBufferingTitle: '下一曲目缓冲',
|
||||
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
|
||||
preloadOff: '关闭',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
preloadEarly: '提前(播放5秒后)',
|
||||
preloadCustom: '自定义',
|
||||
@@ -570,6 +629,10 @@ export const zhTranslation = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
fsPlayerSection: '全屏播放器',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
@@ -736,10 +799,11 @@ export const zhTranslation = {
|
||||
genreDistribution: '流派分布(前 20)',
|
||||
loadMore: '加载更多',
|
||||
statArtists: '艺术家',
|
||||
statArtistsTooltip: '仅限专辑艺术家——仅作为单曲艺术家出现(合唱、客串等)且无自己专辑的艺术家不计入此处。',
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
statPlaytime: '音频总时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
@@ -799,6 +863,7 @@ export const zhTranslation = {
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
lyricsSourceNetease: '来源:网易云',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
@@ -850,6 +915,7 @@ export const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
addSelected: '添加所选',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
removeOffline: '从离线缓存中移除',
|
||||
@@ -868,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: '最常播放',
|
||||
@@ -877,7 +968,10 @@ export const zhTranslation = {
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
noData: '暂无播放数据。开始收听吧!',
|
||||
noArtists: '所有艺术家已过滤。',
|
||||
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
|
||||
filterCompilationsShort: '隐藏合辑',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
@@ -909,6 +1003,10 @@ export const zhTranslation = {
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
listenerCount_one: '{{count}} 位听众',
|
||||
listenerCount_other: '{{count}} 位听众',
|
||||
recentlyPlayed: '最近播放',
|
||||
upNext: '即将播放',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: '空文件夹',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -57,6 +57,10 @@ export default function AlbumDetail() {
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||
|
||||
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);
|
||||
|
||||
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
|
||||
const albumId = album?.album.id ?? '';
|
||||
@@ -259,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,
|
||||
@@ -267,12 +330,6 @@ const handleEnqueueAll = () => {
|
||||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
// 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]);
|
||||
|
||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||
|
||||
@@ -318,8 +375,29 @@ 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}
|
||||
@@ -330,6 +408,9 @@ const handleEnqueueAll = () => {
|
||||
onRate={handleRate}
|
||||
onToggleSongStar={toggleSongStar}
|
||||
onContextMenu={openContextMenu}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
|
||||
@@ -6,11 +6,12 @@ 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';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
@@ -68,6 +69,7 @@ export default function Albums() {
|
||||
};
|
||||
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -284,6 +286,7 @@ export default function Albums() {
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+84
-13
@@ -57,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);
|
||||
@@ -73,6 +74,9 @@ export default function ArtistDetail() {
|
||||
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);
|
||||
@@ -95,12 +99,6 @@ export default function ArtistDetail() {
|
||||
// Render the page immediately from local data
|
||||
setLoading(false);
|
||||
|
||||
// Fetch artist info (may trigger slow external lookup on the server)
|
||||
// and top songs in the background — do not block rendering
|
||||
getArtistInfo(id).then(artistInfo => {
|
||||
if (!cancelled) setInfo(artistInfo ?? null);
|
||||
}).catch(() => {});
|
||||
|
||||
getTopSongs(artistData.artist.name).then(songsData => {
|
||||
if (!cancelled) setTopSongs(songsData ?? []);
|
||||
}).catch(() => {});
|
||||
@@ -110,6 +108,23 @@ export default function ArtistDetail() {
|
||||
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);
|
||||
@@ -174,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 => {
|
||||
@@ -197,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);
|
||||
@@ -331,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
|
||||
@@ -564,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"
|
||||
@@ -592,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>
|
||||
|
||||
|
||||
+132
-53
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+124
-7
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -16,6 +16,8 @@ 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 },
|
||||
@@ -42,8 +44,17 @@ export default function Favorites() {
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
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);
|
||||
@@ -77,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([
|
||||
@@ -170,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 (
|
||||
@@ -233,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"
|
||||
@@ -252,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); };
|
||||
@@ -264,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>
|
||||
|
||||
+717
-47
@@ -1,10 +1,20 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
|
||||
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';
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
type ColumnKind = 'roots' | 'indexes' | 'directory';
|
||||
type NavPos = { colIndex: number; rowIndex: number };
|
||||
let persistedPlayingPathIds: string[] = [];
|
||||
|
||||
type Column = {
|
||||
id: string;
|
||||
@@ -13,9 +23,28 @@ type Column = {
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
kind: ColumnKind;
|
||||
};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
/** 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 {
|
||||
@@ -37,17 +66,69 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track {
|
||||
};
|
||||
}
|
||||
|
||||
// ── component ─────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
|
||||
// ── root: load music folders on mount ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false };
|
||||
const placeholder: Column = {
|
||||
id: 'root',
|
||||
name: '',
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: 'roots',
|
||||
};
|
||||
setColumns([placeholder]);
|
||||
getMusicFolders()
|
||||
.then(folders => {
|
||||
@@ -63,28 +144,225 @@ export default function FolderBrowser() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── auto-scroll to newly added column ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
}, [columns.length]);
|
||||
|
||||
// ── click a directory ──────────────────────────────────────────────────────
|
||||
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) => {
|
||||
// Mark selected + truncate columns after this one + add loading column
|
||||
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 },
|
||||
{
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
items: [],
|
||||
selectedId: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: nextKind,
|
||||
},
|
||||
]);
|
||||
|
||||
// Column 0 holds music folder roots — their IDs are only valid for
|
||||
// getIndexes.view (musicFolderId), not getMusicDirectory.view
|
||||
const fetchItems = colIndex === 0
|
||||
? getMusicIndexes(item.id)
|
||||
: getMusicDirectory(item.id).then(d => d.child);
|
||||
const fetchItems =
|
||||
colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child);
|
||||
|
||||
fetchItems
|
||||
.then(items => {
|
||||
@@ -105,26 +383,397 @@ export default function FolderBrowser() {
|
||||
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)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── click a file (track) ───────────────────────────────────────────────────
|
||||
const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
setColumns(prev => prev.map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
));
|
||||
// Build queue from all tracks in this column
|
||||
const col = columns[colIndex];
|
||||
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
|
||||
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
||||
}, [columns, playTrack]);
|
||||
|
||||
// ── render ─────────────────────────────────────────────────────────────────
|
||||
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" ref={wrapperRef}>
|
||||
<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">
|
||||
<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 }} />
|
||||
@@ -133,32 +782,53 @@ export default function FolderBrowser() {
|
||||
<div className="folder-col-status folder-col-error">
|
||||
{t('folderBrowser.error')}
|
||||
</div>
|
||||
) : col.items.length === 0 ? (
|
||||
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
|
||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||
) : (
|
||||
col.items.map(item => {
|
||||
(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}
|
||||
className={`folder-col-row${isSelected ? ' selected' : ''}`}
|
||||
onClick={() =>
|
||||
item.isDir
|
||||
? handleDirClick(colIndex, item)
|
||||
: handleFileClick(colIndex, item)
|
||||
}
|
||||
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">
|
||||
{item.isDir
|
||||
? isSelected
|
||||
? <FolderOpen size={14} />
|
||||
: <Folder size={14} />
|
||||
: <Music size={14} />}
|
||||
<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" />
|
||||
)}
|
||||
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
|
||||
+1
-1
@@ -120,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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+18
-5
@@ -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,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -50,6 +51,7 @@ export default function NewReleases() {
|
||||
}, []);
|
||||
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;
|
||||
@@ -183,6 +185,7 @@ export default function NewReleases() {
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+137
-5
@@ -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,15 +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(() => {
|
||||
@@ -231,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[]>([]);
|
||||
@@ -259,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">
|
||||
|
||||
+244
-47
@@ -23,6 +23,7 @@ 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 {
|
||||
@@ -41,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 {
|
||||
@@ -58,6 +57,7 @@ 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: 72, defaultWidth: 92, required: false },
|
||||
@@ -114,6 +114,10 @@ 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);
|
||||
@@ -208,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[]>([]);
|
||||
@@ -228,6 +234,8 @@ export default function PlaylistDetail() {
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
@@ -247,7 +255,7 @@ export default function PlaylistDetail() {
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
}, [id, lastModified]);
|
||||
|
||||
// ── Suggestions ───────────────────────────────────────────────
|
||||
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
|
||||
@@ -443,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 = () => {
|
||||
@@ -461,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;
|
||||
@@ -586,10 +639,11 @@ 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>
|
||||
{/* search close resets selection */}
|
||||
{songs.length > 0 && id && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
@@ -652,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>
|
||||
)}
|
||||
|
||||
@@ -716,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
|
||||
@@ -729,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>
|
||||
@@ -739,7 +921,20 @@ 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
|
||||
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',
|
||||
@@ -750,7 +945,8 @@ export default function PlaylistDetail() {
|
||||
paddingLeft: isCentered ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<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)} />
|
||||
@@ -799,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 (
|
||||
@@ -843,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))' }}>
|
||||
@@ -859,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>
|
||||
@@ -868,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 ── */}
|
||||
|
||||
+168
-57
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-2
@@ -182,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;
|
||||
@@ -313,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}
|
||||
|
||||
+828
-273
File diff suppressed because it is too large
Load Diff
+50
-71
@@ -1,5 +1,13 @@
|
||||
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';
|
||||
@@ -15,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' },
|
||||
@@ -59,81 +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);
|
||||
// Album/song totals come from paginated getAlbumList (see playtime effect) — getGenres is not musicFolder-scoped.
|
||||
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: playtime + album/song counts (same paginated list as library filter; caps at 5000 albums)
|
||||
// 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 playtimeSec = 0;
|
||||
let albumsCounted = 0;
|
||||
let songsCounted = 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) {
|
||||
playtimeSec += a.duration ?? 0;
|
||||
albumsCounted += 1;
|
||||
songsCounted += a.songCount ?? 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(playtimeSec);
|
||||
setTotalAlbums(albumsCounted);
|
||||
setTotalSongs(songsCounted);
|
||||
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]);
|
||||
|
||||
@@ -176,13 +155,13 @@ 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.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 },
|
||||
@@ -204,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>
|
||||
@@ -220,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()}
|
||||
|
||||
+162
-3
@@ -2,6 +2,11 @@ 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 {
|
||||
@@ -14,6 +19,15 @@ export interface ServerProfile {
|
||||
|
||||
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[];
|
||||
@@ -34,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;
|
||||
@@ -48,7 +64,12 @@ interface AuthState {
|
||||
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;
|
||||
|
||||
@@ -56,6 +77,8 @@ interface AuthState {
|
||||
|
||||
/** 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;
|
||||
@@ -104,6 +127,27 @@ interface AuthState {
|
||||
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;
|
||||
@@ -130,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;
|
||||
@@ -144,11 +190,16 @@ interface AuthState {
|
||||
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;
|
||||
@@ -216,6 +267,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: 0,
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
@@ -230,11 +283,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
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,
|
||||
@@ -250,6 +308,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 0,
|
||||
entityRatingSupportByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
@@ -272,11 +334,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
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,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -312,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 }),
|
||||
@@ -326,12 +404,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 }),
|
||||
@@ -403,6 +486,60 @@ export const useAuthStore = create<AuthState>()(
|
||||
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: () => {
|
||||
@@ -426,6 +563,26 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
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),
|
||||
@@ -433,6 +590,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
...conflictingLegacyState,
|
||||
...lyricsSourcesMigrated,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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('+');
|
||||
}
|
||||
|
||||
+168
-68
@@ -32,6 +32,8 @@ export interface Track {
|
||||
genre?: string;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
/** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */
|
||||
size?: number;
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
}
|
||||
@@ -58,9 +60,56 @@ export function songToTrack(song: SubsonicSong): Track {
|
||||
genre: song.genre,
|
||||
samplingRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth,
|
||||
size: song.size,
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleArray<T>(items: T[]): T[] {
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite queue source strategy (Instant Mix-like):
|
||||
* 1) Prefer artist-driven candidates (Top + Similar) around the current track.
|
||||
* 2) Fallback to random songs when artist-driven fetches are empty.
|
||||
*/
|
||||
async function buildInfiniteQueueCandidates(
|
||||
seedTrack: Track | null,
|
||||
existingIds: Set<string>,
|
||||
count = 5,
|
||||
): Promise<Track[]> {
|
||||
const artistId = seedTrack?.artistId?.trim() || null;
|
||||
const artistName = seedTrack?.artist?.trim() || null;
|
||||
|
||||
const [similar, top] = await Promise.all([
|
||||
artistId ? getSimilarSongs2(artistId).catch(() => []) : Promise.resolve([]),
|
||||
artistName ? getTopSongs(artistName).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const seedId = seedTrack?.id ?? null;
|
||||
const mixCandidates = shuffleArray(
|
||||
[...top, ...similar]
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedId && !existingIds.has(t.id)),
|
||||
)
|
||||
.slice(0, count)
|
||||
.map(t => ({ ...t, autoAdded: true as const }));
|
||||
|
||||
if (mixCandidates.length > 0) return mixCandidates;
|
||||
|
||||
const random = await getRandomSongs(count, seedTrack?.genre).catch(() => []);
|
||||
return random
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedId && !existingIds.has(t.id))
|
||||
.slice(0, count)
|
||||
.map(t => ({ ...t, autoAdded: true as const }));
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
@@ -117,6 +166,7 @@ interface PlayerState {
|
||||
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
|
||||
syncLastfmLovedTracks: () => Promise<void>;
|
||||
|
||||
resetAudioPause: () => void;
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
contextMenu: {
|
||||
@@ -124,10 +174,12 @@ interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
|
||||
queueIndex?: number;
|
||||
playlistId?: string;
|
||||
playlistSongIndex?: number;
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
@@ -163,7 +215,6 @@ let seekTarget: number | null = null;
|
||||
// Guard against rapid double-click play/pause sending two state transitions
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
/**
|
||||
* Skip → 1★: counts in `authStore.skipStarManualSkipCountsByKey` (persisted).
|
||||
* Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count;
|
||||
@@ -271,10 +322,10 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
|
||||
// Track ID that has already been sent to audio_chain_preload / audio_preload.
|
||||
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
|
||||
// the last 30 seconds of a track, each spawning its own HTTP download.
|
||||
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||
let gaplessPreloadingId: string | null = null;
|
||||
// Track ID that has already been sent to audio_preload (byte pre-download).
|
||||
let bytePreloadingId: string | null = null;
|
||||
|
||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -328,48 +379,59 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
|
||||
// Pre-buffer / pre-chain next track based on preload mode.
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
|
||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
const shouldPreload = preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0; // balanced (default)
|
||||
if (shouldPreload) {
|
||||
|
||||
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
||||
const shouldBytePreload = !hotCacheEnabled && preloadMode !== 'off' && (
|
||||
preloadMode === 'early'
|
||||
? current_time >= 5
|
||||
: preloadMode === 'custom'
|
||||
? remaining < preloadCustomSeconds && remaining > 0
|
||||
: remaining < 30 && remaining > 0 // balanced (default)
|
||||
);
|
||||
|
||||
if (shouldChainGapless || shouldBytePreload) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||
if (shouldBytePreload && nextTrack.id !== bytePreloadingId) {
|
||||
bytePreloadingId = nextTrack.id;
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
// advance. By the time the track boundary arrives, the next source is
|
||||
// already live — no IPC round-trip at the gap point.
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? nextTrack.replayGainAlbumDb
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb)
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,7 +469,7 @@ function handleAudioEnded() {
|
||||
*/
|
||||
function handleAudioTrackSwitched(duration: number) {
|
||||
lastGaplessSwitchTime = Date.now();
|
||||
gaplessPreloadingId = null; // allow preloading for the track after this one
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // allow preloading for the track after this one
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
@@ -482,11 +544,28 @@ function handleAudioError(message: string) {
|
||||
* set of listeners before creating the second, avoiding duplicate handlers.
|
||||
*/
|
||||
export function initAudioListeners(): () => void {
|
||||
// Dev-only: warn when audio:progress events arrive faster than 10/s.
|
||||
// This would indicate the Rust emit interval was accidentally lowered.
|
||||
let _devEventCount = 0;
|
||||
let _devWindowStart = 0;
|
||||
|
||||
const pending = [
|
||||
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
|
||||
handleAudioProgress(payload.current_time, payload.duration)
|
||||
),
|
||||
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
|
||||
if (import.meta.env.DEV) {
|
||||
_devEventCount++;
|
||||
const now = Date.now();
|
||||
if (_devWindowStart === 0) _devWindowStart = now;
|
||||
if (now - _devWindowStart >= 1000) {
|
||||
if (_devEventCount > 10) {
|
||||
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
|
||||
}
|
||||
_devEventCount = 0;
|
||||
_devWindowStart = now;
|
||||
}
|
||||
}
|
||||
handleAudioProgress(payload.current_time, payload.duration);
|
||||
}),
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
@@ -496,9 +575,12 @@ export function initAudioListeners(): () => void {
|
||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||
if (audioOutputDevice) {
|
||||
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
|
||||
}
|
||||
|
||||
// Keep audio settings in sync whenever auth store changes.
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
@@ -652,8 +734,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
repeatMode: 'off',
|
||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||
|
||||
openContextMenu: (x, y, item, type, queueIndex) => set({
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
|
||||
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex) => set({
|
||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex },
|
||||
}),
|
||||
closeContextMenu: () => set(state => ({
|
||||
contextMenu: { ...state.contextMenu, isOpen: false },
|
||||
@@ -737,19 +819,25 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── playRadio ────────────────────────────────────────────────────────────
|
||||
playRadio: (station) => {
|
||||
playRadio: async (station) => {
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
isAudioPaused = false;
|
||||
clearRadioReconnectTimer();
|
||||
radioReconnectCount = 0;
|
||||
gaplessPreloadingId = null;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
||||
// to HTML5 <audio> — the browser cannot play playlist files directly.
|
||||
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
|
||||
.catch(() => station.streamUrl);
|
||||
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
||||
radioAudio.src = station.streamUrl;
|
||||
radioAudio.volume = volume;
|
||||
radioAudio.src = streamUrl;
|
||||
const { replayGainFallbackDb } = useAuthStore.getState();
|
||||
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
|
||||
radioAudio.volume = Math.min(1, volume * fallbackFactor);
|
||||
radioAudio.play().catch((err: unknown) => {
|
||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
@@ -778,7 +866,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null; // new track — allow fresh preload for next
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
@@ -813,7 +901,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
setDeferHotCachePrefetch(true);
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
|
||||
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
@@ -822,6 +910,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
@@ -863,6 +953,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resetAudioPause: () => {
|
||||
isAudioPaused = false;
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
@@ -891,7 +985,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (freshSong) set({ currentTrack: trackToPlay });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = authStateCold.replayGainEnabled
|
||||
? (authStateCold.replayGainMode === 'album' ? trackToPlay.replayGainAlbumDb : trackToPlay.replayGainTrackDb) ?? null
|
||||
? (authStateCold.replayGainMode === 'album' ? (trackToPlay.replayGainAlbumDb ?? trackToPlay.replayGainTrackDb) : trackToPlay.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
@@ -903,8 +997,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
manual: false,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
@@ -935,6 +1031,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
@@ -970,9 +1068,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
|
||||
if (remainingAuto <= 2) {
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
if (songs.length > 0) {
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
if (newTracks.length > 0) {
|
||||
set(state => ({ queue: [...state.queue, ...newTracks] }));
|
||||
}
|
||||
}).catch(() => {}).finally(() => { infiniteQueueFetching = false; });
|
||||
@@ -1058,15 +1156,15 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||
if (infiniteQueueFetching) return;
|
||||
infiniteQueueFetching = true;
|
||||
getRandomSongs(5, currentTrack?.genre).then(songs => {
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
infiniteQueueFetching = false;
|
||||
if (songs.length === 0) {
|
||||
if (newTracks.length === 0) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue, false);
|
||||
@@ -1266,18 +1364,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (!currentTrack || !currentTrack.id) return;
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? currentTrack.replayGainAlbumDb
|
||||
? (authState.replayGainMode === 'album'
|
||||
? (currentTrack.replayGainAlbumDb ?? currentTrack.replayGainTrackDb)
|
||||
: currentTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (currentTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
|
||||
invoke('audio_update_replay_gain', {
|
||||
volume,
|
||||
replayGainDb,
|
||||
replayGainPeak
|
||||
invoke('audio_update_replay_gain', {
|
||||
volume,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
}).catch(console.error);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getPlaylists, createPlaylist as apiCreatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
|
||||
interface PlaylistStore {
|
||||
recentIds: string[];
|
||||
playlists: SubsonicPlaylist[];
|
||||
playlistsLoading: boolean;
|
||||
lastModified: Record<string, number>;
|
||||
touchPlaylist: (id: string) => void;
|
||||
removeId: (id: string) => void;
|
||||
fetchPlaylists: () => Promise<void>;
|
||||
createPlaylist: (name: string, songIds?: string[]) => Promise<SubsonicPlaylist | null>;
|
||||
addPlaylist: (playlist: SubsonicPlaylist) => void;
|
||||
}
|
||||
|
||||
export const usePlaylistStore = create<PlaylistStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
recentIds: [],
|
||||
playlists: [],
|
||||
playlistsLoading: false,
|
||||
lastModified: {},
|
||||
touchPlaylist: (id) =>
|
||||
set((s) => ({
|
||||
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
|
||||
lastModified: { ...s.lastModified, [id]: Date.now() },
|
||||
})),
|
||||
removeId: (id) =>
|
||||
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
|
||||
fetchPlaylists: async () => {
|
||||
set({ playlistsLoading: true });
|
||||
try {
|
||||
const playlists = await getPlaylists();
|
||||
set({ playlists, playlistsLoading: false });
|
||||
} catch {
|
||||
set({ playlistsLoading: false });
|
||||
}
|
||||
},
|
||||
createPlaylist: async (name: string, songIds?: string[]) => {
|
||||
try {
|
||||
const playlist = await apiCreatePlaylist(name, songIds);
|
||||
set((s) => ({
|
||||
playlists: [...s.playlists, playlist],
|
||||
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
|
||||
}));
|
||||
return playlist;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
addPlaylist: (playlist) => {
|
||||
set((s) => ({
|
||||
playlists: [...s.playlists, playlist],
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{ name: 'psysonic_playlists_recent' }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SelectionState {
|
||||
selectedIds: Set<string>;
|
||||
setSelectedIds: (update: (prev: Set<string>) => Set<string>) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const useSelectionStore = create<SelectionState>((set) => ({
|
||||
selectedIds: new Set<string>(),
|
||||
setSelectedIds: (update) => set((s) => ({ selectedIds: update(s.selectedIds) })),
|
||||
clearAll: () => set({ selectedIds: new Set() }),
|
||||
}));
|
||||
@@ -12,10 +12,9 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mainstage', visible: true },
|
||||
{ id: 'newReleases', visible: true },
|
||||
{ id: 'allAlbums', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
{ id: 'randomPicker', visible: true },
|
||||
{ id: 'artists', visible: true },
|
||||
{ id: 'genres', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'favorites', visible: true },
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula';
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula' | 'vision-dark' | 'vision-navy';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+584
-35
@@ -21,17 +21,7 @@
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
|
||||
transform: scale(1.05);
|
||||
filter: blur(12px);
|
||||
}
|
||||
|
||||
.hero:hover .hero-bg {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.hero:hover .hero-bg {
|
||||
transform: scale(1);
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
|
||||
.hero-dots {
|
||||
@@ -208,6 +198,7 @@
|
||||
|
||||
/* ─ Album Card ─ */
|
||||
.album-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -402,6 +393,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.album-grid-wrap .album-card {
|
||||
@@ -528,6 +520,33 @@
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─── Artist Card Selection Checkbox ─── */
|
||||
.artist-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.artist-card-select-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-card-select-check--on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ── Albums selection bar (portal) ── */
|
||||
.albums-selection-bar {
|
||||
position: fixed;
|
||||
@@ -576,11 +595,17 @@
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-card-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-card-year {
|
||||
@@ -909,11 +934,8 @@
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(20px) brightness(0.6);
|
||||
transform: scale(1.1);
|
||||
/* Hide blur edges */
|
||||
z-index: 0;
|
||||
opacity: 0.8;
|
||||
opacity: 0.35;
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
|
||||
@@ -1148,6 +1170,8 @@
|
||||
|
||||
.album-detail-badge {
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.album-detail-back {
|
||||
@@ -3014,23 +3038,22 @@
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
|
||||
@keyframes mesh-aura-a {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
33% { transform: translate3d(5%, -6%, 0); }
|
||||
66% { transform: translate3d(-3%, 2%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
0% { transform: translate(0, 0 ); }
|
||||
33% { transform: translate(5%, -6% ); }
|
||||
66% { transform: translate(-3%, 2% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes mesh-aura-b {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(-6%, 5%, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
0% { transform: translate(0, 0 ); }
|
||||
50% { transform: translate(-6%, 5% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes portrait-drift {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(0, -10px, 0); }
|
||||
0%, 100% { transform: translateY(0 ); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.fs-mesh-bg {
|
||||
@@ -3042,12 +3065,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */
|
||||
/* Blobs are real divs — no will-change/filter to avoid software compositing layers */
|
||||
.fs-mesh-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-a {
|
||||
@@ -3058,8 +3080,8 @@
|
||||
bottom: -35%;
|
||||
background: radial-gradient(ellipse,
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%,
|
||||
transparent 65%);
|
||||
filter: blur(55px);
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 96%) 45%,
|
||||
transparent 70%);
|
||||
animation: mesh-aura-a 26s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: background 400ms ease-in-out;
|
||||
@@ -3072,8 +3094,8 @@
|
||||
top: -25%;
|
||||
background: radial-gradient(ellipse,
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%,
|
||||
transparent 65%);
|
||||
filter: blur(65px);
|
||||
color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 98%) 45%,
|
||||
transparent 70%);
|
||||
animation: mesh-aura-b 20s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: background 400ms ease-in-out;
|
||||
@@ -3125,6 +3147,17 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Subtle darkening overlay — plain div paint, no GPU cost.
|
||||
Alpha driven by --fs-portrait-dim (set via inline style on .fs-player). */
|
||||
.fs-portrait-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, var(--fs-portrait-dim, 0.28));
|
||||
}
|
||||
|
||||
/* ── Horizontal scrim — dark left edge for text legibility ── */
|
||||
.fs-scrim {
|
||||
position: absolute;
|
||||
@@ -3446,6 +3479,8 @@
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 2vh;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
@@ -3475,6 +3510,74 @@
|
||||
}
|
||||
|
||||
|
||||
/* ── no-compositing overrides ─────────────────────────────────────────────────
|
||||
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
|
||||
Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with
|
||||
software-friendly equivalents so the fullscreen player stays responsive. */
|
||||
|
||||
/* fs-player: remove will-change — without GPU compositor it only wastes RAM */
|
||||
html.no-compositing .fs-player {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Close button: replace frosted-glass blur with flat semi-opaque background */
|
||||
html.no-compositing .fs-close {
|
||||
backdrop-filter: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Portrait: remove CSS filter (drop-shadow), will-change and drift animation */
|
||||
html.no-compositing .fs-portrait {
|
||||
filter: none;
|
||||
will-change: auto;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
|
||||
pointer-events: none on wrap means the overlay never blocks clicks. */
|
||||
html.no-compositing .fs-portrait-wrap {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
html.no-compositing .fs-portrait-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#06060e 0%,
|
||||
rgba(6, 6, 14, 0.4) 6%,
|
||||
transparent 16%
|
||||
);
|
||||
}
|
||||
|
||||
/* Lyrics overlay: just remove the fade — no replacement needed */
|
||||
html.no-compositing .fs-lyrics-overlay {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* Lyrics rail: remove will-change */
|
||||
html.no-compositing .fs-lyrics-rail {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Mesh blobs: stop animations — in software mode each frame composites surfaces
|
||||
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
|
||||
displays this saturates the CPU and drops the player to ~10 FPS.
|
||||
Static gradients are preserved; only the slow pan animation is removed. */
|
||||
html.no-compositing .fs-mesh-blob {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Seekbar played bar: remove box-shadow — its width changes on every playback
|
||||
tick; in software rendering each change triggers a box-shadow repaint. */
|
||||
html.no-compositing .fs-seekbar-played {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-popup {
|
||||
position: absolute;
|
||||
@@ -3625,6 +3728,7 @@
|
||||
backdrop-filter: blur(16px);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
@@ -3641,6 +3745,26 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-keyboard-active,
|
||||
.context-menu-rating-row.context-menu-keyboard-active {
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-keyboard-active {
|
||||
box-shadow: inset 2px 0 0 var(--accent), inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.context-menu-rating-row.context-menu-keyboard-active {
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
box-shadow: inset 3px 0 0 var(--accent), inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.context-menu-rating-row.context-menu-keyboard-active .context-menu-rating-icon {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
@@ -3648,12 +3772,48 @@
|
||||
}
|
||||
|
||||
.context-menu-rating-row {
|
||||
padding: 6px 12px;
|
||||
/* Match .context-menu-item padding so the row lines up with other entries */
|
||||
padding: var(--space-2) var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.context-menu-rating-row .context-menu-rating-icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.context-menu-rating-row .context-menu-rating-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-menu-rating-row .star-rating {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.context-menu-rating-row .star-rating .star {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
/* ─ CSS Tooltips ─ */
|
||||
@@ -3866,6 +4026,13 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-card-label[data-tooltip] {
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Genre chart */
|
||||
.genre-chart {
|
||||
background: var(--bg-card);
|
||||
@@ -4687,6 +4854,121 @@
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* ─ Radio NowPlaying section ─ */
|
||||
.np-radio-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.np-badge-live {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: #f87171;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.np-badge-azuracast {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.np-radio-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.np-radio-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.np-radio-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.np-radio-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.np-radio-next-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.np-radio-track-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.np-radio-track-art--sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.np-radio-track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.np-radio-track-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.np-radio-track-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Radio progress bar in PlayerBar */
|
||||
.radio-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.radio-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
/* Listener count in PlayerBar */
|
||||
.player-radio-listeners {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Queue section */
|
||||
.np-queue-section {
|
||||
flex: 1;
|
||||
@@ -5487,6 +5769,104 @@
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
/* ─ Random Landing ─ */
|
||||
.random-landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: var(--space-8);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
.random-landing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(var(--space-4), 2vw, var(--space-6));
|
||||
width: 100%;
|
||||
max-width: min(860px, 90vw);
|
||||
}
|
||||
|
||||
.mix-pick-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: clamp(200px, 22vh, 320px);
|
||||
padding: var(--space-6);
|
||||
border-radius: 16px;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mix-pick-card:hover {
|
||||
transform: translateY(-4px) scale(1.015);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--accent), 0 0 24px -4px var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mix-pick-card-bg-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -16px;
|
||||
transform: translateY(-50%);
|
||||
width: clamp(140px, 14vw, 200px);
|
||||
height: clamp(140px, 14vw, 200px);
|
||||
color: var(--text-primary);
|
||||
opacity: 0.07;
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mix-pick-card-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mix-pick-card-icon {
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.mix-pick-card-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mix-pick-card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
max-width: 22ch;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.random-landing {
|
||||
padding: var(--space-5) var(--space-4);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.random-landing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mix-pick-card {
|
||||
min-height: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─ Playlists overview header ─ */
|
||||
.playlists-header {
|
||||
display: flex;
|
||||
@@ -5671,6 +6051,28 @@
|
||||
}
|
||||
.alphabet-filter-btn:hover { background: var(--bg-hover); }
|
||||
.alphabet-filter-btn.active { background: var(--accent); color: var(--ctp-crust); }
|
||||
|
||||
/* ─ Artists page alphabet bar ─ */
|
||||
.artists-alpha-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.artists-alpha-btn:not(.artists-alpha-btn--active):hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
.artists-alpha-btn--active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.alphabet-filter-btn.empty { opacity: 0.28; pointer-events: none; }
|
||||
|
||||
/* ─ Radio favorite star ─ */
|
||||
@@ -5829,7 +6231,7 @@
|
||||
|
||||
.playlist-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto 52px 28px;
|
||||
grid-template-columns: 18px 36px 1fr auto 52px 28px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 3px var(--space-1);
|
||||
@@ -5841,6 +6243,18 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.playlist-search-row--selected {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.playlist-search-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-search-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -6804,19 +7218,87 @@
|
||||
/* height fallback for browsers that don't support flex: 1 correctly here */
|
||||
height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--accent) 35%, var(--text-muted)) transparent;
|
||||
}
|
||||
|
||||
.folder-browser-columns:focus,
|
||||
.folder-browser-columns:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.folder-browser-columns::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.folder-browser-columns::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.folder-browser-columns::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--accent) 35%, var(--text-muted));
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.folder-col {
|
||||
flex: 1;
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--accent) 35%, var(--text-muted)) transparent;
|
||||
transition: min-width 0.18s ease, max-width 0.18s ease, flex-basis 0.18s ease, flex-grow 0.18s ease;
|
||||
}
|
||||
|
||||
.folder-browser-columns--compact .folder-col {
|
||||
flex: 0 0 var(--folder-col-width, 220px);
|
||||
min-width: var(--folder-col-width, 220px);
|
||||
max-width: var(--folder-col-width, 220px);
|
||||
}
|
||||
|
||||
.folder-col.folder-col--compact {
|
||||
flex-basis: 56px;
|
||||
min-width: 56px;
|
||||
max-width: 56px;
|
||||
}
|
||||
|
||||
.folder-col.folder-col--compact .folder-col-row {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-left: 0.2rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.folder-col.folder-col--compact .folder-col-name,
|
||||
.folder-col.folder-col--compact .folder-col-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-col.folder-col--compact .folder-col-icon {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.folder-col::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.folder-col::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.folder-col::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--accent) 35%, var(--text-muted));
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.folder-col::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--accent) 55%, var(--text-primary));
|
||||
}
|
||||
|
||||
.folder-col-status {
|
||||
@@ -6830,6 +7312,35 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-col-filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: color-mix(in srgb, var(--bg-app) 92%, transparent);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.folder-col-filter-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.folder-col-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--border-subtle));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.folder-col.folder-col--compact .folder-col-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-col-error {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
@@ -6854,6 +7365,19 @@
|
||||
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
|
||||
}
|
||||
|
||||
.folder-browser-columns.keyboard-nav-active .folder-col-row:hover:not(.selected):not(.context-active):not(.keyboard-active) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Context-menu anchor only — does not replace Miller “open” selection (accent). */
|
||||
.folder-col-row.context-active:not(.selected) {
|
||||
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
|
||||
}
|
||||
|
||||
.folder-col-row.keyboard-active:not(.selected) {
|
||||
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
|
||||
}
|
||||
|
||||
.folder-col-row.selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
@@ -6874,6 +7398,31 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.folder-col-row.now-playing .folder-col-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.folder-col-playing-icon {
|
||||
animation: folderTrackPulse 1.2s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.folder-col-path-playing-icon {
|
||||
animation: folderTrackPulse 1.2s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.folder-col-path-playing-icon svg {
|
||||
stroke-width: 2.6;
|
||||
filter: saturate(1.08) contrast(1.06);
|
||||
}
|
||||
|
||||
@keyframes folderTrackPulse {
|
||||
0% { transform: scale(1); opacity: 0.92; }
|
||||
50% { transform: scale(1.16); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 0.92; }
|
||||
}
|
||||
|
||||
.folder-col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1088,6 +1088,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.player-album-art-wrap::after {
|
||||
content: '';
|
||||
@@ -2305,3 +2306,143 @@
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Sidebar Expandable Playlists Section ─── */
|
||||
.sidebar-playlists-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-playlists-main-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlists-header.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-playlists-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--space-4);
|
||||
padding-top: var(--space-1);
|
||||
padding-bottom: var(--space-1);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar-playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-playlist-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlist-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlist-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-playlists-empty {
|
||||
padding: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
+195
-25
@@ -6516,11 +6516,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Old Shire craftsmanship — vertical wood grain, warm hearthlight from right edge */
|
||||
[data-theme='middle-earth'] .sidebar {
|
||||
background-color: #221608;
|
||||
background-image:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(255, 220, 120, 0.04) 0px, rgba(255, 220, 120, 0.04) 1px,
|
||||
transparent 1px, transparent 14px),
|
||||
linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
|
||||
background-image: linear-gradient(180deg, #261a0a 0%, #1e1408 55%, #241c0e 100%);
|
||||
border-right: 2px solid rgba(200, 160, 40, 0.28);
|
||||
box-shadow:
|
||||
inset -3px 0 12px rgba(200, 140, 30, 0.10),
|
||||
@@ -6982,6 +6978,40 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #7a5a30;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider label */
|
||||
[data-theme='middle-earth'] .queue-divider span {
|
||||
color: #a07840 !important;
|
||||
}
|
||||
|
||||
/* Queue: artist text visible on hover for inactive items */
|
||||
[data-theme='middle-earth'] .queue-item:hover .queue-item-artist,
|
||||
[data-theme='middle-earth'] .queue-item.context-active .queue-item-artist {
|
||||
color: #e8c878;
|
||||
}
|
||||
|
||||
/* AlbumDetail header: artist name readable */
|
||||
[data-theme='middle-earth'] .album-detail-artist,
|
||||
[data-theme='middle-earth'] .album-detail-artist-link {
|
||||
color: #e8c878;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .album-detail-artist-link:hover {
|
||||
color: #f8e060;
|
||||
}
|
||||
|
||||
/* ArtistDetail About: heading, bio text + "Mehr lesen" readable */
|
||||
[data-theme='middle-earth'] .np-card-title {
|
||||
color: #6b4e2a;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .np-bio-text {
|
||||
color: #5a3e20;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .np-bio-toggle {
|
||||
color: #7a5228;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Morpheus — Matrix Style
|
||||
───────────────────────────────────────────────────────────── */
|
||||
@@ -8281,19 +8311,19 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--ctp-teal: #00f0f0;
|
||||
--ctp-yellow: #f0d000;
|
||||
/* O-piece — yellow */
|
||||
--ctp-mauve: #a020f0;
|
||||
--ctp-mauve: #c070ff;
|
||||
/* T-piece — purple */
|
||||
--ctp-lavender: #a020f0;
|
||||
--ctp-pink: #a020f0;
|
||||
--ctp-lavender: #c070ff;
|
||||
--ctp-pink: #c070ff;
|
||||
--ctp-green: #00d000;
|
||||
/* S-piece — green */
|
||||
--ctp-red: #f02000;
|
||||
/* Z-piece — red */
|
||||
--ctp-flamingo: #f02000;
|
||||
--ctp-maroon: #f02000;
|
||||
--ctp-blue: #0060f0;
|
||||
--ctp-blue: #4090ff;
|
||||
/* J-piece — blue */
|
||||
--ctp-sapphire: #0060f0;
|
||||
--ctp-sapphire: #4090ff;
|
||||
--ctp-peach: #f07800;
|
||||
/* L-piece — orange */
|
||||
--ctp-rosewater: #f07800;
|
||||
@@ -8315,7 +8345,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #f0d000;
|
||||
/* O-piece yellow — like the score readout */
|
||||
--text-muted: #3a3a6a;
|
||||
--text-muted: #7878b8;
|
||||
--border: #1e1e48;
|
||||
--border-subtle: #0d0d22;
|
||||
|
||||
@@ -8343,7 +8373,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
[data-theme='tetrastack'] .player-bar {
|
||||
border-top: 3px solid #00f0f0;
|
||||
box-shadow:
|
||||
0 -2px 0 #a020f0,
|
||||
0 -2px 0 #c070ff,
|
||||
0 -8px 24px rgba(0, 240, 240, 0.12);
|
||||
}
|
||||
|
||||
@@ -8395,7 +8425,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
|
||||
/* Album title in queue = T-piece purple */
|
||||
[data-theme='tetrastack'] .queue-meta-album {
|
||||
color: #a020f0;
|
||||
color: #c070ff;
|
||||
}
|
||||
|
||||
/* Active queue item = I-piece left border */
|
||||
@@ -8473,8 +8503,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Artist pill links = J-piece blue */
|
||||
[data-theme='tetrastack'] .artist-ext-link:hover {
|
||||
background: rgba(0, 96, 240, 0.1);
|
||||
color: #0060f0 !important;
|
||||
border-color: rgba(0, 96, 240, 0.4) !important;
|
||||
color: #4090ff !important;
|
||||
border-color: rgba(64, 144, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Now playing active track = Z-piece red accent */
|
||||
@@ -8490,6 +8520,26 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #f0d000;
|
||||
}
|
||||
|
||||
/* Sidebar section labels */
|
||||
[data-theme='tetrastack'] .nav-section-label {
|
||||
color: rgba(0, 240, 240, 0.45);
|
||||
}
|
||||
|
||||
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
|
||||
[data-theme='tetrastack'] .queue-tab-btn {
|
||||
color: #6060a8;
|
||||
}
|
||||
|
||||
/* Queue: inactive song artist */
|
||||
[data-theme='tetrastack'] .queue-item-artist {
|
||||
color: #6060a8;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider */
|
||||
[data-theme='tetrastack'] .queue-divider span {
|
||||
color: #6060a8 !important;
|
||||
}
|
||||
|
||||
/* ── The Book (Social Media) ────────────────────────────────── */
|
||||
[data-theme='the-book'] {
|
||||
color-scheme: light;
|
||||
@@ -12138,11 +12188,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Sidebar — iron plate lines + subtle red glow on active */
|
||||
[data-theme='horde'] .sidebar {
|
||||
background-color: #110300;
|
||||
background-image: repeating-linear-gradient(180deg,
|
||||
transparent 0px,
|
||||
transparent 39px,
|
||||
rgba(100, 20, 0, 0.18) 39px,
|
||||
rgba(100, 20, 0, 0.18) 40px);
|
||||
border-right: 1px solid rgba(140, 40, 10, 0.35);
|
||||
}
|
||||
|
||||
@@ -12262,11 +12307,6 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
/* Sidebar — cathedral stone columns with gold right-border */
|
||||
[data-theme='alliance'] .sidebar {
|
||||
background-color: #040c18;
|
||||
background-image: repeating-linear-gradient(180deg,
|
||||
transparent 0px,
|
||||
transparent 47px,
|
||||
rgba(60, 100, 180, 0.14) 47px,
|
||||
rgba(60, 100, 180, 0.14) 48px);
|
||||
border-right: 2px solid rgba(200, 170, 50, 0.30);
|
||||
}
|
||||
|
||||
@@ -12846,6 +12886,30 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #6a5030;
|
||||
}
|
||||
|
||||
/* Sidebar section labels (sky-blue bg needs light text) */
|
||||
[data-theme='toy-tale'] .nav-section-label {
|
||||
color: rgba(200, 230, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Queue tab buttons (Lyrics / Warteschlange) — inactive */
|
||||
[data-theme='toy-tale'] .queue-tab-btn {
|
||||
color: #b89060;
|
||||
}
|
||||
|
||||
[data-theme='toy-tale'] .queue-tab-btn:hover {
|
||||
color: #f0e8d8;
|
||||
}
|
||||
|
||||
/* Queue: inactive song artist */
|
||||
[data-theme='toy-tale'] .queue-item-artist {
|
||||
color: #9a7848;
|
||||
}
|
||||
|
||||
/* Queue: "Nächste Titel" divider */
|
||||
[data-theme='toy-tale'] .queue-divider span {
|
||||
color: #b89060 !important;
|
||||
}
|
||||
|
||||
/* ─── North Park — South Park ─── */
|
||||
/* Construction paper flat aesthetic — paper-cream main, Colorado mountain-blue sidebar,
|
||||
Kenny orange accent, South Park sky blue player accents. Light, no dark mode. */
|
||||
@@ -14253,3 +14317,109 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--warning: #ffb86c;
|
||||
--danger: #ff5555;
|
||||
}
|
||||
|
||||
/* ─── Vision Dark — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
|
||||
/* Gold #FFD700 on #0D0B12 → contrast ~14.7:1 (WCAG AAA) */
|
||||
[data-theme='vision-dark'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23C8C4D4%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #030210;
|
||||
--ctp-mantle: #06050e;
|
||||
--ctp-base: #0d0b12;
|
||||
--ctp-surface0: #16131e;
|
||||
--ctp-surface1: #201c2c;
|
||||
--ctp-surface2: #2c2838;
|
||||
--ctp-overlay0: #544e6a;
|
||||
--ctp-overlay1: #6e6886;
|
||||
--ctp-overlay2: #8a84a0;
|
||||
--ctp-text: #f2eef8;
|
||||
--ctp-subtext1: #c8c4d4;
|
||||
--ctp-subtext0: #9c98aa;
|
||||
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
|
||||
--ctp-lavender: #a07af8;
|
||||
--ctp-blue: #60a5fa;
|
||||
--ctp-sapphire: #38bdf8;
|
||||
--ctp-sky: #38bdf8;
|
||||
--ctp-teal: #2dd4bf;
|
||||
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
|
||||
--ctp-yellow: #ffd700; /* Gold */
|
||||
--ctp-peach: #e69f00;
|
||||
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion — danger */
|
||||
--ctp-red: #d55e00;
|
||||
--ctp-flamingo: #f472b6;
|
||||
--ctp-pink: #e879f9;
|
||||
--ctp-rosewater: #f0abfc;
|
||||
|
||||
--bg-app: #0d0b12;
|
||||
--bg-sidebar: #08060e;
|
||||
--bg-card: #16131e;
|
||||
--bg-hover: #201c2c;
|
||||
--bg-player: #050310;
|
||||
--bg-glass: rgba(13, 11, 18, 0.84);
|
||||
--accent: #ffd700;
|
||||
--accent-dim: rgba(255, 215, 0, 0.14);
|
||||
--accent-glow: rgba(255, 215, 0, 0.36);
|
||||
--text-primary: #f2eef8;
|
||||
--text-secondary: #c8c4d4;
|
||||
--text-muted: #7e7a8e;
|
||||
--border: #201c2c;
|
||||
--border-subtle: #16131e;
|
||||
--positive: #009e73;
|
||||
--warning: #e69f00;
|
||||
--danger: #d55e00;
|
||||
--volume-accent: #ffd700;
|
||||
}
|
||||
|
||||
/* ─── Vision Navy — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
|
||||
/* Gold #FFD700 on #0A1628 → contrast ~14.5:1 (WCAG AAA) */
|
||||
[data-theme='vision-navy'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23C0C8D8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #030a14;
|
||||
--ctp-mantle: #060e1c;
|
||||
--ctp-base: #0a1628;
|
||||
--ctp-surface0: #112038;
|
||||
--ctp-surface1: #1a2e48;
|
||||
--ctp-surface2: #243c58;
|
||||
--ctp-overlay0: #486480;
|
||||
--ctp-overlay1: #607a98;
|
||||
--ctp-overlay2: #7890ae;
|
||||
--ctp-text: #eef2f8;
|
||||
--ctp-subtext1: #c0c8d8;
|
||||
--ctp-subtext0: #8898ae;
|
||||
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
|
||||
--ctp-lavender: #a07af8;
|
||||
--ctp-blue: #3b82f6;
|
||||
--ctp-sapphire: #38bdf8;
|
||||
--ctp-sky: #7dd3fc;
|
||||
--ctp-teal: #2dd4bf;
|
||||
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
|
||||
--ctp-yellow: #ffd700; /* Gold */
|
||||
--ctp-peach: #e69f00;
|
||||
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion */
|
||||
--ctp-red: #d55e00;
|
||||
--ctp-flamingo: #f472b6;
|
||||
--ctp-pink: #e879f9;
|
||||
--ctp-rosewater: #f0abfc;
|
||||
|
||||
--bg-app: #0a1628;
|
||||
--bg-sidebar: #060e1c;
|
||||
--bg-card: #112038;
|
||||
--bg-hover: #1a2e48;
|
||||
--bg-player: #040c18;
|
||||
--bg-glass: rgba(10, 22, 40, 0.84);
|
||||
--accent: #ffd700;
|
||||
--accent-dim: rgba(255, 215, 0, 0.14);
|
||||
--accent-glow: rgba(255, 215, 0, 0.36);
|
||||
--text-primary: #eef2f8;
|
||||
--text-secondary: #c0c8d8;
|
||||
--text-muted: #607080;
|
||||
--border: #1a2e48;
|
||||
--border-subtle: #112038;
|
||||
--positive: #009e73;
|
||||
--warning: #e69f00;
|
||||
--danger: #d55e00;
|
||||
--volume-accent: #ffd700;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import i18n from '../i18n';
|
||||
|
||||
/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */
|
||||
export function formatHumanHoursMinutes(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) {
|
||||
return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m });
|
||||
}
|
||||
return i18n.t('common.durationMinutesOnly', { minutes: m });
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
|
||||
let deferHotCachePrefetch = false;
|
||||
|
||||
/** Last track that was «current» before a forward queue step — not evicted until debounce elapses. */
|
||||
let previousTrackGraceUntilMs = 0;
|
||||
let previousTrackGraceTrackId: string | null = null;
|
||||
let previousTrackGraceServerId: string | null = null;
|
||||
|
||||
export function setDeferHotCachePrefetch(v: boolean): void {
|
||||
deferHotCachePrefetch = v;
|
||||
}
|
||||
@@ -8,3 +13,26 @@ export function setDeferHotCachePrefetch(v: boolean): void {
|
||||
export function getDeferHotCachePrefetch(): boolean {
|
||||
return deferHotCachePrefetch;
|
||||
}
|
||||
|
||||
/** Call when `queueIndex` advances; the old current track stays eviction-safe for `debounceSec` (capped at 600 s). */
|
||||
export function bumpHotCachePreviousTrackGrace(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
debounceSec: number,
|
||||
): void {
|
||||
const sec = Number.isFinite(debounceSec) ? Math.min(600, Math.max(0, debounceSec)) : 0;
|
||||
previousTrackGraceUntilMs = Date.now() + sec * 1000;
|
||||
previousTrackGraceTrackId = trackId;
|
||||
previousTrackGraceServerId = serverId;
|
||||
}
|
||||
|
||||
export function isHotCachePreviousTrackUnderGrace(trackId: string, serverId: string): boolean {
|
||||
if (!previousTrackGraceTrackId || Date.now() >= previousTrackGraceUntilMs) return false;
|
||||
return previousTrackGraceTrackId === trackId && previousTrackGraceServerId === serverId;
|
||||
}
|
||||
|
||||
export function clearHotCachePreviousGrace(): void {
|
||||
previousTrackGraceUntilMs = 0;
|
||||
previousTrackGraceTrackId = null;
|
||||
previousTrackGraceServerId = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */
|
||||
export type SubsonicServerIdentity = {
|
||||
type?: string;
|
||||
serverVersion?: string;
|
||||
openSubsonic?: boolean;
|
||||
};
|
||||
|
||||
/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */
|
||||
export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped';
|
||||
|
||||
const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0];
|
||||
|
||||
function parseLeadingSemver(version: string | undefined): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function semverGte(a: [number, number, number], b: [number, number, number]): boolean {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
||||
* until the first successful ping with metadata.
|
||||
*/
|
||||
export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean {
|
||||
if (!identity?.type?.trim()) return true;
|
||||
const t = identity.type.trim().toLowerCase();
|
||||
if (t !== 'navidrome') return false;
|
||||
const parsed = parseLeadingSemver(identity.serverVersion);
|
||||
if (!parsed) return true;
|
||||
return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings.
|
||||
* Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no
|
||||
* similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents).
|
||||
*/
|
||||
export function showAudiomuseNavidromeServerSetting(
|
||||
identity: SubsonicServerIdentity | undefined,
|
||||
instantMixProbe: InstantMixProbeResult | undefined,
|
||||
): boolean {
|
||||
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false;
|
||||
if (instantMixProbe === 'empty') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -21,25 +21,29 @@ function loadPrefs(
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
|
||||
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
|
||||
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[]; known?: string[] };
|
||||
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
|
||||
columns.filter(c => c.required).forEach(c => visible.add(c.key));
|
||||
// Auto-show columns that are new since prefs were last saved.
|
||||
// "known" tracks every column seen at save time; absent = newly added column → default to visible.
|
||||
if (parsed.known) {
|
||||
const known = new Set<string>(parsed.known);
|
||||
columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key));
|
||||
}
|
||||
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
|
||||
const durationCol = columns.find(c => c.key === 'duration');
|
||||
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
|
||||
widths.duration = defaultWidths.duration;
|
||||
}
|
||||
return {
|
||||
widths,
|
||||
visible,
|
||||
};
|
||||
return { widths, visible };
|
||||
} catch {
|
||||
return { widths: defaultWidths, visible: defaultVisible };
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
|
||||
const known = Object.keys(widths);
|
||||
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible], known }));
|
||||
}
|
||||
|
||||
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
|
||||
|
||||
Reference in New Issue
Block a user