mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
62 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 |
@@ -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
|
||||
|
||||
+113
-2
@@ -5,15 +5,126 @@ 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 automatically detects whether [AudioMuse-AI](https://github.com/cucadmuh/audiomuse-ai) is active on the Navidrome server and uses it for Random Mix, Similar Artists, and Instant Mix. No configuration required — Psysonic falls back to its existing behavior when AudioMuse is unavailable. Also includes an Instant Mix probe, ping identity, and improved UX for AudioMuse-specific actions.
|
||||
- **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 [@sorensiimSalling](https://github.com/sorensiimSalling), 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.
|
||||
- **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.
|
||||
|
||||
|
||||
Generated
+26
-23
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.7",
|
||||
"version": "1.34.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.7",
|
||||
"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.8",
|
||||
"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.8"
|
||||
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
+322
-42
@@ -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,
|
||||
@@ -1820,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);
|
||||
@@ -1840,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,
|
||||
@@ -1929,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;
|
||||
@@ -2012,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;
|
||||
@@ -2174,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> {
|
||||
@@ -2234,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
|
||||
@@ -2614,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);
|
||||
@@ -2873,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);
|
||||
@@ -2886,45 +3085,129 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
|
||||
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls the OS default output device every 3 s. When it changes (Bluetooth
|
||||
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
|
||||
// the new device and `audio:device-changed` is emitted so the frontend can
|
||||
// restart playback. The old Sink is dropped here — it was bound to the
|
||||
// now-closed OutputStream and can no longer produce audio on any device.
|
||||
// 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 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_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
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;
|
||||
|
||||
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
// 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};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}).await.unwrap_or(None);
|
||||
#[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![]));
|
||||
|
||||
if current_name == last_name {
|
||||
// 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;
|
||||
}
|
||||
|
||||
last_name = current_name.clone();
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
// Only act if there is actually a device to open.
|
||||
let Some(_new_name) = current_name else { continue };
|
||||
#[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;
|
||||
@@ -2934,8 +3217,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
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, reply_tx)).is_err() {
|
||||
return None; // audio thread exited
|
||||
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);
|
||||
@@ -2946,11 +3229,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
|
||||
// Drop the old Sink — it was bound to the now-closed OutputStream.
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -286,12 +286,70 @@ struct IcyMetadata {
|
||||
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;
|
||||
@@ -301,6 +359,9 @@ async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
.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")
|
||||
@@ -390,6 +451,20 @@ async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
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.
|
||||
@@ -1414,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]
|
||||
@@ -1608,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,
|
||||
@@ -1627,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,
|
||||
@@ -1640,6 +1730,7 @@ pub fn run() {
|
||||
fetch_url_bytes,
|
||||
fetch_json_url,
|
||||
fetch_icy_metadata,
|
||||
resolve_stream_url,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
|
||||
@@ -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.8",
|
||||
"version": "1.34.10",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+49
-23
@@ -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);
|
||||
@@ -419,13 +421,10 @@ function TauriEventBridge() {
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen('audio:device-changed', () => {
|
||||
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
|
||||
if (!currentTrack) return;
|
||||
if (isPlaying) {
|
||||
const pos = currentTime;
|
||||
const dur = currentTrack.duration || 1;
|
||||
playTrack(currentTrack);
|
||||
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
|
||||
} 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.
|
||||
@@ -435,6 +434,23 @@ function TauriEventBridge() {
|
||||
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);
|
||||
@@ -447,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();
|
||||
@@ -468,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();
|
||||
|
||||
+4
-9
@@ -631,11 +631,10 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
|
||||
let songsCounted = 0;
|
||||
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
|
||||
const pageSize = 500;
|
||||
const maxPages = 10;
|
||||
let capped = false;
|
||||
const capped = false;
|
||||
let offset = 0;
|
||||
let nextPage = getAlbumList('newest', pageSize, 0);
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
|
||||
for (;;) {
|
||||
try {
|
||||
const albums = await nextPage;
|
||||
for (const a of albums) {
|
||||
@@ -653,12 +652,8 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
|
||||
g.albumCount += 1;
|
||||
}
|
||||
if (albums.length < pageSize) break;
|
||||
if (page === maxPages - 1) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
offset += pageSize;
|
||||
nextPage = getAlbumList('newest', pageSize, offset);
|
||||
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+367
-185
@@ -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>
|
||||
@@ -252,11 +167,11 @@ export default function AlbumTrackList({
|
||||
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>
|
||||
);
|
||||
@@ -264,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)}
|
||||
/>
|
||||
);
|
||||
@@ -293,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">
|
||||
@@ -305,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}
|
||||
@@ -338,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
|
||||
@@ -356,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')}
|
||||
@@ -420,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>
|
||||
|
||||
+1311
-49
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}
|
||||
>
|
||||
|
||||
@@ -355,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.
|
||||
@@ -419,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) */}
|
||||
@@ -429,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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function PlayerBar() {
|
||||
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.
|
||||
@@ -86,6 +87,7 @@ export default function PlayerBar() {
|
||||
// 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;
|
||||
@@ -312,7 +314,14 @@ export default function PlayerBar() {
|
||||
<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 */}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+15
-14
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -47,8 +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 enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics);
|
||||
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
@@ -159,29 +159,30 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
// Netease as last fallback — only when explicitly enabled
|
||||
if (enableNeteaselyrics) {
|
||||
// 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 fetchNetease()) return;
|
||||
if (await fn()) return;
|
||||
}
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack?.id, lyricsSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
|
||||
+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 },
|
||||
|
||||
+81
-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',
|
||||
@@ -105,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: {
|
||||
@@ -135,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',
|
||||
@@ -148,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',
|
||||
@@ -197,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',
|
||||
@@ -217,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',
|
||||
@@ -238,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',
|
||||
@@ -279,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',
|
||||
@@ -309,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',
|
||||
@@ -318,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',
|
||||
},
|
||||
@@ -346,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',
|
||||
@@ -383,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',
|
||||
@@ -467,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.",
|
||||
@@ -488,6 +527,11 @@ export const deTranslation = {
|
||||
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',
|
||||
@@ -497,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',
|
||||
@@ -540,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',
|
||||
@@ -571,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',
|
||||
@@ -590,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',
|
||||
@@ -756,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',
|
||||
@@ -871,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',
|
||||
@@ -889,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',
|
||||
|
||||
+83
-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',
|
||||
@@ -106,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: {
|
||||
@@ -136,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',
|
||||
@@ -149,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',
|
||||
@@ -198,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',
|
||||
@@ -218,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',
|
||||
@@ -239,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',
|
||||
@@ -280,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',
|
||||
@@ -310,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',
|
||||
@@ -319,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',
|
||||
},
|
||||
@@ -347,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',
|
||||
@@ -378,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',
|
||||
@@ -468,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+).",
|
||||
@@ -489,6 +529,11 @@ export const enTranslation = {
|
||||
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',
|
||||
@@ -498,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',
|
||||
@@ -564,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',
|
||||
@@ -572,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',
|
||||
@@ -591,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',
|
||||
@@ -650,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',
|
||||
@@ -757,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',
|
||||
@@ -872,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',
|
||||
@@ -890,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',
|
||||
|
||||
+1027
File diff suppressed because it is too large
Load Diff
+95
-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',
|
||||
@@ -105,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: {
|
||||
@@ -135,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',
|
||||
@@ -148,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',
|
||||
@@ -197,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',
|
||||
@@ -217,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',
|
||||
@@ -238,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',
|
||||
@@ -255,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',
|
||||
@@ -279,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',
|
||||
@@ -309,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',
|
||||
@@ -318,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',
|
||||
},
|
||||
@@ -346,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',
|
||||
@@ -383,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',
|
||||
@@ -467,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+).",
|
||||
@@ -486,6 +525,11 @@ export const frTranslation = {
|
||||
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',
|
||||
@@ -495,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',
|
||||
@@ -538,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',
|
||||
@@ -569,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',
|
||||
@@ -588,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',
|
||||
@@ -754,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',
|
||||
@@ -869,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',
|
||||
@@ -887,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',
|
||||
@@ -897,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',
|
||||
|
||||
+95
-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',
|
||||
@@ -105,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: {
|
||||
@@ -135,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',
|
||||
@@ -148,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',
|
||||
@@ -197,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',
|
||||
@@ -217,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',
|
||||
@@ -238,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',
|
||||
@@ -255,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',
|
||||
@@ -279,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',
|
||||
@@ -309,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',
|
||||
@@ -318,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',
|
||||
},
|
||||
@@ -346,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',
|
||||
@@ -383,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',
|
||||
@@ -468,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.",
|
||||
@@ -485,6 +524,11 @@ export const nbTranslation = {
|
||||
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',
|
||||
@@ -494,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',
|
||||
@@ -560,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',
|
||||
@@ -568,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',
|
||||
@@ -587,6 +633,10 @@ export const nbTranslation = {
|
||||
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',
|
||||
@@ -753,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',
|
||||
@@ -868,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',
|
||||
@@ -886,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',
|
||||
@@ -896,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',
|
||||
|
||||
+92
-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',
|
||||
@@ -109,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: {
|
||||
@@ -135,6 +141,7 @@ export const nlTranslation = {
|
||||
goToArtist: 'Naar {{artist}}',
|
||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||
trackTitle: 'Titel',
|
||||
trackAlbum: 'Album',
|
||||
trackArtist: 'Artiest',
|
||||
trackGenre: 'Genre',
|
||||
trackFormat: 'Formaat',
|
||||
@@ -148,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',
|
||||
@@ -197,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',
|
||||
@@ -217,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',
|
||||
@@ -238,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',
|
||||
@@ -255,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',
|
||||
@@ -279,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',
|
||||
@@ -309,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',
|
||||
@@ -318,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',
|
||||
},
|
||||
@@ -346,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',
|
||||
@@ -383,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',
|
||||
@@ -467,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.",
|
||||
@@ -486,6 +524,11 @@ export const nlTranslation = {
|
||||
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',
|
||||
@@ -495,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',
|
||||
@@ -538,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',
|
||||
@@ -569,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',
|
||||
@@ -588,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',
|
||||
@@ -754,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',
|
||||
@@ -869,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',
|
||||
@@ -887,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',
|
||||
@@ -897,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',
|
||||
|
||||
+104
-23
@@ -6,6 +6,7 @@ export const ruTranslation = {
|
||||
newReleases: 'Новинки',
|
||||
allAlbums: 'Все альбомы',
|
||||
randomAlbums: 'Случайные альбомы',
|
||||
randomPicker: 'Собрать микс',
|
||||
artists: 'Исполнители',
|
||||
randomMix: 'Случайный микс',
|
||||
favorites: 'Избранное',
|
||||
@@ -26,6 +27,8 @@ export const ruTranslation = {
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
expandPlaylists: 'Развернуть плейлисты',
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
},
|
||||
home: {
|
||||
hero: 'Подборка',
|
||||
@@ -106,10 +109,14 @@ export const ruTranslation = {
|
||||
unfavoriteArtist: 'Убрать исполнителя из избранного',
|
||||
unfavoriteAlbum: 'Убрать альбом из избранного',
|
||||
removeFromQueue: 'Убрать из очереди',
|
||||
removeFromPlaylist: 'Убрать из плейлиста',
|
||||
openAlbum: 'Открыть альбом',
|
||||
goToArtist: 'К исполнителю',
|
||||
download: 'Скачать (ZIP)',
|
||||
addToPlaylist: 'В плейлист',
|
||||
selectedPlaylists: '{{count}} плейлистов выбрано',
|
||||
selectedAlbums: '{{count}} альбомов выбрано',
|
||||
selectedArtists: '{{count}} исполнителей выбрано',
|
||||
songInfo: 'Сведения о треке',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -137,6 +144,7 @@ export const ruTranslation = {
|
||||
goToArtist: 'Перейти к {{artist}}',
|
||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||
trackTitle: 'Название',
|
||||
trackAlbum: 'Альбом',
|
||||
trackArtist: 'Исполнитель',
|
||||
trackGenre: 'Жанр',
|
||||
trackFormat: 'Формат',
|
||||
@@ -150,6 +158,11 @@ export const ruTranslation = {
|
||||
bioClose: 'Закрыть',
|
||||
ratingLabel: 'Оценка',
|
||||
enlargeCover: 'Увеличить обложку',
|
||||
filterSongs: 'Фильтр треков…',
|
||||
sortNatural: 'По умолчанию',
|
||||
sortByTitle: 'А–Я (название)',
|
||||
sortByArtist: 'А–Я (исполнитель)',
|
||||
sortByAlbum: 'А–Я (альбом)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: 'Оценка альбома',
|
||||
@@ -201,6 +214,13 @@ export const ruTranslation = {
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Собрать микс',
|
||||
mixByTracks: 'Микс по трекам',
|
||||
mixByTracksDesc: 'Случайная подборка треков со всей медиатеки',
|
||||
mixByAlbums: 'Микс по альбомам',
|
||||
mixByAlbumsDesc: 'Случайная подборка альбомов для открытий',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Случайные альбомы',
|
||||
refresh: 'Обновить',
|
||||
@@ -223,6 +243,8 @@ export const ruTranslation = {
|
||||
title: 'Случайный микс',
|
||||
remix: 'Новый микс',
|
||||
remixTooltip: 'Подобрать другие случайные треки',
|
||||
remixGenre: 'Новый микс: {{genre}}',
|
||||
remixTooltipGenre: 'Подобрать другие треки жанра {{genre}}',
|
||||
playAll: 'Воспроизвести всё',
|
||||
trackTitle: 'Название',
|
||||
trackArtist: 'Исполнитель',
|
||||
@@ -245,6 +267,7 @@ export const ruTranslation = {
|
||||
blacklistToggle: 'Фильтр по словам',
|
||||
genreMixTitle: 'Микс по жанру',
|
||||
genreMixDesc: 'Топ-20 жанров по числу треков — нажмите, чтобы собрать микс',
|
||||
genreMixAll: 'Все треки',
|
||||
genreMixLoadMore: 'Ещё 10 жанров',
|
||||
genreMixNoGenres: 'На сервере нет данных о жанрах.',
|
||||
shuffleGenres: 'Другие жанры',
|
||||
@@ -264,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: 'Исполнители',
|
||||
@@ -290,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: 'Пароль',
|
||||
@@ -320,6 +348,7 @@ export const ruTranslation = {
|
||||
offlineTitle: 'Нет связи с сервером',
|
||||
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
|
||||
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
|
||||
offlineNoCacheBanner: 'Нет соединения с сервером — {{server}} недоступен',
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
@@ -332,6 +361,7 @@ export const ruTranslation = {
|
||||
offlineFilterPlaylists: 'Плейлисты',
|
||||
offlineFilterArtists: 'Дискографии',
|
||||
retry: 'Повторить',
|
||||
serverSettings: 'Настройки сервера',
|
||||
lastfmConnected: 'Last.fm: @{{user}}',
|
||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
||||
},
|
||||
@@ -360,6 +390,7 @@ export const ruTranslation = {
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||
bulkClear: 'Снять выделение',
|
||||
@@ -398,6 +429,7 @@ export const ruTranslation = {
|
||||
servers: 'Серверы',
|
||||
serverName: 'Название',
|
||||
serverUrl: 'Адрес',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
|
||||
serverUsername: 'Логин',
|
||||
serverPassword: 'Пароль',
|
||||
addServer: 'Добавить сервер',
|
||||
@@ -484,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:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
@@ -496,20 +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 сначала.',
|
||||
enableNeteaselyrics: 'Тексты Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда сервер и 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: 'Выбрать',
|
||||
@@ -519,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',
|
||||
@@ -589,6 +631,7 @@ export const ruTranslation = {
|
||||
shortcutSeekForward: 'Вперёд на 10 с',
|
||||
shortcutSeekBackward: 'Назад на 10 с',
|
||||
shortcutToggleQueue: 'Показать / скрыть очередь',
|
||||
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Системный полный экран',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
@@ -597,6 +640,8 @@ export const ruTranslation = {
|
||||
replayGainMode: 'Режим',
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
replayGainFallback: 'Резерв (без тегов / радио)',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
@@ -616,6 +661,10 @@ export const ruTranslation = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
fsPlayerSection: 'Полноэкранный плеер',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
@@ -805,6 +854,7 @@ export const ruTranslation = {
|
||||
genreDistribution: 'Жанры (топ-20)',
|
||||
loadMore: 'Ещё',
|
||||
statArtists: 'Исполнители',
|
||||
statArtistsTooltip: 'Только исполнители альбомов — артисты, присутствующие лишь в треках (фичеринг, гость и т. д.) без собственного альбома, не учитываются.',
|
||||
statAlbums: 'Альбомы',
|
||||
statSongs: 'Треки',
|
||||
statGenres: 'Жанры',
|
||||
@@ -928,6 +978,7 @@ export const ruTranslation = {
|
||||
titleBadge: 'Плейлист',
|
||||
refreshSuggestions: 'Обновить подборку',
|
||||
addSong: 'В плейлист',
|
||||
addSelected: 'Добавить выбранные',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
removeOffline: 'Удалить из офлайн-кэша',
|
||||
@@ -946,6 +997,33 @@ 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: 'Популярное',
|
||||
@@ -955,7 +1033,10 @@ export const ruTranslation = {
|
||||
sortMost: 'Сначала популярные',
|
||||
sortLeast: 'Сначала малоизвестные',
|
||||
loadMore: 'Загрузить больше альбомов',
|
||||
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||
noData: 'Пока нет данных о прослушивании. Начните слушать!',
|
||||
noArtists: 'Все исполнители отфильтрованы.',
|
||||
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
|
||||
filterCompilationsShort: 'Скрыть сборники',
|
||||
},
|
||||
radio: {
|
||||
title: 'Онлайн-радио',
|
||||
|
||||
+93
-14
@@ -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: '精选',
|
||||
@@ -109,6 +112,9 @@ export const zhTranslation = {
|
||||
goToArtist: '前往艺术家',
|
||||
download: '下载 (ZIP)',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
selectedPlaylists: '已选择 {{count}} 个播放列表',
|
||||
selectedAlbums: '已选择 {{count}} 个专辑',
|
||||
selectedArtists: '已选择 {{count}} 个艺术家',
|
||||
songInfo: '歌曲信息',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -135,6 +141,7 @@ export const zhTranslation = {
|
||||
goToArtist: '前往 {{artist}}',
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackAlbum: '专辑',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
@@ -148,6 +155,11 @@ export const zhTranslation = {
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
filterSongs: '筛选歌曲…',
|
||||
sortNatural: '默认顺序',
|
||||
sortByTitle: 'A–Z(标题)',
|
||||
sortByArtist: 'A–Z(艺术家)',
|
||||
sortByAlbum: 'A–Z(专辑)',
|
||||
},
|
||||
entityRating: {
|
||||
albumShort: '专辑评分',
|
||||
@@ -197,6 +209,13 @@ export const zhTranslation = {
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomLanding: {
|
||||
title: '创建混音',
|
||||
mixByTracks: '按曲目混音',
|
||||
mixByTracksDesc: '从整个媒体库随机选取曲目',
|
||||
mixByAlbums: '按专辑混音',
|
||||
mixByAlbumsDesc: '随机选取专辑,探索新音乐',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
refresh: '刷新',
|
||||
@@ -217,6 +236,8 @@ export const zhTranslation = {
|
||||
title: '随机混音',
|
||||
remix: '重新混音',
|
||||
remixTooltip: '加载新的随机歌曲',
|
||||
remixGenre: '重混 {{genre}}',
|
||||
remixTooltipGenre: '加载新的{{genre}}歌曲',
|
||||
playAll: '全部播放',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
@@ -238,6 +259,7 @@ export const zhTranslation = {
|
||||
blacklistToggle: '关键词过滤',
|
||||
genreMixTitle: '流派混音',
|
||||
genreMixDesc: '按歌曲数量排列的前 20 个流派 — 点击获取随机混音',
|
||||
genreMixAll: '所有歌曲',
|
||||
genreMixLoadMore: '加载 10 首更多',
|
||||
genreMixNoGenres: '服务器上未找到流派。',
|
||||
shuffleGenres: '显示其他流派',
|
||||
@@ -255,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: '艺术家',
|
||||
@@ -279,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: '密码',
|
||||
@@ -309,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: '会话无效 — 点击重新连接',
|
||||
},
|
||||
@@ -342,6 +371,7 @@ export const zhTranslation = {
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||
bulkClear: '取消选择',
|
||||
@@ -379,6 +409,7 @@ export const zhTranslation = {
|
||||
servers: '服务器',
|
||||
serverName: '服务器名称',
|
||||
serverUrl: '服务器地址',
|
||||
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
|
||||
serverUsername: '用户名',
|
||||
serverPassword: '密码',
|
||||
addServer: '添加服务器',
|
||||
@@ -463,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+)时启用。",
|
||||
@@ -482,6 +520,11 @@ export const zhTranslation = {
|
||||
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
|
||||
enableNeteaselyrics: '网易云音乐歌词',
|
||||
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
|
||||
lyricsSourcesTitle: '歌词来源',
|
||||
lyricsSourcesDesc: '选择要查询的歌词来源及其顺序。拖动以排序。已禁用的来源将被跳过。',
|
||||
lyricsSourceServer: '服务器',
|
||||
lyricsSourceLrclib: 'LRCLIB',
|
||||
lyricsSourceNetease: '网易云音乐',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
@@ -491,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 上的源代码',
|
||||
@@ -557,6 +599,7 @@ export const zhTranslation = {
|
||||
shortcutSeekForward: '快进 10 秒',
|
||||
shortcutSeekBackward: '快退 10 秒',
|
||||
shortcutToggleQueue: '切换队列',
|
||||
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: '全屏播放器',
|
||||
shortcutNativeFullscreen: '原生全屏',
|
||||
playbackTitle: '播放',
|
||||
@@ -565,6 +608,8 @@ export const zhTranslation = {
|
||||
replayGainMode: '模式',
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
replayGainFallback: '回退增益(无标签 / 收音机)',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
@@ -584,6 +629,10 @@ export const zhTranslation = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
fsPlayerSection: '全屏播放器',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
@@ -750,6 +799,7 @@ export const zhTranslation = {
|
||||
genreDistribution: '流派分布(前 20)',
|
||||
loadMore: '加载更多',
|
||||
statArtists: '艺术家',
|
||||
statArtistsTooltip: '仅限专辑艺术家——仅作为单曲艺术家出现(合唱、客串等)且无自己专辑的艺术家不计入此处。',
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
@@ -865,6 +915,7 @@ export const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
addSelected: '添加所选',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
removeOffline: '从离线缓存中移除',
|
||||
@@ -883,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: '最常播放',
|
||||
@@ -892,7 +968,10 @@ export const zhTranslation = {
|
||||
sortMost: '最多播放在前',
|
||||
sortLeast: '最少播放在前',
|
||||
loadMore: '加载更多专辑',
|
||||
noData: '暂无播放数据,开始聆听吧!',
|
||||
noData: '暂无播放数据。开始收听吧!',
|
||||
noArtists: '所有艺术家已过滤。',
|
||||
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
|
||||
filterCompilationsShort: '隐藏合辑',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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>
|
||||
)}
|
||||
|
||||
|
||||
+120
-6
@@ -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,6 +44,12 @@ export default function Favorites() {
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||||
const selectedIds = useSelectionStore(s => s.selectedIds);
|
||||
const inSelectMode = selectedCount > 0;
|
||||
const lastSelectedIdxRef = useRef<number | null>(null);
|
||||
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
@@ -80,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([
|
||||
@@ -173,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 (
|
||||
@@ -236,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"
|
||||
@@ -255,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); };
|
||||
@@ -267,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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+242
-44
@@ -57,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 },
|
||||
@@ -113,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);
|
||||
@@ -207,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[]>([]);
|
||||
@@ -227,6 +234,8 @@ export default function PlaylistDetail() {
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
@@ -246,7 +255,7 @@ export default function PlaylistDetail() {
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
}, [id, lastModified]);
|
||||
|
||||
// ── Suggestions ───────────────────────────────────────────────
|
||||
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
|
||||
@@ -442,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 = () => {
|
||||
@@ -460,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;
|
||||
@@ -585,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' : ''}`}
|
||||
@@ -651,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>
|
||||
)}
|
||||
|
||||
@@ -715,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
|
||||
@@ -728,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>
|
||||
@@ -738,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',
|
||||
@@ -749,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)} />
|
||||
@@ -798,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 (
|
||||
@@ -842,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))' }}>
|
||||
@@ -858,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>
|
||||
@@ -867,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 ── */}
|
||||
|
||||
+165
-52
@@ -1,12 +1,13 @@
|
||||
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 {
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
@@ -16,10 +17,13 @@ export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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('');
|
||||
@@ -27,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('');
|
||||
};
|
||||
@@ -73,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' }}>
|
||||
@@ -91,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>
|
||||
|
||||
@@ -130,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 ? (
|
||||
@@ -163,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}
|
||||
|
||||
+554
-88
@@ -5,11 +5,13 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -18,12 +20,13 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore';
|
||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
|
||||
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
@@ -71,6 +74,7 @@ const CONTRIBUTORS = [
|
||||
'Click-to-seek in synced lyrics (PR #38)',
|
||||
'Volume scroll wheel on volume slider (PR #38)',
|
||||
'Lyrics line visual states: active / completed / upcoming (PR #38)',
|
||||
'Queue auto-scroll to keep upcoming tracks in view; fixed re-renders from currentTime in QueuePanel (PR #115)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -101,6 +105,15 @@ const CONTRIBUTORS = [
|
||||
'Richer star ratings, skip threshold, and library filtering (PR #130)',
|
||||
'Statistics: scope album and song totals to selected music library (PR #138)',
|
||||
'AudioMuse-AI discovery integration for Navidrome (PR #147)',
|
||||
'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)',
|
||||
'Folder Browser: keyboard navigation, context menus, now-playing path emphasis, and adaptive column layout (PR #158)',
|
||||
'Infinite queue: artist-driven candidates via Top Songs + Similar Songs with random fallback (PR #163)',
|
||||
'Folder Browser: per-column filter with keyboard flow and Shift+Enter queue append (PR #165)',
|
||||
'Keybindings: modifier + key chords for in-app shortcuts; fixed seek ±10s units (PR #167)',
|
||||
'Statistics: genre insights scoped to music library, cached Subsonic fetches, localized duration formatting (PR #144)',
|
||||
'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
|
||||
'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
|
||||
'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +125,19 @@ const CONTRIBUTORS = [
|
||||
'Album cover art in Discord Rich Presence via iTunes API (PR #111)',
|
||||
'Tiling WM detection: hide custom TitleBar on Hyprland/Sway/i3/etc. (PR #134)',
|
||||
'Russian translation: lyricsServerFirst settings strings (PR #140)',
|
||||
'Russian translation refinements (PR #148)',
|
||||
'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
|
||||
'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
|
||||
'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'kveld9',
|
||||
since: '1.34.4',
|
||||
contributions: [
|
||||
'Spanish (es) translation — 964 strings (PR #159)',
|
||||
'Column-header sorting for albums & playlists (PR #160)',
|
||||
'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,7 +177,7 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
|
||||
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder="192.168.1.100:4533" autoComplete="off" />
|
||||
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder={t('settings.serverUrlPlaceholder')} autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
@@ -204,6 +230,125 @@ function snapHotCacheMb(v: number): number {
|
||||
return Math.round((x - 32) / 32) * 32 + 32;
|
||||
}
|
||||
|
||||
/** Makes raw ALSA device names more readable on Linux.
|
||||
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
|
||||
* e.g. "sysdefault:CARD=U192k" → "U192k"
|
||||
* "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)"
|
||||
* "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
|
||||
* "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
|
||||
* Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
|
||||
function formatAudioDeviceLabel(name: string): string {
|
||||
const cardMatch = name.match(/CARD=([^,]+)/);
|
||||
if (!cardMatch) return name;
|
||||
const card = cardMatch[1];
|
||||
const devM = name.match(/DEV=(\d+)/);
|
||||
const devNum = devM ? parseInt(devM[1], 10) : null;
|
||||
const subM = name.match(/SUBDEV=(\d+)/);
|
||||
const subNum = subM ? parseInt(subM[1], 10) : null;
|
||||
|
||||
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
|
||||
if (name.startsWith('hdmi:')) {
|
||||
const d = devNum !== null ? devNum : 0;
|
||||
return `${card} (HDMI · DEV ${d})`;
|
||||
}
|
||||
if (name.startsWith('sysdefault:')) {
|
||||
if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
|
||||
return card;
|
||||
}
|
||||
if (name.startsWith('plughw:')) {
|
||||
if (devNum !== null) {
|
||||
const sub = subNum !== null ? ` · sub ${subNum}` : '';
|
||||
return `${card} (plug · PCM ${devNum}${sub})`;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
if (name.startsWith('hw:')) {
|
||||
if (devNum !== null) {
|
||||
const sub = subNum !== null ? ` · sub ${subNum}` : '';
|
||||
return `${card} (hw · PCM ${devNum}${sub})`;
|
||||
}
|
||||
return `${card} (hw)`;
|
||||
}
|
||||
if (name.startsWith('front:')) return `${card} (Front)`;
|
||||
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
|
||||
// Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
|
||||
const iface = name.split(':')[0];
|
||||
if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
|
||||
if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
|
||||
return `${card} (${iface})`;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
|
||||
function audioDeviceDuplicateHint(raw: string): string {
|
||||
const cardM = raw.match(/CARD=([^,]+)/);
|
||||
const devM = raw.match(/DEV=(\d+)/);
|
||||
const subM = raw.match(/SUBDEV=(\d+)/);
|
||||
const iface = raw.split(':')[0] || '';
|
||||
const parts: string[] = [];
|
||||
if (iface) parts.push(iface);
|
||||
if (cardM) parts.push(cardM[1]);
|
||||
if (devM) parts.push(`PCM ${devM[1]}`);
|
||||
if (subM) parts.push(`sub ${subM[1]}`);
|
||||
if (parts.length > 1) return parts.join(' · ');
|
||||
return raw.length > 56 ? `…${raw.slice(-53)}` : raw;
|
||||
}
|
||||
|
||||
/** When several devices share the same display label, append a disambiguator. */
|
||||
function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
|
||||
if (!duplicateBase) return baseLabel;
|
||||
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
|
||||
}
|
||||
|
||||
/** cpal order is arbitrary; sort by readable label, current OS default first. */
|
||||
function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
|
||||
return [...devices].sort((a, b) => {
|
||||
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
|
||||
const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
|
||||
if (aDef !== bDef) return aDef ? -1 : 1;
|
||||
const la = formatAudioDeviceLabel(a);
|
||||
const lb = formatAudioDeviceLabel(b);
|
||||
const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
|
||||
if (byLabel !== 0) return byLabel;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAudioDeviceSelectOptions(
|
||||
devices: string[],
|
||||
defaultLabel: string,
|
||||
osDefaultDeviceId: string | null,
|
||||
osDefaultMark: string,
|
||||
pinnedDevice: string | null,
|
||||
notInListSuffix: string,
|
||||
): { value: string; label: string }[] {
|
||||
const baseLabels = devices.map(formatAudioDeviceLabel);
|
||||
const countByBase = new Map<string, number>();
|
||||
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
|
||||
const pinned = pinnedDevice?.trim() || null;
|
||||
const pinnedNotListed = !!(pinned && !devices.includes(pinned));
|
||||
const ghost: { value: string; label: string }[] = pinnedNotListed
|
||||
? (() => {
|
||||
const base = formatAudioDeviceLabel(pinned);
|
||||
let label = `${base} · ${notInListSuffix}`;
|
||||
if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
|
||||
return [{ value: pinned, label }];
|
||||
})()
|
||||
: [];
|
||||
return [
|
||||
{ value: '', label: defaultLabel },
|
||||
...ghost,
|
||||
...devices.map((d, i) => {
|
||||
const base = baseLabels[i];
|
||||
const dup = (countByBase.get(base) ?? 0) > 1;
|
||||
let label = disambiguatedAudioDeviceLabel(d, base, dup);
|
||||
if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
|
||||
return { value: d, label };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
@@ -243,6 +388,10 @@ export default function Settings() {
|
||||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||||
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
||||
const [audioDevices, setAudioDevices] = useState<string[]>([]);
|
||||
const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
|
||||
const [deviceSwitching, setDeviceSwitching] = useState(false);
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [contributorsOpen, setContributorsOpen] = useState(false);
|
||||
@@ -253,16 +402,96 @@ export default function Settings() {
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'audio') {
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
return;
|
||||
}
|
||||
if (activeTab !== 'storage') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
||||
|
||||
const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => {
|
||||
const silent = !!opts?.silent;
|
||||
if (!silent) setDevicesLoading(true);
|
||||
const listP = invoke<string[]>('audio_list_devices').catch((e) => {
|
||||
console.error(e);
|
||||
showToast(t('settings.audioOutputDeviceListError'), 5000, 'error');
|
||||
return [] as string[];
|
||||
});
|
||||
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
|
||||
Promise.all([listP, defP])
|
||||
.then(async ([devices, osDefault]) => {
|
||||
let canon: string | null = null;
|
||||
try {
|
||||
canon = await invoke<string | null>('audio_canonicalize_selected_device');
|
||||
if (canon) useAuthStore.getState().setAudioOutputDevice(canon);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const finalList = canon
|
||||
? await invoke<string[]>('audio_list_devices').catch(() => devices)
|
||||
: devices;
|
||||
const defId = osDefault ?? null;
|
||||
setAudioDevices(sortAudioDeviceIds(finalList, defId));
|
||||
setOsDefaultAudioDeviceId(defId);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!silent) setDevicesLoading(false);
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
// Load available audio output devices when Audio tab opens.
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'audio') return;
|
||||
refreshAudioDevices();
|
||||
}, [activeTab, refreshAudioDevices]);
|
||||
|
||||
// Keep device list + "current system output" mark in sync when the backend reopens the stream.
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'audio') return;
|
||||
let cancelled = false;
|
||||
const unlisteners: Array<() => void> = [];
|
||||
(async () => {
|
||||
for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) {
|
||||
const u = await listen(ev, () => {
|
||||
if (!cancelled) refreshAudioDevices({ silent: true });
|
||||
});
|
||||
if (cancelled) {
|
||||
u();
|
||||
return;
|
||||
}
|
||||
unlisteners.push(u);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const u of unlisteners) u();
|
||||
};
|
||||
}, [activeTab, refreshAudioDevices]);
|
||||
|
||||
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'audio') return;
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
const refresh = () => {
|
||||
invoke<number>('get_hot_cache_size', { customDir })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
};
|
||||
refresh();
|
||||
if (!auth.hotCacheEnabled) return;
|
||||
const interval = window.setInterval(refresh, 2000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'audio' || !auth.hotCacheEnabled) return;
|
||||
const t = window.setTimeout(() => {
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [hotCacheEntries, activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
await clearImageCache();
|
||||
@@ -452,6 +681,76 @@ export default function Settings() {
|
||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'audio' && (
|
||||
<>
|
||||
{/* Audio Output Device */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<AudioLines size={18} />
|
||||
<h2>{t('settings.audioOutputDevice')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.audioOutputDeviceDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<CustomSelect
|
||||
style={{ flex: 1 }}
|
||||
value={auth.audioOutputDevice ?? ''}
|
||||
disabled={deviceSwitching || devicesLoading}
|
||||
onChange={async (val) => {
|
||||
const device = val || null;
|
||||
setDeviceSwitching(true);
|
||||
try {
|
||||
await invoke('audio_set_device', { deviceName: device });
|
||||
auth.setAudioOutputDevice(device);
|
||||
} catch { /* device open failed — don't persist */ }
|
||||
setDeviceSwitching(false);
|
||||
}}
|
||||
options={buildAudioDeviceSelectOptions(
|
||||
audioDevices,
|
||||
t('settings.audioOutputDeviceDefault'),
|
||||
osDefaultAudioDeviceId,
|
||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||
auth.audioOutputDevice,
|
||||
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => refreshAudioDevices()}
|
||||
disabled={devicesLoading || deviceSwitching}
|
||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||
>
|
||||
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Native Hi-Res Playback */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Waves size={18} />
|
||||
<h2>{t('settings.hiResTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||
id="hires-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Equalizer */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
@@ -500,6 +799,38 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{auth.replayGainEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 160 }}>
|
||||
{t('settings.replayGainPreGain')}
|
||||
</span>
|
||||
<input
|
||||
type="range" min={0} max={6} step={0.5}
|
||||
value={auth.replayGainPreGainDb}
|
||||
onChange={e => auth.setReplayGainPreGainDb(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 160 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 40, textAlign: 'right' }}>
|
||||
{auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 160 }}>
|
||||
{t('settings.replayGainFallback')}
|
||||
</span>
|
||||
<input
|
||||
type="range" min={-6} max={0} step={0.5}
|
||||
value={auth.replayGainFallbackDb}
|
||||
onChange={e => auth.setReplayGainFallbackDb(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 160 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 40, textAlign: 'right' }}>
|
||||
{auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
@@ -740,31 +1071,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Native Hi-Res Playback */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Waves size={18} />
|
||||
<h2>{t('settings.hiResTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.enableHiRes}
|
||||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||
id="hires-enabled-toggle"
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -862,31 +1168,12 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsServerFirst')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsServerFirstDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.lyricsServerFirst')}>
|
||||
<input type="checkbox" checked={auth.lyricsServerFirst} onChange={e => auth.setLyricsServerFirst(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.enableNeteaselyrics')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableNeteaselyricsDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.enableNeteaselyrics')}>
|
||||
<input type="checkbox" checked={auth.enableNeteaselyrics} onChange={e => auth.setEnableNeteaselyrics(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Lyrics Sources */}
|
||||
<LyricsSourcesCustomizer />
|
||||
|
||||
{/* Random Mix */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
@@ -1233,6 +1520,7 @@ export default function Settings() {
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: 'es', label: t('settings.languageEs') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'nb', label: t('settings.languageNb') },
|
||||
@@ -1412,6 +1700,42 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Maximize2 size={18} />
|
||||
<h2>{t('settings.fsPlayerSection')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.fsShowArtistPortrait')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.fsShowArtistPortraitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.fsShowArtistPortrait')}>
|
||||
<input type="checkbox" checked={auth.showFsArtistPortrait} onChange={e => auth.setShowFsArtistPortrait(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.fsPortraitDim')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 36, textAlign: 'right' }}>{auth.fsPortraitDim}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={auth.fsPortraitDim}
|
||||
onChange={e => auth.setFsPortraitDim(parseInt(e.target.value, 10))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
@@ -1447,12 +1771,16 @@ export default function Settings() {
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.tabInput')}</h2>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
|
||||
onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}
|
||||
data-tooltip={t('settings.shortcutsReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
@@ -1463,6 +1791,7 @@ export default function Settings() {
|
||||
['seek-forward', t('settings.shortcutSeekForward')],
|
||||
['seek-backward', t('settings.shortcutSeekBackward')],
|
||||
['toggle-queue', t('settings.shortcutToggleQueue')],
|
||||
['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })],
|
||||
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
|
||||
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
|
||||
] as [KeyAction, string][]).map(([action, label]) => {
|
||||
@@ -1484,13 +1813,17 @@ export default function Settings() {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code !== 'Escape') {
|
||||
// unbind any existing action with this key first
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === e.code)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, e.code);
|
||||
if (e.code === 'Escape') {
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
return;
|
||||
}
|
||||
const chord = buildInAppBinding(e);
|
||||
if (!chord) return;
|
||||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||||
.find(([, c]) => c === chord)?.[0];
|
||||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||||
kb.setBinding(action, chord);
|
||||
setListeningFor(null);
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
@@ -1506,7 +1839,7 @@ export default function Settings() {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')}
|
||||
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
|
||||
</button>
|
||||
{bound && !isListening && (
|
||||
<button
|
||||
@@ -1523,6 +1856,7 @@ export default function Settings() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
@@ -1533,12 +1867,16 @@ export default function Settings() {
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
|
||||
{t('settings.globalShortcutsNote')}
|
||||
</p>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
|
||||
onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}
|
||||
data-tooltip={t('settings.shortcutsReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||||
{t('settings.shortcutsReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{([
|
||||
['play-pause', t('settings.shortcutPlayPause')],
|
||||
@@ -1606,6 +1944,7 @@ export default function Settings() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -1863,9 +2202,6 @@ export default function Settings() {
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
|
||||
{t('settings.aboutDesc')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
|
||||
{t('settings.aboutFeatures')}
|
||||
</p>
|
||||
|
||||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||||
|
||||
@@ -2007,25 +2343,27 @@ function HomeCustomizer() {
|
||||
<div className="settings-section-header">
|
||||
<LayoutGrid size={18} />
|
||||
<h2>{t('settings.homeCustomizerTitle')}</h2>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
style={{ position: 'absolute', top: -22, right: 0, fontSize: 12, color: 'var(--text-muted)', padding: '2px 4px' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.sidebarReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
|
||||
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||
{sections.map(sec => (
|
||||
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
|
||||
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -2049,6 +2387,134 @@ function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'lib
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lyrics Sources Customizer ──────────────────────────────────────────────
|
||||
|
||||
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
|
||||
server: 'settings.lyricsSourceServer',
|
||||
lrclib: 'settings.lyricsSourceLrclib',
|
||||
netease: 'settings.lyricsSourceNetease',
|
||||
};
|
||||
|
||||
type LyricsDropTarget = { idx: number; before: boolean } | null;
|
||||
|
||||
function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { onMouseDown } = useDragSource(() => ({
|
||||
data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
<span
|
||||
className="sidebar-customizer-grip"
|
||||
data-tooltip={t('settings.sidebarDrag')}
|
||||
data-tooltip-pos="right"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LyricsSourcesCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
|
||||
const setLyricsSources = useAuthStore(s => s.setLyricsSources);
|
||||
const { isDragging: isPsyDragging } = useDragDrop();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
|
||||
const dropTargetRef = useRef<LyricsDropTarget>(null);
|
||||
const sourcesRef = useRef(lyricsSources);
|
||||
sourcesRef.current = lyricsSources;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: { type?: string; index?: number };
|
||||
try { parsed = JSON.parse(detail.data as string); } catch { return; }
|
||||
if (parsed.type !== 'lyrics_source_reorder' || parsed.index == null) return;
|
||||
|
||||
const fromIdx = parsed.index;
|
||||
const target = dropTargetRef.current;
|
||||
dropTargetRef.current = null; setDropTarget(null);
|
||||
if (!target) return;
|
||||
|
||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||
|
||||
const next = [...sourcesRef.current];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
setLyricsSources(next);
|
||||
};
|
||||
el.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [setLyricsSources]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPsyDragging || !containerRef.current) return;
|
||||
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
|
||||
let target: LyricsDropTarget = null;
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.lyricsIdx);
|
||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
||||
target = { idx, before: false };
|
||||
}
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
|
||||
const toggleSource = (id: LyricsSourceId) => {
|
||||
setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lyricsSourcesTitle')}</h2>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||
{t('settings.lyricsSourcesDesc')}
|
||||
</p>
|
||||
<div className="settings-card" style={{ padding: '4px 0' }} ref={containerRef} onMouseMove={handleMouseMove}>
|
||||
{lyricsSources.map((src, i) => {
|
||||
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
|
||||
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
||||
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
||||
return (
|
||||
<div
|
||||
key={src.id}
|
||||
data-lyrics-idx={i}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<LyricsSourceGripHandle idx={i} label={label} />
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sidebar Customizer ──────────────────────────────────────────────────────
|
||||
|
||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||
|
||||
function SidebarCustomizer() {
|
||||
@@ -2297,7 +2763,7 @@ function ChangelogSection() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="changelog-list">
|
||||
{versions.map(({ version, date, body }) => (
|
||||
{versions.slice(0, 3).map(({ version, date, body }) => (
|
||||
<details key={version} className="changelog-entry" open={version === appVersion}>
|
||||
<summary className="changelog-summary">
|
||||
<span className="changelog-version">v{version}</span>
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Statistics() {
|
||||
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 },
|
||||
@@ -183,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>
|
||||
|
||||
@@ -19,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[];
|
||||
@@ -39,6 +48,8 @@ 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;
|
||||
@@ -54,7 +65,11 @@ interface AuthState {
|
||||
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;
|
||||
|
||||
@@ -62,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;
|
||||
@@ -157,6 +174,8 @@ 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;
|
||||
@@ -172,11 +191,15 @@ interface AuthState {
|
||||
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;
|
||||
@@ -244,6 +267,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: 0,
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
@@ -259,11 +284,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
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,
|
||||
@@ -353,6 +382,14 @@ 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 }),
|
||||
@@ -368,12 +405,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 }),
|
||||
@@ -522,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),
|
||||
@@ -529,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('+');
|
||||
}
|
||||
|
||||
+89
-20
@@ -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;
|
||||
@@ -125,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 };
|
||||
@@ -164,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;
|
||||
@@ -378,6 +428,8 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -523,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) => {
|
||||
@@ -679,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 },
|
||||
@@ -764,7 +819,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── playRadio ────────────────────────────────────────────────────────────
|
||||
playRadio: (station) => {
|
||||
playRadio: async (station) => {
|
||||
const { volume } = get();
|
||||
++playGeneration;
|
||||
isAudioPaused = false;
|
||||
@@ -774,9 +829,15 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
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');
|
||||
@@ -849,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) => {
|
||||
@@ -934,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) {
|
||||
@@ -966,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) => {
|
||||
@@ -1001,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; });
|
||||
@@ -1089,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);
|
||||
@@ -1305,10 +1372,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
? (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;
|
||||
|
||||
+451
-4
@@ -198,6 +198,7 @@
|
||||
|
||||
/* ─ Album Card ─ */
|
||||
.album-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -392,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 {
|
||||
@@ -518,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;
|
||||
@@ -566,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 {
|
||||
@@ -3112,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;
|
||||
@@ -3433,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);
|
||||
@@ -3462,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;
|
||||
@@ -3612,6 +3728,7 @@
|
||||
backdrop-filter: blur(16px);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
@@ -3628,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);
|
||||
@@ -3635,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 ─ */
|
||||
@@ -3853,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);
|
||||
@@ -5589,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;
|
||||
@@ -5773,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 ─ */
|
||||
@@ -5931,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);
|
||||
@@ -5943,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;
|
||||
@@ -6906,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 {
|
||||
@@ -6932,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);
|
||||
}
|
||||
@@ -6956,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;
|
||||
@@ -6976,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;
|
||||
|
||||
@@ -2306,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;
|
||||
}
|
||||
|
||||
@@ -14317,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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