mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bcef81b22 | |||
| 693766134b | |||
| 71fbc717f6 | |||
| d33c7042b6 | |||
| 61c17d2e24 | |||
| e4abaf8814 | |||
| c17fc0f6ac | |||
| 16491f6321 | |||
| d8244e0139 | |||
| 60da17f7cc | |||
| 8cc115cade | |||
| e07ef0ebf1 | |||
| 6456f13bde | |||
| a48159d302 | |||
| 2871db9a96 | |||
| 42ad24cce1 | |||
| eb747ba1ae | |||
| 6bd2bfc01c | |||
| b5751c2918 | |||
| 8c050ad297 | |||
| e2100aedc4 | |||
| aa69f360eb | |||
| ee932db8a9 | |||
| 461a759f0f | |||
| 193a37cf0c | |||
| 65a46bdd0f | |||
| 0afcc4ab68 | |||
| ab35ef5eb4 | |||
| 4ff4ea0df0 | |||
| cef2db92cb | |||
| 72e193cf2c | |||
| 6b3e809d12 | |||
| 2e5a34178b | |||
| 25537f2743 | |||
| 8b7bce5b85 | |||
| 89e8f43add | |||
| da38b411b0 | |||
| 4f2c313bb7 | |||
| c96eb0a805 | |||
| 66c0ecbc1f | |||
| 225609e93c | |||
| 4773f1ca8c | |||
| 129cd3ea23 | |||
| 98352bb656 | |||
| a2f880da0d | |||
| 95f714654d | |||
| e201bc630c | |||
| 36f0ef79fe | |||
| 939ed3d412 | |||
| bddfd45086 | |||
| 48c10e5619 |
@@ -83,10 +83,8 @@ jobs:
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
|
||||
# Re-add before the next public release.
|
||||
# - platform: 'windows-latest'
|
||||
# args: '--bundles nsis'
|
||||
- platform: 'windows-latest'
|
||||
args: '--bundles nsis'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -183,9 +181,6 @@ jobs:
|
||||
gh release upload "app-v${VERSION}" latest.json --clobber
|
||||
|
||||
build-linux:
|
||||
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
|
||||
# Re-enable by removing `if: false` before the next public release.
|
||||
if: false
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -241,11 +236,6 @@ jobs:
|
||||
#
|
||||
# The refreshed lock/hash files are committed back to `main` when they change.
|
||||
verify-nix:
|
||||
# TEMPORARILY DISABLED during the v1.34.x testing iteration (signing +
|
||||
# updater stabilisation). Remove `if: false` to re-enable — it auto-commits
|
||||
# flake.lock / npmDepsHash refreshes to main on every release, which causes
|
||||
# rebase friction while we're iterating fast on the release pipeline.
|
||||
if: false
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -291,7 +281,15 @@ jobs:
|
||||
run: nix flake update --accept-flake-config
|
||||
|
||||
- name: verify nix build + push to Cachix
|
||||
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
# The cachix-action daemon writes a post-build-hook into the user
|
||||
# nix.conf, but the Determinate Nix daemon that runs the builds reads
|
||||
# the system nix.conf — so the hook never fires and only a couple of
|
||||
# early prep paths get uploaded. Force an explicit closure push here;
|
||||
# cachix dedupes against anything already in the cache.
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
|
||||
- name: commit + push refreshed lock and hash (if changed)
|
||||
run: |
|
||||
|
||||
+101
-10
@@ -5,39 +5,130 @@ 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).
|
||||
|
||||
> **⚠️ Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
|
||||
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
|
||||
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
|
||||
>
|
||||
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
|
||||
>
|
||||
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
|
||||
|
||||
## [1.34.16] - 2026-04-18
|
||||
## [1.42.1] - 2026-04-19
|
||||
|
||||
> **🚨 Critical bug fix for Windows users.** On 1.42.0, opening the mini player on Windows could stall Tauri's event loop: the mini would appear as a blank white window, neither the main window nor the mini could be closed, and the only way out was killing the process via Task Manager. **Please update immediately if you're on Windows 1.42.0.** macOS and Linux were not affected.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI — Updater signature upload** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: tauri-action on macOS produces the `.app.tar.gz.sig` minisign signature locally but does not upload it as a release asset for cross-target builds, which caused the `latest.json` manifest generator to fail (no signature to embed). An explicit post-build step now finds the `.sig` under `src-tauri/target/*/release/bundle/macos/` and uploads it with the expected filename (`Psysonic_aarch64.app.tar.gz.sig` / `Psysonic_x64.app.tar.gz.sig`).
|
||||
|
||||
## [1.34.15] - 2026-04-18
|
||||
- **Mini player no longer hangs the app on Windows** *([@Psychotoxical](https://github.com/Psychotoxical))*: Creating the second WebView2 webview lazily from the `open_mini_player` invoke handler reliably froze the app on Windows — the mini opened blank, both windows became unresponsive, and the user had to kill the process from Task Manager. The builder + `main.minimize()` combo racing against WebView2's first paint was the trigger. The mini webview is now pre-built hidden in Tauri's `.setup()` on Windows, so the first open is a pure show/hide instead of creation + minimize. `open_mini_player` is simpler on all platforms, the minimize-main dance around show/hide is skipped on Windows, and Windows also goes back to the native window decorations (the earlier `decorations: false` mini titlebar was part of the hang surface).
|
||||
- **Mini player syncs immediately on first open** *([@Psychotoxical](https://github.com/Psychotoxical))*: With the mini pre-created on Windows, the mount-time `mini:ready` event could race past the main window's bridge listener and leave the mini without a snapshot when the user actually opened it. The mini now also re-emits `mini:ready` on every window focus, so opening the mini always triggers a fresh sync regardless of startup ordering.
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
- **Optional “Preload mini player” setting on Linux + macOS** *([@Psychotoxical](https://github.com/Psychotoxical))*: Settings → General → App behaviour. Off by default. When enabled, the mini player window is built hidden at app start so the first open is instant instead of waiting a few seconds for WebKit to boot + React to hydrate + the bridge snapshot to arrive. Costs one extra WebKit process in the background permanently (~50–100 MB RAM). Windows always preloads regardless of this toggle — it's how we work around the hang above, not an opt-in feature there.
|
||||
|
||||
## [1.34.14] - 2026-04-18
|
||||
## [1.42.0] - 2026-04-19
|
||||
|
||||
> **🛠️ Note on the 1.41.0 jump:** The 1.41.0 tag exists as an internal Draft release on GitHub — it was used to wire up and verify the Cachix substituter pipeline and never went public. **1.42.0 is the first public release after 1.40.0** and consolidates everything that was prepared for 1.41.0 plus the work landed on top in the days since.
|
||||
>
|
||||
> **❄️ Cachix is live for NixOS users.** The `psysonic.cachix.org` substituter is now actually fed by every release. Earlier 1.40.x runs were silently skipping the cache push (see *Fixed* below), so the first user to ask for a given output paid the full compile cost. Starting with 1.42.0, `nix run github:Psychotoxical/psysonic` and the NixOS module both pull the prebuilt closure straight from Cachix — no local Rust + symphonia + libopus build required.
|
||||
|
||||
### Added
|
||||
|
||||
- **Mini player — feature-complete second cut** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: The early-alpha mini from the internal 1.41.0 prep gets the rest of the workflow it was missing.
|
||||
- **Expandable queue panel** with full track list, search-style overlay scrollbar (no width-eating gutter), drag-to-reorder using the existing PsyDnD system, and a localized right-click context menu (Play now / Remove from queue / Open album / Go to artist / Favorite / Song info — all forwarded to the main window via Tauri events so the source-of-truth playerStore stays consistent).
|
||||
- **Custom in-page titlebar** on Windows + Linux with a drag region, the current track title and the queue / pin / open-main / close action icons. macOS keeps the native traffic-lights titlebar so the system look is preserved. The lower toolbar from the alpha is gone — its four buttons live in the titlebar now.
|
||||
- **Persistent geometry**: window position, expanded-queue height and queue-open state all survive an app restart. Position is written to `<app_config_dir>/mini_player_pos.json` on every move (throttled), and re-applied after each show — Linux WMs (Mutter/KWin) re-centre hidden windows on show, so without re-applying the position would be lost on the second open.
|
||||
- **User-bindable keyboard shortcut** in Settings → Shortcuts (`open-mini-player`, default unbound). The same chord toggles between main and mini regardless of which window has focus.
|
||||
- **Layout polish**: cover shrinks 112 → 84 px, the right column gets title / artist / transport in a single block, progress + toolbar take full width.
|
||||
- **Live theme / font / language sync**: changes in the main window propagate to an open mini via the shared localStorage `storage` event — no need to close + re-open the mini after rebinding a shortcut or switching themes.
|
||||
- **Always-on-top reliability fix**: WMs that silently ignore `set_always_on_top(true)` when the flag is "already true" (KWin, certain Mutter releases) get a forced false → true cycle so the constraint is actually re-evaluated. The frontend also re-asserts the pin state on mount and on focus, so the user no longer has to click the pin button twice for it to stick.
|
||||
|
||||
- **Player bar — click-to-toggle duration / remaining time** *(contributed by [@kveld9](https://github.com/kveld9), PR [#212](https://github.com/Psychotoxical/psysonic/pull/212))*: Click the time read-out in the player bar to swap between total duration (`3:45`) and remaining time (`-2:34`). Updates live, persisted to `themeStore.showRemainingTime`. A small swap icon (⇄) and hover highlight signal the interaction.
|
||||
|
||||
- **Queue — ReplayGain in tech strip, expandable badge** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), originally by [@cucadmuh](https://github.com/cucadmuh) in PRs [#196](https://github.com/Psychotoxical/psysonic/pull/196) / [#201](https://github.com/Psychotoxical/psysonic/pull/201) — UX iteration by [@Psychotoxical](https://github.com/Psychotoxical) on cucadmuh's feedback)*: Tracks with ReplayGain metadata now show a small `RG ⌄` pill at the end of the codec/bitrate/sample-rate strip. Hover reveals the values via tooltip; click expands a second line ("ReplayGain · T -8.9 dB · A -11.0 dB · Peak 0.998") that is persisted across sessions. Hides itself for tracks without RG metadata.
|
||||
|
||||
- **Changelog — sidebar banner + dedicated `/whats-new` page** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The auto-popup modal that nagged the user on first launch after each update is replaced by a discreet sidebar banner. Clicking it opens a full `/whats-new` page that renders the latest CHANGELOG section in app — no separate Markdown viewer, no broken links to GitHub.
|
||||
|
||||
- **Favorites — genre column + Top Favorite Artists row** *(Issue [#87](https://github.com/Psychotoxical/psysonic/issues/87), by [@Psychotoxical](https://github.com/Psychotoxical))*: The Favorites tracklist now has a toggleable Genre column (alongside the existing Album column and multi-genre filter). A new horizontally scrolling "Top Favorite Artists" row sits between Radio Stations and Songs, aggregated from starred tracks and sorted by star count. Clicking an artist card narrows the song list to that artist.
|
||||
|
||||
- **Compilation filter on All Albums** *(Issue [#65](https://github.com/Psychotoxical/psysonic/issues/65), by [@Psychotoxical](https://github.com/Psychotoxical))*: A tri-state toggle in the Albums page header (All / Only compilations / Hide compilations) that reads the OpenSubsonic `isCompilation` tag exposed by Navidrome 0.61+. Client-side filter, no additional server calls. Translated into all 8 supported locales.
|
||||
|
||||
- **Sticky header on Albums, New Releases, Artists** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The header row with search/sort/genre/year controls now pins to the top while scrolling, so filters stay reachable without jumping back up. Works the same on all three browse pages.
|
||||
|
||||
- **Device Sync — album artist on both panels** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Album entries in both the library (left) and on-device (right) panels now display `Album · Artist` inline, so sampler discs and self-titled albums are no longer guesswork. Playlists unchanged.
|
||||
|
||||
- **NixOS — first-class flake install guide** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PRs [#209](https://github.com/Psychotoxical/psysonic/pull/209) / [#210](https://github.com/Psychotoxical/psysonic/pull/210))*: A new top-level `nixos-install.md` walks through adding Psysonic as a flake input, installing via `environment.systemPackages` / `home.packages`, and wiring up the public `psysonic.cachix.org` substituter so every NixOS user pulls prebuilt binaries. README links to it directly.
|
||||
|
||||
- **README — AppImage in the Linux install options + Cachix badge** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Linux install section now lists AppImage alongside `.deb`, `.rpm`, AUR and Nix flakes. A Cachix badge on the README header signals that NixOS users get prebuilt binaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Genre filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The inline tagbox + dropdown (capped at 60 entries, ate header space when expanded) is replaced by a compact button that opens a portal-rendered popover with a search field and the full scrollable list of genres. Selected genres sort to the top. Used on Albums, New Releases, Random Albums and Favorites.
|
||||
|
||||
- **Year filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The From/To number inputs in the Albums header became a single button with a popover mirroring the genre filter pattern. When the filter is active, the button shows the range (e.g. `2020–2024`) in accent colour.
|
||||
|
||||
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`A–Z (Album)`, `A–Z (Artist)`) collapse into one dropdown button showing the current choice. Generic `SortDropdown` component, reusable for other pages.
|
||||
|
||||
- **Device Sync — album/playlist meta inline** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `BrowserRow` renders secondary info inline with a `·` separator in muted colour instead of a separate right-aligned column, matching the on-device panel's format.
|
||||
|
||||
- **README — Arch/AUR fold-up** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Arch / AUR install instructions are folded into the Linux install section so the README stops scrolling forever.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Player bar — black-flash on WebKitGTK** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Linux users occasionally saw the entire player bar paint fully black for one frame when an unrelated layer elsewhere on the page invalidated. `contain: layout paint` makes the bar its own paint boundary so it can no longer be pulled into a surrounding dirty rect. No-op on platforms that don't exhibit the flash (Wayland-with-GPU, Chromium webviews on Windows / macOS).
|
||||
|
||||
- **Player bar — time-toggle tooltip uses the in-app TooltipPortal** *(follow-up to PR [#212](https://github.com/Psychotoxical/psysonic/pull/212), by [@Psychotoxical](https://github.com/Psychotoxical))*: The new time-swap control was rendering the native browser `title=` tooltip (unstyled OS popup, ignored by every other control). Switched to `data-tooltip="…"` so it matches every other player-bar tooltip.
|
||||
|
||||
- **Fullscreen player — lyrics menu toggle + readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Re-clicking the mic icon now actually closes the lyrics settings panel instead of the outside-click handler closing it and the click re-opening it — the trigger button is excluded from the outside-check. The panel itself is now a solid surface (no backdrop blur, near-opaque background, higher-contrast button text) so settings remain readable over the busy fullscreen background.
|
||||
|
||||
- **i18n — ArtistCardLocal album count** *(contributed by [@cucadmuh](https://github.com/cucadmuh))*: Local artist cards were rendering the album count with hardcoded German (`Album` / `Alben`). Switched to the existing plural-aware `artists.albumCount` key which already covers all 8 locales including Russian Slavic plurals.
|
||||
|
||||
- **Release CI — Cachix never receiving the psysonic closure** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `cachix-action` installs its post-build hook via `NIX_USER_CONF_FILES`, but the Determinate Nix daemon that runs the actual builds reads the system nix.conf — so the hook never fired. Only a couple of early prep paths ever reached the cache, never the compiled `psysonic` output. The release workflow now pushes the full closure explicitly after `nix build`; Cachix dedupes against paths already present, so redundancy is cheap.
|
||||
|
||||
### Contributors
|
||||
|
||||
- [@kveld9](https://github.com/kveld9) — click-to-toggle duration / remaining time in the player bar.
|
||||
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal, ReplayGain UX feedback that drove the expandable badge, NixOS install guide, README polish.
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] - 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
|
||||
|
||||
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Install now** in the notification modal downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper re-approval, no manual DMG handling. The modal shows trust badges ("Notarized by Apple" + "Signature verified"), a 3-second restart countdown after install with a manual "Restart now" option, and hides redundant buttons during each download/install phase. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
|
||||
|
||||
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Device Sync — fixed naming scheme + playlist folders** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The user-configurable filename template is gone. Every sync now writes files under a single, non-negotiable scheme:
|
||||
- Album / artist sources: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
|
||||
- Playlist sources: `Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}` plus a self-contained `.m3u8` that references sibling filenames.
|
||||
|
||||
**Why:** different OSes normalised separators and special characters differently, so the same library synced from macOS and then plugged into a Windows machine appeared "different" and re-downloaded every album. The fixed scheme ends that forever.
|
||||
|
||||
**Playlist folders instead of the album tree:** playlists used to be scattered across the album structure as `.m3u8` references. For playlists with 40 artists that meant 40 new folders on the stick. Now every playlist is one self-contained folder; the `.m3u8` sits inside it and references siblings, so you can copy the whole folder anywhere.
|
||||
|
||||
**Migration for existing sticks:** a "Reorganize existing files…" button on the Device Sync page reads the legacy template from the v1 manifest, computes per-track rename pairs, detects collisions, and executes atomic `fs::rename`s. Empty directories left behind are cleaned up automatically. Playlist tracks synced under the old scheme are left for the next sync to re-download into the new playlist folder, rather than being force-moved.
|
||||
|
||||
**Album-Artist fallback:** libraries without an albumArtist tag fall back to the track artist — "Unknown Artist" is only ever a last-resort placeholder.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
|
||||
|
||||
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
|
||||
|
||||
### Changed
|
||||
### Contributors
|
||||
|
||||
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
|
||||
- **PR [#205](https://github.com/Psychotoxical/psysonic/pull/205)** — Apple Music-style scrolling lyrics with spring-physics scroll, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#206](https://github.com/Psychotoxical/psysonic/pull/206)** — Golos Text + Unbounded fonts with Cyrillic support, by [@kilyabin](https://github.com/kilyabin).
|
||||
- **PR [#207](https://github.com/Psychotoxical/psysonic/pull/207)** — WebKitGTK wheel scroll mode toggle, by [@cucadmuh](https://github.com/cucadmuh).
|
||||
|
||||
All three now credited in Settings → About.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
|
||||
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
<a href="https://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +50,8 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
|
||||
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
|
||||
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
|
||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm, NixOS flake).
|
||||
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
@@ -74,29 +76,9 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
|
||||
**Manual Installation:**
|
||||
- **Ubuntu / Debian**: `.deb` from GitHub Releases
|
||||
- **Fedora / RHEL**: `.rpm` from GitHub Releases
|
||||
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
|
||||
|
||||
### 🍎 macOS
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
|
||||
> [!WARNING]
|
||||
> **Gatekeeper Note:**
|
||||
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
|
||||
> ```sh
|
||||
> xattr -cr /Applications/Psysonic.app
|
||||
> ```
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
**Arch Linux (AUR):**
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
@@ -106,6 +88,25 @@ Psysonic is available in the **AUR** in two versions. Choose the one that best f
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
**❄️ NixOS (flakes):**
|
||||
- `nix run github:Psychotoxical/psysonic` — one-shot launch
|
||||
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
|
||||
|
||||
### 🍎 macOS
|
||||
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
|
||||
|
||||
> [!NOTE]
|
||||
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
|
||||
|
||||
### 🪟 Windows
|
||||
|
||||
- **Windows**: `.exe` (NSIS installer)
|
||||
|
||||
> [!WARNING]
|
||||
> **SmartScreen Note:**
|
||||
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
1. Download and install Psysonic.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
|
||||
"npmDepsHash": "sha256-5YAQ2PqxJtD7+adecF++swOHVKNstEkyRQeiBrP2lvA="
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Installing Psysonic on NixOS (flake)
|
||||
|
||||
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Flakes** enabled (e.g. in `configuration.nix`):
|
||||
|
||||
```nix
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
```
|
||||
|
||||
## Binary cache (Cachix)
|
||||
|
||||
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
|
||||
|
||||
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
|
||||
- **Substituter URL:** `https://psysonic.cachix.org`
|
||||
- **Public key** (trust this only if it matches what you expect from the cache owners):
|
||||
|
||||
```text
|
||||
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
|
||||
```
|
||||
|
||||
### NixOS (`configuration.nix` or a flake module)
|
||||
|
||||
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
|
||||
|
||||
```nix
|
||||
{
|
||||
nix.settings = {
|
||||
substituters = [
|
||||
"https://psysonic.cachix.org"
|
||||
"https://cache.nixos.org/"
|
||||
];
|
||||
trusted-public-keys = [
|
||||
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
|
||||
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
|
||||
|
||||
## Install on NixOS (flake configuration)
|
||||
|
||||
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
|
||||
|
||||
### Example: top-level `flake.nix` + `nixosConfigurations`
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
psysonic.url = "github:Psychotoxical/psysonic";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }@inputs: let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{
|
||||
environment.systemPackages = [
|
||||
inputs.psysonic.packages.${system}.psysonic
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
|
||||
|
||||
```nix
|
||||
environment.systemPackages = with pkgs; [
|
||||
# …
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
### Pinning a revision or tag
|
||||
|
||||
Follow **`main`** (above) to track the moving branch, or pin for reproducibility:
|
||||
|
||||
```nix
|
||||
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.34.13"; # example: release tag
|
||||
```
|
||||
|
||||
Use a tag or commit SHA that exists on GitHub; the release workflow keeps **`flake.lock`** and **`nix/upstream-sources.json`** (`npmDepsHash`) in sync on tagged releases.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
- **NixOS flake host**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#my-host
|
||||
```
|
||||
|
||||
- **Home Manager** (if used separately)
|
||||
|
||||
```bash
|
||||
home-manager switch --flake .#my-user@my-host
|
||||
```
|
||||
|
||||
## Home Manager
|
||||
|
||||
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
|
||||
|
||||
```nix
|
||||
home.packages = [
|
||||
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
|
||||
];
|
||||
```
|
||||
|
||||
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
## Troubleshooting (Linux / WebKit)
|
||||
|
||||
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
|
||||
|
||||
## More detail in-repo
|
||||
|
||||
- **`flake.nix`** — package outputs, `devShell`, supported systems (see comments there for `nix build` / `nix develop`).
|
||||
- **`nix/psysonic.nix`** — how the app is built from this source tree.
|
||||
- **`.github/workflows/release.yml`** — `verify-nix` job: refreshes lock/npm hash and pushes store paths to Cachix on release tags.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.41.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.34.13",
|
||||
"version": "1.41.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.19",
|
||||
"version": "1.42.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.12
|
||||
pkgver=1.42.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+17
-11
@@ -3115,9 +3115,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
version = "5.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -3653,7 +3653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.19"
|
||||
version = "1.42.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
@@ -5971,9 +5971,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
@@ -6186,11 +6186,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6199,7 +6199,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6339,9 +6339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
@@ -7139,6 +7139,12 @@ dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.19"
|
||||
version = "1.42.1"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Psysonic",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "mini"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
@@ -36,6 +36,7 @@
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
|
||||
@@ -342,7 +342,7 @@ impl Device {
|
||||
/// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it.
|
||||
fn ensure_future_audio_client(
|
||||
&self,
|
||||
) -> Result<MutexGuard<Option<IAudioClientWrapper>>, windows::core::Error> {
|
||||
) -> Result<MutexGuard<'_, Option<IAudioClientWrapper>>, windows::core::Error> {
|
||||
let mut lock = self.future_audio_client.lock().unwrap();
|
||||
if lock.is_some() {
|
||||
return Ok(lock);
|
||||
|
||||
+625
-84
@@ -13,9 +13,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager,
|
||||
};
|
||||
// MouseButtonState is only matched on non-Windows targets — on Windows the
|
||||
// tray uses DoubleClick which doesn't carry a button_state.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use tauri::tray::MouseButtonState;
|
||||
|
||||
/// Tracks which user-configured shortcuts are currently registered (shortcut_str → action).
|
||||
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
|
||||
@@ -1499,9 +1503,15 @@ fn get_removable_drives() -> Vec<RemovableDrive> {
|
||||
/// The file records which sources (albums/playlists/artists) are synced to this
|
||||
/// device so that another machine can pick them up without relying on localStorage.
|
||||
#[tauri::command]
|
||||
fn write_device_manifest(dest_dir: String, sources: serde_json::Value, filename_template: String) -> Result<(), String> {
|
||||
fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> {
|
||||
let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json");
|
||||
let payload = serde_json::json!({ "version": 1, "sources": sources, "filenameTemplate": filename_template });
|
||||
// Manifest v2: fixed "{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}" schema,
|
||||
// no user-configurable filename template. Readers still accept v1 manifests.
|
||||
let payload = serde_json::json!({
|
||||
"version": 2,
|
||||
"schema": "fixed-v1",
|
||||
"sources": sources
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1515,6 +1525,138 @@ fn read_device_manifest(dest_dir: String) -> Option<serde_json::Value> {
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
/// Per-entry result for `rename_device_files`.
|
||||
#[derive(serde::Serialize)]
|
||||
struct RenameResult {
|
||||
#[serde(rename = "oldPath")]
|
||||
old_path: String,
|
||||
#[serde(rename = "newPath")]
|
||||
new_path: String,
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Atomically renames files on the device from their old path to the new fixed-
|
||||
/// schema path. Intended for the migration flow when switching away from the
|
||||
/// user-configurable template. All paths are relative to `target_dir`.
|
||||
///
|
||||
/// After renaming, removes any directories left empty under `target_dir`
|
||||
/// (so stale `{OldArtist}/{OldAlbum}/` trees don't linger).
|
||||
///
|
||||
/// Returns a per-entry result so the UI can show which renames succeeded
|
||||
/// and which failed. Does not roll back on partial failure — each `fs::rename`
|
||||
/// is atomic, so nothing can be half-renamed.
|
||||
#[tauri::command]
|
||||
fn rename_device_files(
|
||||
target_dir: String,
|
||||
pairs: Vec<(String, String)>,
|
||||
) -> Result<Vec<RenameResult>, String> {
|
||||
let root = std::path::PathBuf::from(&target_dir);
|
||||
if !root.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
if !is_path_on_mounted_volume(&root) {
|
||||
return Err("NOT_MOUNTED_VOLUME".to_string());
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(pairs.len());
|
||||
for (old_rel, new_rel) in pairs {
|
||||
let old_abs = root.join(&old_rel);
|
||||
let new_abs = root.join(&new_rel);
|
||||
|
||||
let entry = if old_rel == new_rel {
|
||||
// Nothing to do, count as success so the UI can show "already correct".
|
||||
RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None }
|
||||
} else if !old_abs.exists() {
|
||||
RenameResult {
|
||||
old_path: old_rel, new_path: new_rel,
|
||||
ok: false, error: Some("source not found".to_string()),
|
||||
}
|
||||
} else if new_abs.exists() {
|
||||
RenameResult {
|
||||
old_path: old_rel, new_path: new_rel,
|
||||
ok: false, error: Some("target already exists".to_string()),
|
||||
}
|
||||
} else {
|
||||
// Ensure target parent exists.
|
||||
if let Some(parent) = new_abs.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
results.push(RenameResult {
|
||||
old_path: old_rel, new_path: new_rel,
|
||||
ok: false, error: Some(format!("mkdir: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match std::fs::rename(&old_abs, &new_abs) {
|
||||
Ok(_) => RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None },
|
||||
Err(e) => RenameResult {
|
||||
old_path: old_rel, new_path: new_rel,
|
||||
ok: false, error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
};
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
// Clean up directories emptied by the renames. Walk depth-first and remove
|
||||
// any dir whose only remaining contents were the files we moved out.
|
||||
fn remove_empty_dirs(dir: &std::path::Path, root: &std::path::Path) {
|
||||
if dir == root { return; }
|
||||
let rd = match std::fs::read_dir(dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut empty = true;
|
||||
let mut children: Vec<std::path::PathBuf> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() { children.push(p); } else { empty = false; }
|
||||
}
|
||||
for child in children {
|
||||
remove_empty_dirs(&child, root);
|
||||
}
|
||||
// Re-check after recursion cleared subdirs.
|
||||
let still_empty = std::fs::read_dir(dir).map(|r| r.count() == 0).unwrap_or(false);
|
||||
if empty && still_empty {
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
remove_empty_dirs(&root, &root);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Writes an Extended-M3U playlist at `{dest_dir}/Playlists/{name}/{name}.m3u8`.
|
||||
/// References are sibling filenames (just `01 - Artist - Title.ext`) so the
|
||||
/// playlist is self-contained — moving/copying the folder anywhere keeps it
|
||||
/// working. Tracks are expected to be in playlist order (index starts at 1).
|
||||
#[tauri::command]
|
||||
fn write_playlist_m3u8(
|
||||
dest_dir: String,
|
||||
playlist_name: String,
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
) -> Result<(), String> {
|
||||
let safe_name = sanitize_or(&playlist_name, "Unnamed Playlist");
|
||||
let playlist_dir = std::path::Path::new(&dest_dir).join("Playlists").join(&safe_name);
|
||||
std::fs::create_dir_all(&playlist_dir).map_err(|e| e.to_string())?;
|
||||
let file_path = playlist_dir.join(format!("{}.m3u8", safe_name));
|
||||
|
||||
let mut body = String::from("#EXTM3U\n");
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let idx = (i as u32) + 1;
|
||||
let duration = track.duration.map(|d| d as i64).unwrap_or(-1);
|
||||
let display_artist = if track.artist.trim().is_empty() { &track.album_artist[..] } else { &track.artist[..] };
|
||||
let title = track.title.trim();
|
||||
body.push_str(&format!("#EXTINF:{},{} - {}\n", duration, display_artist.trim(), title));
|
||||
// Sibling filename — same shape as build_track_path's playlist branch.
|
||||
let artist_safe = sanitize_or(display_artist, "Unknown Artist");
|
||||
let title_safe = sanitize_or(title, "Unknown Title");
|
||||
body.push_str(&format!("{:02} - {} - {}.{}\n", idx, artist_safe, title_safe, track.suffix));
|
||||
}
|
||||
std::fs::write(&file_path, body).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Checks whether `path` sits on top of an active mount point (i.e. not the root
|
||||
/// filesystem). This prevents accidentally writing to `/media/usb` after the
|
||||
/// USB drive has been unmounted — at that point the path would fall through to `/`
|
||||
@@ -1556,14 +1698,28 @@ struct TrackSyncInfo {
|
||||
id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
/// Track artist — used in Extended M3U (#EXTINF) entries so playlists display
|
||||
/// the actual performer rather than the album artist.
|
||||
artist: String,
|
||||
/// Album artist — used for the top-level folder so compilation albums stay together.
|
||||
/// Falls back to `artist` in the frontend when the server has no albumArtist tag.
|
||||
#[serde(rename = "albumArtist")]
|
||||
album_artist: String,
|
||||
album: String,
|
||||
title: String,
|
||||
#[serde(rename = "trackNumber")]
|
||||
track_number: Option<u32>,
|
||||
#[serde(rename = "discNumber")]
|
||||
disc_number: Option<u32>,
|
||||
year: Option<u32>,
|
||||
/// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries.
|
||||
#[serde(default)]
|
||||
duration: Option<u32>,
|
||||
/// When set, the track belongs to a playlist source and is placed under
|
||||
/// `Playlists/{name}/` with `playlist_index` as its filename prefix.
|
||||
/// Same track synced from both an album and a playlist source ends up twice
|
||||
/// on the device — once in the album tree, once in the playlist folder.
|
||||
#[serde(default, rename = "playlistName")]
|
||||
playlist_name: Option<String>,
|
||||
#[serde(default, rename = "playlistIndex")]
|
||||
playlist_index: Option<u32>,
|
||||
}
|
||||
|
||||
/// Summary returned by `sync_batch_to_device` after all tracks are processed.
|
||||
@@ -1581,8 +1737,9 @@ struct SyncTrackResult {
|
||||
}
|
||||
|
||||
/// Replaces characters that are invalid in file/directory names on Windows and
|
||||
/// most Unix filesystems with an underscore. Also trims leading/trailing dots
|
||||
/// and spaces which cause issues on Windows.
|
||||
/// most Unix filesystems with an underscore, and trims leading/trailing dots and
|
||||
/// spaces which cause issues on Windows. Underscore (not deletion) so that "AC/DC"
|
||||
/// and "ACDC" don't collapse into the same folder.
|
||||
fn sanitize_path_component(s: &str) -> String {
|
||||
const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let sanitized: String = s
|
||||
@@ -1592,31 +1749,37 @@ fn sanitize_path_component(s: &str) -> String {
|
||||
sanitized.trim_matches(|c| c == '.' || c == ' ').to_string()
|
||||
}
|
||||
|
||||
/// Evaluates `template` by substituting `{artist}`, `{album}`, `{title}`,
|
||||
/// `{track_number}`, `{disc_number}`, `{year}` with sanitized values from `track`.
|
||||
fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String {
|
||||
let track_number = track.track_number
|
||||
.map(|n| format!("{:02}", n))
|
||||
.unwrap_or_default();
|
||||
let disc_number = track.disc_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let year = track.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_default();
|
||||
/// Sanitize and replace empty results with a placeholder — prevents paths like
|
||||
/// `//01 - .flac` when metadata is missing.
|
||||
fn sanitize_or(s: &str, fallback: &str) -> String {
|
||||
let cleaned = sanitize_path_component(s);
|
||||
if cleaned.is_empty() { fallback.to_string() } else { cleaned }
|
||||
}
|
||||
|
||||
let result = template
|
||||
.replace("{artist}", &sanitize_path_component(&track.artist))
|
||||
.replace("{album}", &sanitize_path_component(&track.album))
|
||||
.replace("{title}", &sanitize_path_component(&track.title))
|
||||
.replace("{track_number}", &track_number)
|
||||
.replace("{disc_number}", &disc_number)
|
||||
.replace("{year}", &year);
|
||||
// Normalize to the OS path separator so compute_sync_paths and list_device_dir_files
|
||||
// produce identical strings for Set comparison.
|
||||
/// Builds the fixed device path for a track. When the track carries a playlist
|
||||
/// context it goes into the playlist folder, otherwise into the album tree.
|
||||
///
|
||||
/// Album-tree: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
|
||||
/// Playlist: `Playlists/{PlaylistName}/{PlaylistIndex:02d} - {Artist} - {Title}.{ext}`
|
||||
fn build_track_path(track: &TrackSyncInfo) -> String {
|
||||
let relative = match (&track.playlist_name, track.playlist_index) {
|
||||
(Some(name), Some(idx)) => {
|
||||
let playlist = sanitize_or(name, "Unnamed Playlist");
|
||||
let artist = sanitize_or(&track.artist, "Unknown Artist");
|
||||
let title = sanitize_or(&track.title, "Unknown Title");
|
||||
format!("Playlists/{}/{:02} - {} - {}", playlist, idx, artist, title)
|
||||
}
|
||||
_ => {
|
||||
let album_artist = sanitize_or(&track.album_artist, "Unknown Artist");
|
||||
let album = sanitize_or(&track.album, "Unknown Album");
|
||||
let title = sanitize_or(&track.title, "Unknown Title");
|
||||
let track_num = track.track_number.map(|n| format!("{:02}", n)).unwrap_or_else(|| "00".to_string());
|
||||
format!("{}/{}/{} - {}", album_artist, album, track_num, title)
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = result.replace('/', "\\");
|
||||
result
|
||||
let relative = relative.replace('/', "\\");
|
||||
relative
|
||||
}
|
||||
|
||||
/// Downloads a single track to a USB/SD device using the configured filename template.
|
||||
@@ -1625,11 +1788,10 @@ fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String {
|
||||
async fn sync_track_to_device(
|
||||
track: TrackSyncInfo,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
job_id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncTrackResult, String> {
|
||||
let relative = apply_device_sync_template(&template, &track);
|
||||
let relative = build_track_path(&track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
let dest_path = std::path::Path::new(&dest_dir).join(&file_name);
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
@@ -1679,16 +1841,12 @@ async fn sync_track_to_device(
|
||||
Ok(SyncTrackResult { path: path_str, skipped: false })
|
||||
}
|
||||
|
||||
/// Computes the expected file paths for a batch of tracks using the given template,
|
||||
/// without downloading anything. Used by the cleanup flow to find orphans.
|
||||
/// Computes the expected file paths for a batch of tracks under the fixed schema.
|
||||
/// Used by the cleanup flow to find orphans.
|
||||
#[tauri::command]
|
||||
fn compute_sync_paths(
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
) -> Vec<String> {
|
||||
fn compute_sync_paths(tracks: Vec<TrackSyncInfo>, dest_dir: String) -> Vec<String> {
|
||||
tracks.iter().map(|track| {
|
||||
let relative = apply_device_sync_template(&template, track);
|
||||
let relative = build_track_path(track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
std::path::Path::new(&dest_dir)
|
||||
.join(&file_name)
|
||||
@@ -1771,11 +1929,15 @@ struct SubsonicAuthPayload {
|
||||
f: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct DeviceSyncSourcePayload {
|
||||
#[serde(rename = "type")]
|
||||
source_type: String,
|
||||
id: String,
|
||||
/// Playlist display name — only present for playlist sources, used when
|
||||
/// computing the playlist-folder path on the device.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -1831,7 +1993,6 @@ async fn calculate_sync_payload(
|
||||
deletion_ids: Vec<String>,
|
||||
auth: SubsonicAuthPayload,
|
||||
target_dir: String,
|
||||
template: String,
|
||||
) -> Result<SyncDeltaResult, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
@@ -1853,14 +2014,15 @@ async fn calculate_sync_payload(
|
||||
}
|
||||
}
|
||||
|
||||
let mut handles = Vec::new();
|
||||
let mut handles: Vec<(DeviceSyncSourcePayload, tokio::task::JoinHandle<Vec<serde_json::Value>>)> = Vec::new();
|
||||
for source in add_sources {
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(),
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let source_snapshot = source.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut res_tracks = Vec::new();
|
||||
if source.source_type == "album" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
@@ -1887,9 +2049,10 @@ async fn calculate_sync_payload(
|
||||
}
|
||||
}
|
||||
res_tracks
|
||||
}));
|
||||
});
|
||||
handles.push((source_snapshot, handle));
|
||||
}
|
||||
|
||||
|
||||
let mut del_handles = Vec::new();
|
||||
for source in del_sources {
|
||||
let auth_clone = SubsonicAuthPayload {
|
||||
@@ -1908,39 +2071,65 @@ async fn calculate_sync_payload(
|
||||
}));
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for handle in handles {
|
||||
// Dedup key is (source_id, track_id) rather than just track_id — a track
|
||||
// appearing in both an album and a playlist needs to end up on the device
|
||||
// in both locations (album tree + playlist folder).
|
||||
let mut seen_by_source: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
|
||||
for (source, handle) in handles {
|
||||
if let Ok(ts) = handle.await {
|
||||
let is_playlist = source.source_type == "playlist";
|
||||
let mut playlist_position: u32 = 0;
|
||||
for track in ts {
|
||||
if let Some(tid) = track.get("id").and_then(|i| i.as_str()) {
|
||||
if !seen.contains(tid) {
|
||||
seen.insert(tid.to_string());
|
||||
// Build the expected path and skip files already present on device.
|
||||
let already_exists = {
|
||||
let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3");
|
||||
let sync_info = TrackSyncInfo {
|
||||
id: tid.to_string(),
|
||||
url: String::new(),
|
||||
suffix: suffix.to_string(),
|
||||
artist: track.get("artist").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
disc_number: track.get("discNumber").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
year: track.get("year").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
};
|
||||
let relative = apply_device_sync_template(&template, &sync_info);
|
||||
let file_name = format!("{}.{}", relative, suffix);
|
||||
std::path::Path::new(&target_dir).join(&file_name).exists()
|
||||
let key = (source.id.clone(), tid.to_string());
|
||||
if seen_by_source.contains(&key) { continue; }
|
||||
seen_by_source.insert(key);
|
||||
if is_playlist { playlist_position += 1; }
|
||||
let pl_name = if is_playlist { source.name.clone() } else { None };
|
||||
let pl_idx = if is_playlist { Some(playlist_position) } else { None };
|
||||
|
||||
let already_exists = {
|
||||
let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3");
|
||||
let artist_raw = track.get("artist").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let album_artist = track.get("albumArtist")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(artist_raw);
|
||||
let sync_info = TrackSyncInfo {
|
||||
id: tid.to_string(),
|
||||
url: String::new(),
|
||||
suffix: suffix.to_string(),
|
||||
artist: artist_raw.to_string(),
|
||||
album_artist: album_artist.to_string(),
|
||||
album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
duration: track.get("duration").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
playlist_name: pl_name.clone(),
|
||||
playlist_index: pl_idx,
|
||||
};
|
||||
if !already_exists {
|
||||
add_count += 1;
|
||||
let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| {
|
||||
track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8
|
||||
});
|
||||
add_bytes += size;
|
||||
sync_tracks.push(track);
|
||||
let relative = build_track_path(&sync_info);
|
||||
let file_name = format!("{}.{}", relative, suffix);
|
||||
std::path::Path::new(&target_dir).join(&file_name).exists()
|
||||
};
|
||||
if !already_exists {
|
||||
add_count += 1;
|
||||
let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| {
|
||||
track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8
|
||||
});
|
||||
add_bytes += size;
|
||||
// Embed playlist context in the track JSON so the frontend
|
||||
// can pass it back to sync_batch_to_device without re-computing it.
|
||||
let mut track_with_ctx = track.clone();
|
||||
if let Some(obj) = track_with_ctx.as_object_mut() {
|
||||
if let Some(name) = &pl_name {
|
||||
obj.insert("_playlistName".to_string(), serde_json::Value::String(name.clone()));
|
||||
}
|
||||
if let Some(idx) = pl_idx {
|
||||
obj.insert("_playlistIndex".to_string(), serde_json::Value::Number(idx.into()));
|
||||
}
|
||||
}
|
||||
sync_tracks.push(track_with_ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1991,7 +2180,6 @@ fn cancel_device_sync(job_id: String, app: tauri::AppHandle) {
|
||||
async fn sync_batch_to_device(
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
job_id: String,
|
||||
expected_bytes: u64,
|
||||
app: tauri::AppHandle,
|
||||
@@ -2057,7 +2245,6 @@ async fn sync_batch_to_device(
|
||||
let cli = client.clone();
|
||||
let app2 = app.clone();
|
||||
let job = job_id.clone();
|
||||
let tmpl = template.clone();
|
||||
let dest = dest_dir.clone();
|
||||
let d = done.clone();
|
||||
let s = skipped.clone();
|
||||
@@ -2071,7 +2258,7 @@ async fn sync_batch_to_device(
|
||||
// Bail out if cancelled while waiting in the semaphore queue.
|
||||
if cancel.load(Ordering::Relaxed) { return; }
|
||||
|
||||
let relative = apply_device_sync_template(&tmpl, &track);
|
||||
let relative = build_track_path(&track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
let dest_path = std::path::Path::new(&dest).join(&file_name);
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
@@ -2249,11 +2436,26 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event {
|
||||
// Windows fires a Click on *every* half of a double-click, so a
|
||||
// double-click would toggle the window visibility twice and end up
|
||||
// back where it started (the bug #cucadmuh reported). Switch to the
|
||||
// Windows-only DoubleClick event there and ignore single clicks;
|
||||
// that matches the standard Windows tray convention (Discord, etc).
|
||||
#[cfg(target_os = "windows")]
|
||||
let should_toggle = matches!(
|
||||
event,
|
||||
TrayIconEvent::DoubleClick { button: MouseButton::Left, .. }
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let should_toggle = matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
);
|
||||
if should_toggle {
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
@@ -2399,6 +2601,301 @@ fn is_tiling_wm_cmd() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mini Player window ──────────────────────────────────────────────────────
|
||||
// Secondary always-on-top window with minimal playback controls. Uses the
|
||||
// same frontend bundle as the main window; disambiguated by window label
|
||||
// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so
|
||||
// we fall back to a regular window there.
|
||||
|
||||
/// Persisted geometry for the mini player. Stored in
|
||||
/// `<app_config_dir>/mini_player_pos.json` and rewritten (throttled) on
|
||||
/// every `WindowEvent::Moved` so the window reopens where the user last
|
||||
/// left it. Coordinates are physical pixels — that's what `set_position`
|
||||
/// and the move event both report, so we don't need to round-trip through
|
||||
/// scale factors that may differ across monitors.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)]
|
||||
struct MiniPlayerPosition {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
fn mini_pos_file(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
|
||||
app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json"))
|
||||
}
|
||||
|
||||
fn read_mini_pos(app: &tauri::AppHandle) -> Option<MiniPlayerPosition> {
|
||||
let path = mini_pos_file(app)?;
|
||||
let raw = std::fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) {
|
||||
let Some(path) = mini_pos_file(app) else { return };
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(&pos) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks when we last set the mini player position programmatically.
|
||||
/// `WindowEvent::Moved` fires for both user drags AND our own `show()` /
|
||||
/// `set_position` calls — without this guard the WM's "centre on show"
|
||||
/// behaviour would silently overwrite the user's saved position.
|
||||
fn last_programmatic_pos_set() -> &'static Mutex<std::time::Instant> {
|
||||
static LAST: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
|
||||
LAST.get_or_init(|| Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)))
|
||||
}
|
||||
|
||||
fn mark_mini_pos_programmatic() {
|
||||
*last_programmatic_pos_set().lock().unwrap() = std::time::Instant::now();
|
||||
}
|
||||
|
||||
fn is_mini_pos_programmatic() -> bool {
|
||||
last_programmatic_pos_set().lock().unwrap().elapsed()
|
||||
< std::time::Duration::from_millis(1000)
|
||||
}
|
||||
|
||||
/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on
|
||||
/// every pointer step. 250 ms keeps the file fresh enough that any close
|
||||
/// or release lands a recent position, without hammering the disk.
|
||||
/// Programmatic moves (during `show()` / `set_position`) are skipped so
|
||||
/// WM re-centring on re-show doesn't clobber the saved position.
|
||||
fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) {
|
||||
if is_mini_pos_programmatic() {
|
||||
return;
|
||||
}
|
||||
static LAST_WRITE: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
|
||||
let mu = LAST_WRITE.get_or_init(|| {
|
||||
Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10))
|
||||
});
|
||||
{
|
||||
let mut last = mu.lock().unwrap();
|
||||
if last.elapsed() < std::time::Duration::from_millis(250) {
|
||||
return;
|
||||
}
|
||||
*last = std::time::Instant::now();
|
||||
}
|
||||
write_mini_pos(app, MiniPlayerPosition { x, y });
|
||||
}
|
||||
|
||||
/// Default position when nothing is persisted: bottom-right of the monitor
|
||||
/// the main window sits on (falls back to primary). A 24 px logical margin
|
||||
/// keeps it off the screen edge; +56 px on the bottom margin avoids most
|
||||
/// taskbars/docks since Tauri does not expose work-area rects.
|
||||
fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPosition<i32>> {
|
||||
let monitor = app
|
||||
.get_webview_window("main")
|
||||
.and_then(|w| w.current_monitor().ok().flatten())
|
||||
.or_else(|| app.primary_monitor().ok().flatten())?;
|
||||
|
||||
let scale = monitor.scale_factor();
|
||||
let m_pos = monitor.position();
|
||||
let m_size = monitor.size();
|
||||
|
||||
let win_w = (340.0 * scale).round() as i32;
|
||||
let win_h = (180.0 * scale).round() as i32;
|
||||
let margin_x = (24.0 * scale).round() as i32;
|
||||
let margin_y = (56.0 * scale).round() as i32;
|
||||
|
||||
Some(tauri::PhysicalPosition::new(
|
||||
m_pos.x + (m_size.width as i32) - win_w - margin_x,
|
||||
m_pos.y + (m_size.height as i32) - win_h - margin_y,
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the mini player webview window. Caller decides `visible` so the
|
||||
/// same code path serves both pre-creation (Windows, hidden at app start)
|
||||
/// and lazy creation (other platforms, shown on demand).
|
||||
fn build_mini_player_window(
|
||||
app: &tauri::AppHandle,
|
||||
visible: bool,
|
||||
) -> Result<tauri::WebviewWindow, String> {
|
||||
let use_always_on_top = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ !is_tiling_wm() }
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ true }
|
||||
};
|
||||
|
||||
// Resolve target position BEFORE building so the WM places the window
|
||||
// correctly from creation. Calling `set_position` after `build()` is
|
||||
// unreliable on several Linux WMs which re-centre hidden windows.
|
||||
let target_physical = read_mini_pos(app)
|
||||
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
|
||||
.or_else(|| default_mini_position(app));
|
||||
let scale = app
|
||||
.primary_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.scale_factor())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
// macOS + Windows keep the native titlebar (traffic lights / caption
|
||||
// buttons + system look). Linux uses a custom in-page titlebar so the
|
||||
// mini fits a tighter visual style across all WMs (incl. tiling).
|
||||
let use_decorations = !cfg!(target_os = "linux");
|
||||
|
||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"mini",
|
||||
tauri::WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("Psysonic Mini")
|
||||
.inner_size(340.0, 180.0)
|
||||
.min_inner_size(320.0, 180.0)
|
||||
.resizable(true)
|
||||
.decorations(use_decorations)
|
||||
.always_on_top(use_always_on_top)
|
||||
.skip_taskbar(false)
|
||||
.visible(visible);
|
||||
|
||||
if let Some(pos) = target_physical {
|
||||
builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale);
|
||||
}
|
||||
|
||||
// Suppress Moved-event echo for the initial show — Linux WMs sometimes
|
||||
// fire stray Moved events with default coords during the first paint.
|
||||
mark_mini_pos_programmatic();
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build mini player window: {e}"))
|
||||
}
|
||||
|
||||
/// Pre-build the mini player window hidden, so the first `open_mini_player`
|
||||
/// call becomes a pure show/hide and the user sees content instantly. On
|
||||
/// Windows this already happens unconditionally in `.setup()` as a hang
|
||||
/// workaround; this command is used by Linux/macOS when the user opts into
|
||||
/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists.
|
||||
#[tauri::command]
|
||||
fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if app.get_webview_window("mini").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
build_mini_player_window(&app, false).map(|_| ())
|
||||
}
|
||||
|
||||
/// Open (or toggle) the mini player window. On platforms where the window
|
||||
/// was pre-created at startup (Windows), this is a pure show/hide. On
|
||||
/// other platforms the window is created lazily on first call.
|
||||
/// Opening the mini player minimizes the main window; hiding the mini
|
||||
/// player restores the main window. Both steps are skipped on Windows
|
||||
/// because creating + immediately minimizing main stalls WebView2's paint
|
||||
/// pipeline and locks up the Tauri event loop.
|
||||
#[tauri::command]
|
||||
fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let win = match app.get_webview_window("mini") {
|
||||
Some(w) => w,
|
||||
None => build_mini_player_window(&app, false)?,
|
||||
};
|
||||
|
||||
let visible = win.is_visible().unwrap_or(false);
|
||||
if visible {
|
||||
win.hide().map_err(|e| e.to_string())?;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.unminimize();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
} else {
|
||||
// Re-applying the saved position after show() — many Linux WMs
|
||||
// (Mutter, KWin) re-centre hidden windows when they're shown
|
||||
// again, ignoring any earlier set_position. Mark the move as
|
||||
// programmatic so the Moved-event handler doesn't echo the
|
||||
// intermediate centre coords back to disk.
|
||||
let target = read_mini_pos(&app);
|
||||
mark_mini_pos_programmatic();
|
||||
win.show().map_err(|e| e.to_string())?;
|
||||
let _ = win.set_focus();
|
||||
if let Some(p) = target {
|
||||
let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y));
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.minimize();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide the mini player window if it exists and restore the main window.
|
||||
/// Does not destroy the mini window so its state is preserved for next open.
|
||||
#[tauri::command]
|
||||
fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("mini") {
|
||||
win.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.unminimize();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unminimize + show + focus the main window. Called from the mini player's
|
||||
/// "expand" button. Can't rely on a JS event bridge here because the main
|
||||
/// window's JS is paused while minimized on WebKitGTK. Also hides the mini
|
||||
/// window so the two don't sit on screen at the same time.
|
||||
#[tauri::command]
|
||||
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(mini) = app.get_webview_window("mini") {
|
||||
let _ = mini.hide();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
main.unminimize().map_err(|e| e.to_string())?;
|
||||
main.show().map_err(|e| e.to_string())?;
|
||||
main.set_focus().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle always-on-top on the mini player window.
|
||||
///
|
||||
/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland)
|
||||
/// silently ignore `set_always_on_top(true)` when the internal flag is
|
||||
/// already `true` — which happens whenever the window was hidden and
|
||||
/// re-shown, or focus was lost and the WM dropped the constraint. We
|
||||
/// always force a `false → true` cycle so the WM re-evaluates the layer.
|
||||
#[tauri::command]
|
||||
fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("mini") {
|
||||
if on_top {
|
||||
let _ = win.set_always_on_top(false);
|
||||
}
|
||||
win.set_always_on_top(on_top).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize the mini player window (logical pixels). Used when toggling the
|
||||
/// queue panel to expand/collapse without a capability dance. Optional
|
||||
/// `minWidth` / `minHeight` adjust the window's resize floor so the user
|
||||
/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when
|
||||
/// the queue panel is open).
|
||||
#[tauri::command]
|
||||
fn resize_mini_player(
|
||||
app: tauri::AppHandle,
|
||||
width: f64,
|
||||
height: f64,
|
||||
min_width: Option<f64>,
|
||||
min_height: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("mini") {
|
||||
// Lower the floor first; otherwise set_size to a value below the
|
||||
// existing min would silently clamp.
|
||||
if let (Some(mw), Some(mh)) = (min_width, min_height) {
|
||||
win.set_min_size(Some(tauri::LogicalSize::new(mw, mh)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -2426,7 +2923,11 @@ pub fn run() {
|
||||
.manage(TrayState::default())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_denylist(&["mini"])
|
||||
.build()
|
||||
)
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
@@ -2572,12 +3073,34 @@ pub fn run() {
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
}
|
||||
|
||||
// ── Pre-create mini player window (Windows) ──────────────────
|
||||
// Creating the second WebView2 webview lazily from an invoke
|
||||
// handler on Windows reliably stalls the Tauri event loop —
|
||||
// the mini shows a blank white window, neither main nor mini
|
||||
// can be closed, and the user has to kill the process via
|
||||
// Task Manager. Building it at startup (hidden) avoids the
|
||||
// runtime-creation code path entirely; later `open_mini_player`
|
||||
// calls are pure show/hide.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Err(e) = build_mini_player_window(app.handle(), false) {
|
||||
eprintln!("[psysonic] Failed to pre-create mini window: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Cold start with `--player …`: defer emit so the webview can register listeners.
|
||||
crate::cli::spawn_deferred_cli_argv_handler(app.handle());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Persist mini player position whenever the user drags it.
|
||||
if window.label() == "mini" {
|
||||
if let tauri::WindowEvent::Moved(pos) = event {
|
||||
persist_mini_pos_throttled(window.app_handle(), pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
api.prevent_close();
|
||||
@@ -2596,6 +3119,16 @@ pub fn run() {
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
}
|
||||
} else if window.label() == "mini" {
|
||||
// Native close on the mini: hide instead of destroying so
|
||||
// state is preserved, and restore the main window.
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
if let Some(main) = window.app_handle().get_webview_window("main") {
|
||||
let _ = main.unminimize();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2611,6 +3144,12 @@ pub fn run() {
|
||||
set_linux_webkit_smooth_scrolling,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
open_mini_player,
|
||||
preload_mini_player,
|
||||
close_mini_player,
|
||||
set_mini_player_always_on_top,
|
||||
resize_mini_player,
|
||||
show_main_window,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
mpris_set_metadata,
|
||||
@@ -2665,6 +3204,8 @@ pub fn run() {
|
||||
get_removable_drives,
|
||||
write_device_manifest,
|
||||
read_device_manifest,
|
||||
write_playlist_m3u8,
|
||||
rename_device_files,
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.19",
|
||||
"version": "1.42.1",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUWFjOQo=",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUVBhYzkK",
|
||||
"endpoints": [
|
||||
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
|
||||
],
|
||||
|
||||
+86
-11
@@ -38,6 +38,9 @@ import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import WhatsNew from './pages/WhatsNew';
|
||||
import MiniPlayer from './components/MiniPlayer';
|
||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
@@ -51,10 +54,9 @@ import OfflineLibrary from './pages/OfflineLibrary';
|
||||
import Genres from './pages/Genres';
|
||||
import GenreDetail from './pages/GenreDetail';
|
||||
import ExportPickerModal from './components/ExportPickerModal';
|
||||
import ChangelogModal from './components/ChangelogModal';
|
||||
import AppUpdater from './components/AppUpdater';
|
||||
import TitleBar from './components/TitleBar';
|
||||
import { IS_LINUX } from './utils/platform';
|
||||
import { IS_LINUX, IS_WINDOWS } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -144,6 +146,17 @@ function AppShell() {
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Mini player → main: route requests dispatched as `psy:navigate`
|
||||
// CustomEvents from the bridge land here so React Router can take over.
|
||||
useEffect(() => {
|
||||
const onPsyNavigate = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.to) navigate(detail.to);
|
||||
};
|
||||
window.addEventListener('psy:navigate', onPsyNavigate);
|
||||
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
||||
}, [navigate]);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
useEffect(() => {
|
||||
@@ -227,14 +240,9 @@ function AppShell() {
|
||||
fn();
|
||||
}, [currentTrack, isPlaying]);
|
||||
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
|
||||
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
|
||||
setChangelogModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
// Post-update changelog is now surfaced via a dismissible banner in the
|
||||
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
|
||||
// modal takeover on startup.
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
|
||||
@@ -325,6 +333,20 @@ function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pause CSS animations when the window is minimized / hidden.
|
||||
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
|
||||
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
|
||||
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
|
||||
// components.css pauses all running animations while this flag is set.
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
|
||||
};
|
||||
document.addEventListener('visibilitychange', update);
|
||||
update();
|
||||
return () => document.removeEventListener('visibilitychange', update);
|
||||
}, []);
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
return (
|
||||
@@ -386,6 +408,7 @@ function AppShell() {
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
@@ -420,7 +443,6 @@ function AppShell() {
|
||||
<DownloadFolderModal />
|
||||
<TooltipPortal />
|
||||
<AppUpdater />
|
||||
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -790,6 +812,9 @@ function TauriEventBridge() {
|
||||
win.isFullscreen().then(fs => win.setFullscreen(!fs));
|
||||
break;
|
||||
}
|
||||
case 'open-mini-player':
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -930,6 +955,12 @@ export default function App() {
|
||||
const font = useFontStore(s => s.font);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
// Mini Player window: detected via Tauri window label. Rendered without
|
||||
// router / sidebar / full audio listeners — it just listens for state + sends
|
||||
// control events. Label is read synchronously from a global set in main.tsx
|
||||
// so the initial render picks the right tree.
|
||||
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
@@ -938,6 +969,50 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// Main window only: push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
if (isMiniWindow) return;
|
||||
return initMiniPlayerBridgeOnMain();
|
||||
}, [isMiniWindow]);
|
||||
|
||||
// Main window only: optionally pre-create the mini player webview hidden so
|
||||
// the first open is instant. Windows already does this unconditionally in
|
||||
// Rust .setup() as a hang workaround — skip here to avoid double-building.
|
||||
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
|
||||
useEffect(() => {
|
||||
if (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
|
||||
invoke('preload_mini_player').catch(() => {});
|
||||
}, [isMiniWindow, preloadMiniPlayer]);
|
||||
|
||||
// Mini window only: re-hydrate persisted appearance stores when the main
|
||||
// window writes new values. Both webviews share localStorage (same origin),
|
||||
// so the `storage` event fires here whenever main mutates a key — but
|
||||
// Zustand persist only reads localStorage on initial load, hence the
|
||||
// explicit rehydrate.
|
||||
useEffect(() => {
|
||||
if (!isMiniWindow) return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (!e.key) return;
|
||||
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
||||
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, [isMiniWindow]);
|
||||
|
||||
if (isMiniWindow) {
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
||||
// because they live in separate grid cells. Document-level zoom is not used
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface SubsonicAlbum {
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
||||
isCompilation?: boolean;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
|
||||
+142
-19
@@ -4,7 +4,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
@@ -103,7 +103,10 @@ export default function AppUpdater() {
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
const countdownRef = useRef<number | null>(null);
|
||||
const relaunchFnRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
@@ -152,19 +155,51 @@ export default function AppUpdater() {
|
||||
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
return () => {
|
||||
unlistenRef.current?.();
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
// On macOS the Tauri Updater handles architecture, signature verification
|
||||
// and in-place install — we don't need (and should not show) a DMG asset.
|
||||
const useTauriUpdater = IS_MACOS;
|
||||
const showInstallBtn = !showAurHint && (useTauriUpdater || !!asset);
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const startRestartCountdown = (seconds: number) => {
|
||||
let remaining = seconds;
|
||||
setCountdown(remaining);
|
||||
countdownRef.current = window.setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
if (countdownRef.current) window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
setCountdown(null);
|
||||
relaunchFnRef.current?.();
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleRestartNow = async () => {
|
||||
if (countdownRef.current) {
|
||||
window.clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setCountdown(null);
|
||||
await relaunchFnRef.current?.();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
// On macOS: use the Tauri Updater plugin — downloads .app.tar.gz, verifies
|
||||
// the minisign signature against the bundled pubkey, replaces the .app, and
|
||||
@@ -175,6 +210,8 @@ export default function AppUpdater() {
|
||||
setDlError('');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
relaunchFnRef.current = relaunch;
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
setDlError(t('common.updaterErrorMsg'));
|
||||
@@ -192,9 +229,12 @@ export default function AppUpdater() {
|
||||
setDlProgress({ bytes: downloaded, total });
|
||||
} else if (event.event === 'Finished') {
|
||||
setDlState('done');
|
||||
// downloadAndInstall replaces the .app in place but does not exit
|
||||
// the running process. Give the user a 3s countdown (with a manual
|
||||
// "Restart now" button) before auto-relaunch.
|
||||
startRestartCountdown(3);
|
||||
}
|
||||
});
|
||||
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
|
||||
} catch (e) {
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
@@ -309,6 +349,59 @@ export default function AppUpdater() {
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
@@ -356,25 +449,55 @@ export default function AppUpdater() {
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{!showAurHint && asset && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
{dlState === 'idle' && (
|
||||
<>
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
|
||||
{dlState === 'done' && useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'done' && !useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
@@ -38,7 +40,7 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
{t('artists.albumCount', { count: artist.albumCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -502,8 +502,9 @@ interface FsLyricsMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
|
||||
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: FsLyricsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
@@ -512,13 +513,16 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// setTimeout(0) defers listener registration past the current click cycle
|
||||
// so the button click that opens the panel doesn't immediately close it.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
@@ -527,7 +531,7 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -702,6 +706,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
@@ -838,8 +843,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
|
||||
+160
-103
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Filter, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getGenres } from '../api/subsonic';
|
||||
|
||||
@@ -13,8 +14,10 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
|
||||
const [open, setOpen] = useState(false);
|
||||
const [genres, setGenres] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,124 +26,178 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
|
||||
);
|
||||
}, []);
|
||||
|
||||
// close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
// sync open state with selection
|
||||
useEffect(() => {
|
||||
if (selected.length > 0) setOpen(true);
|
||||
}, [selected]);
|
||||
// Selected on top, then alphabetical (stable for comfortable scanning).
|
||||
const sortedGenres = useMemo(() => {
|
||||
const arr = [...genres];
|
||||
arr.sort((a, b) => {
|
||||
const sa = selectedSet.has(a) ? 0 : 1;
|
||||
const sb = selectedSet.has(b) ? 0 : 1;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return arr;
|
||||
}, [genres, selectedSet]);
|
||||
|
||||
const filteredOptions = genres.filter(
|
||||
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const filteredGenres = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return sortedGenres;
|
||||
return sortedGenres.filter(g => g.toLowerCase().includes(q));
|
||||
}, [sortedGenres, search]);
|
||||
|
||||
const add = (genre: string) => {
|
||||
onSelectionChange([...selected, genre]);
|
||||
setSearch('');
|
||||
inputRef.current?.focus();
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 280;
|
||||
const MAX_H = 360;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (genre: string) => {
|
||||
onSelectionChange(selected.filter(s => s !== genre));
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => updatePopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!popRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggle = (genre: string) => {
|
||||
if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre));
|
||||
else onSelectionChange([...selected, genre]);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onSelectionChange([]);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const openFilter = () => {
|
||||
setOpen(true);
|
||||
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<Filter size={14} />
|
||||
{t('common.filterGenre')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
// relatedTarget is the next focused element; if it's outside our container, handle close
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (containerRef.current && next && containerRef.current.contains(next)) return;
|
||||
setTimeout(() => {
|
||||
if (selected.length === 0) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setDropdownOpen(false);
|
||||
} else {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
const count = selected.length;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`btn btn-surface${count > 0 ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<Filter size={14} />
|
||||
{t('common.filterGenre')}
|
||||
{count > 0 && <span className="genre-filter-count">{count}</span>}
|
||||
</button>
|
||||
|
||||
<div className="genre-filter-tagbox">
|
||||
{selected.map(g => (
|
||||
<span key={g} className="genre-filter-chip">
|
||||
{g}
|
||||
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="genre-filter-popover"
|
||||
style={popStyle}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="genre-filter-popover__search">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('common.filterSearchGenres')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && filteredGenres.length > 0) {
|
||||
toggle(filteredGenres[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="genre-filter-input"
|
||||
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
|
||||
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
|
||||
remove(selected[selected.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{dropdownOpen && filteredOptions.length > 0 && (
|
||||
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
|
||||
{filteredOptions.slice(0, 60).map(g => (
|
||||
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
|
||||
{g}
|
||||
<div className="genre-filter-popover__list">
|
||||
{filteredGenres.length === 0 ? (
|
||||
<div className="genre-filter-popover__empty">
|
||||
{t('common.filterNoGenres')}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
filteredGenres.map(g => {
|
||||
const isSel = selectedSet.has(g);
|
||||
return (
|
||||
<div
|
||||
key={g}
|
||||
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
|
||||
onClick={() => toggle(g)}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
>
|
||||
<span className="genre-filter-popover__check">
|
||||
{isSel && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{g}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
|
||||
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
|
||||
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
|
||||
<X size={13} />
|
||||
{t('common.filterClear')}
|
||||
</button>
|
||||
{count > 0 && (
|
||||
<div className="genre-filter-popover__footer">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clear}
|
||||
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('common.filterClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
|
||||
import { star, unstar } from '../api/subsonic';
|
||||
import type { MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
track: MiniTrackInfo;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim queue-item context menu for the mini player. The mini lives in its
|
||||
* own webview, so all queue mutations forward to the main window via Tauri
|
||||
* events; only the favorite call hits Subsonic directly because it has no
|
||||
* cross-window state to keep in sync (next mini:sync from main reflects the
|
||||
* new starred flag).
|
||||
*/
|
||||
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [starred, setStarred] = useState(!!track.starred);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Clamp the menu inside the mini window's viewport (it pops near the
|
||||
// cursor and would otherwise overflow at the right/bottom edges of the
|
||||
// small window).
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const left = Math.min(x, Math.max(4, vw - r.width - 4));
|
||||
const top = Math.min(y, Math.max(4, vh - r.height - 4));
|
||||
setPos({ left, top });
|
||||
}, [x, y]);
|
||||
|
||||
// Dismiss on outside click + Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const run = (fn: () => void | Promise<void>) => {
|
||||
Promise.resolve(fn()).finally(onClose);
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
const next = !starred;
|
||||
setStarred(next);
|
||||
try {
|
||||
if (next) await star(track.id, 'song');
|
||||
else await unstar(track.id, 'song');
|
||||
} catch {
|
||||
setStarred(!next);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="context-menu mini-context-menu"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => run(() => emit('mini:remove', { index }))}
|
||||
>
|
||||
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{track.albumId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
|
||||
>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{track.artistId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
|
||||
>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => run(toggleStar)}>
|
||||
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
|
||||
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
|
||||
>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import MiniContextMenu from './MiniContextMenu';
|
||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||
|
||||
const COLLAPSED_SIZE = { w: 340, h: 180 };
|
||||
const EXPANDED_SIZE = { w: 340, h: 440 };
|
||||
// Minimum window dimensions per state. When the queue is open the floor must
|
||||
// keep at least two queue rows visible; a stricter min would let the user
|
||||
// collapse the queue area to nothing while it's still toggled on.
|
||||
const COLLAPSED_MIN = { w: 320, h: 180 };
|
||||
const EXPANDED_MIN = { w: 320, h: 260 };
|
||||
|
||||
// Persist the expanded-window height so reopening the queue restores the
|
||||
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
||||
const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
|
||||
function readStoredExpandedHeight(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(EXPANDED_H_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
|
||||
}
|
||||
} catch {}
|
||||
return EXPANDED_SIZE.h;
|
||||
}
|
||||
|
||||
// Persist whether the queue panel was open so the next launch restores
|
||||
// the same state. Same scope as the height: localStorage of the mini
|
||||
// webview (shared across mini sessions, separate from the main store).
|
||||
const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
|
||||
function readQueueOpen(): boolean {
|
||||
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function toMini(t: any): MiniTrackInfo {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
coverArt: t.coverArt,
|
||||
duration: t.duration,
|
||||
starred: !!t.starred,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate from the persisted playerStore so initial paint shows real content
|
||||
* instead of "—" while we wait for the mini:sync event from the main window.
|
||||
* The persisted state covers the cold-start window (webview boot + bundle).
|
||||
*/
|
||||
function initialSnapshot(): MiniSyncPayload {
|
||||
try {
|
||||
const s = usePlayerStore.getState();
|
||||
return {
|
||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||
queue: (s.queue ?? []).map(toMini),
|
||||
queueIndex: s.queueIndex ?? 0,
|
||||
isPlaying: s.isPlaying,
|
||||
isMobile: false,
|
||||
};
|
||||
} catch {
|
||||
return { track: null, queue: [], queueIndex: 0, isPlaying: false, isMobile: false };
|
||||
}
|
||||
}
|
||||
|
||||
interface ProgressPayload {
|
||||
current_time: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function MiniPlayer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(() => {
|
||||
const initial = initialSnapshot();
|
||||
return initial.track?.duration ?? 0;
|
||||
});
|
||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
||||
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||
const ticker = useRef<number | null>(null);
|
||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── PsyDnD reorder ──
|
||||
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
|
||||
// on the queue computes a drop indicator, psy-drop emits mini:reorder back
|
||||
// to main where the source-of-truth store lives.
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
const isReorderDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
|
||||
// ── Context menu state ──
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
|
||||
|
||||
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
|
||||
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
|
||||
// items so the queue keeps its full width.
|
||||
const recomputeScroll = useCallback(() => {
|
||||
const el = queueScrollRef.current;
|
||||
if (!el) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (scrollHeight <= clientHeight + 1) {
|
||||
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
|
||||
return;
|
||||
}
|
||||
const ratio = clientHeight / scrollHeight;
|
||||
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
|
||||
const range = clientHeight - thumbH;
|
||||
const scrollRange = scrollHeight - clientHeight;
|
||||
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
|
||||
setScrollMeta({ thumbH, thumbT, visible: true });
|
||||
}, []);
|
||||
|
||||
// Announce to main window that we're mounted; it replies with a snapshot.
|
||||
// Also re-announce on window focus: on Windows the mini is pre-created at
|
||||
// app startup so the mount-time emit can race past main's bridge before
|
||||
// it has attached its listener. Re-emitting on focus means every actual
|
||||
// open of the mini (user clicks the player-bar icon) triggers a fresh
|
||||
// sync regardless of startup ordering.
|
||||
useEffect(() => {
|
||||
emit('mini:ready', {}).catch(() => {});
|
||||
const onFocus = () => { emit('mini:ready', {}).catch(() => {}); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, []);
|
||||
|
||||
// Restore the expanded window size on initial mount when the queue was
|
||||
// open at the previous app close. Rust always builds the window at the
|
||||
// collapsed size; without this we'd render queueOpen=true into a 180 px
|
||||
// window. Brief jump from collapsed to expanded is unavoidable since
|
||||
// localStorage only lives in JS.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
invoke('resize_mini_player', {
|
||||
width: EXPANDED_SIZE.w,
|
||||
height: readStoredExpandedHeight(),
|
||||
minWidth: EXPANDED_MIN.w,
|
||||
minHeight: EXPANDED_MIN.h,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Re-apply pin state on mount and whenever the window regains focus.
|
||||
// After a Hide → Show cycle (which is what `open_mini_player` does on
|
||||
// re-toggle) the WM often drops the always-on-top constraint silently;
|
||||
// re-asserting it here means the user no longer has to click the pin
|
||||
// button twice to make it stick.
|
||||
useEffect(() => {
|
||||
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
|
||||
const reapply = () => {
|
||||
if (alwaysOnTop) {
|
||||
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', reapply);
|
||||
return () => window.removeEventListener('focus', reapply);
|
||||
}, [alwaysOnTop]);
|
||||
|
||||
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
|
||||
// Also honour the user-configured 'open-mini-player' shortcut so the
|
||||
// same chord that opens the mini from main also closes it from here.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
const tag = tgt?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return;
|
||||
|
||||
const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player'];
|
||||
if (matchInAppBinding(e, openMiniBinding)) {
|
||||
e.preventDefault();
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
emit('mini:control', 'toggle').catch(() => {});
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
emit('mini:control', 'next').catch(() => {});
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
emit('mini:control', 'prev').catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
// Subscribe to state + progress from the main window / Rust.
|
||||
useEffect(() => {
|
||||
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
|
||||
setState(e.payload);
|
||||
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
|
||||
});
|
||||
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
|
||||
setCurrentTime(e.payload.current_time);
|
||||
if (e.payload.duration > 0) setDuration(e.payload.duration);
|
||||
});
|
||||
const unEnded = listen('audio:ended', () => setCurrentTime(0));
|
||||
return () => {
|
||||
unSync.then(fn => fn()).catch(() => {});
|
||||
unProgress.then(fn => fn()).catch(() => {});
|
||||
unEnded.then(fn => fn()).catch(() => {});
|
||||
if (ticker.current) window.clearInterval(ticker.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
|
||||
|
||||
const toggleOnTop = async () => {
|
||||
const next = !alwaysOnTop;
|
||||
setAlwaysOnTop(next);
|
||||
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
|
||||
};
|
||||
|
||||
const closeMini = async () => {
|
||||
try { await invoke('close_mini_player'); } catch {}
|
||||
};
|
||||
|
||||
const showMain = () => invoke('show_main_window').catch(() => {});
|
||||
|
||||
const toggleQueue = async () => {
|
||||
const next = !queueOpen;
|
||||
// Capture the current expanded height before collapsing so the next
|
||||
// open restores it. Read window.innerHeight directly — it matches the
|
||||
// logical inner size that resize_mini_player set previously.
|
||||
if (!next) {
|
||||
const h = Math.round(window.innerHeight);
|
||||
if (h >= EXPANDED_MIN.h) {
|
||||
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
|
||||
}
|
||||
}
|
||||
setQueueOpen(next);
|
||||
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
|
||||
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
|
||||
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
|
||||
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
|
||||
try {
|
||||
await invoke('resize_mini_player', {
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
minWidth: min.w,
|
||||
minHeight: min.h,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
|
||||
|
||||
// Listen for psy-drop on the queue. Only handles `queue_reorder` payloads
|
||||
// since the mini player has no external drag sources. `queueOpen` must be
|
||||
// in deps because the wrap (and thus queueScrollRef.current) only mounts
|
||||
// when the queue is expanded — without it the ref is null on first run
|
||||
// and the listener never attaches.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
const el = queueScrollRef.current;
|
||||
if (!el) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||
const tgt = dropTargetRef.current;
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
if (parsed.type !== 'queue_reorder') return;
|
||||
const fromIdx = parsed.index as number;
|
||||
psyDragFromIdxRef.current = null;
|
||||
const queueLen = usePlayerStore.getState().queue.length || state.queue.length;
|
||||
const insertIdx = tgt
|
||||
? (tgt.before ? tgt.idx : tgt.idx + 1)
|
||||
: queueLen;
|
||||
if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return;
|
||||
// Adjust target index if removing the source first shifts later items.
|
||||
const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
|
||||
if (fromIdx === adjusted) return;
|
||||
emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {});
|
||||
};
|
||||
el.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [queueOpen, state.queue.length]);
|
||||
|
||||
// Auto-scroll the current track into view when the queue expands.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [queueOpen, state.queueIndex]);
|
||||
|
||||
// Recompute overlay-thumb on open, queue mutations, and window resize.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
recomputeScroll();
|
||||
const onResize = () => recomputeScroll();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [queueOpen, state.queue.length, recomputeScroll]);
|
||||
|
||||
const { track, isPlaying } = state;
|
||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mini-player-shell">
|
||||
<div
|
||||
className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
|
||||
{...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
|
||||
>
|
||||
{IS_LINUX ? (
|
||||
<span className="mini-player__titlebar-title" data-tauri-drag-region>
|
||||
{track?.title ?? 'Psysonic Mini'}
|
||||
</span>
|
||||
) : (
|
||||
// macOS/Windows already render a native titlebar with the window
|
||||
// title + close button; we just need a flexible spacer so the
|
||||
// action buttons sit right.
|
||||
<span className="mini-player__titlebar-spacer" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__titlebar-btn${queueOpen ? ' mini-player__titlebar-btn--active' : ''}`}
|
||||
onClick={toggleQueue}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
|
||||
onClick={toggleOnTop}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
||||
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
||||
>
|
||||
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-player__titlebar-btn"
|
||||
onClick={showMain}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('miniPlayer.openMainWindow')}
|
||||
aria-label={t('miniPlayer.openMainWindow')}
|
||||
>
|
||||
<Maximize2 size={13} />
|
||||
</button>
|
||||
{/* macOS + Windows already provide Close via the native titlebar —
|
||||
skip the duplicate so the in-app titlebar stays minimal. */}
|
||||
{IS_LINUX && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
|
||||
onClick={closeMini}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('miniPlayer.close')}
|
||||
aria-label={t('miniPlayer.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
|
||||
<div className="mini-player__art">
|
||||
{track?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(track.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(track.coverArt, 300)}
|
||||
alt={track.album}
|
||||
/>
|
||||
) : (
|
||||
<div className="mini-player__art-fallback" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mini-player__body" data-tauri-drag-region="false">
|
||||
<div className="mini-player__titles">
|
||||
<div className="mini-player__title" title={track?.title}>
|
||||
{track?.title ?? '—'}
|
||||
</div>
|
||||
<div className="mini-player__artist" title={track?.artist}>
|
||||
{track?.artist ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mini-player__controls">
|
||||
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mini-player__progress" data-tauri-drag-region="false">
|
||||
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
|
||||
<div className="mini-player__progress-track">
|
||||
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mini-player__progress-time">{fmt(duration)}</div>
|
||||
</div>
|
||||
|
||||
{queueOpen && (
|
||||
<div
|
||||
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
|
||||
onMouseMove={(e) => {
|
||||
if (!isReorderDrag || !queueScrollRef.current) return;
|
||||
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i].getBoundingClientRect();
|
||||
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
const idx = parseInt(items[i].dataset.mqIdx!, 10);
|
||||
const t = { idx, before };
|
||||
dropTargetRef.current = t;
|
||||
setDropTarget(t);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
||||
) : (
|
||||
state.queue.map((t, i) => {
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
||||
dragStyle = { opacity: 0.4 };
|
||||
} else if (isReorderDrag && dropTarget?.idx === i) {
|
||||
dragStyle = dropTarget.before
|
||||
? { boxShadow: 'inset 0 2px 0 var(--accent)' }
|
||||
: { boxShadow: 'inset 0 -2px 0 var(--accent)' };
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${t.id}-${i}`}
|
||||
data-mq-idx={i}
|
||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
|
||||
onClick={() => jumpTo(i)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
// Don't start drag while a click would also be valid —
|
||||
// the threshold check below upgrades to a drag once
|
||||
// the pointer leaves the deadband.
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDragFromIdxRef.current = i;
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
|
||||
me.clientX,
|
||||
me.clientY,
|
||||
);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<span className="mini-queue__num">{i + 1}</span>
|
||||
<div className="mini-queue__meta">
|
||||
<div className="mini-queue__title">{t.title}</div>
|
||||
<div className="mini-queue__artist">{t.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scrollMeta.visible && (
|
||||
<div
|
||||
className="mini-queue__thumb"
|
||||
style={{
|
||||
height: `${scrollMeta.thumbH}px`,
|
||||
transform: `translateY(${scrollMeta.thumbT}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctxMenu && (
|
||||
<MiniContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
track={ctxMenu.track}
|
||||
index={ctxMenu.index}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
|
||||
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
|
||||
PictureInPicture2, ArrowLeftRight,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
@@ -41,11 +44,28 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render.
|
||||
const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const updateRemaining = () => {
|
||||
if (spanRef.current) {
|
||||
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
|
||||
spanRef.current.textContent = `-${formatTime(remaining)}`;
|
||||
}
|
||||
};
|
||||
updateRemaining();
|
||||
return usePlayerStore.subscribe(updateRemaining);
|
||||
}, [duration]);
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [eqOpen, setEqOpen] = useState(false);
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
||||
const premuteVolumeRef = useRef(1);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
@@ -295,7 +315,18 @@ export default function PlayerBar() {
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
<span
|
||||
className="player-time player-time-toggle"
|
||||
onClick={() => {
|
||||
const newVal = !localShowRemaining;
|
||||
setLocalShowRemaining(newVal);
|
||||
useThemeStore.getState().setShowRemainingTime(newVal);
|
||||
}}
|
||||
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
|
||||
>
|
||||
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTime(duration)}
|
||||
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,6 +341,16 @@ export default function PlayerBar() {
|
||||
<SlidersVertical size={15} />
|
||||
</button>
|
||||
|
||||
{/* Mini Player */}
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => invoke('open_mini_player').catch(() => {})}
|
||||
aria-label="Mini Player"
|
||||
data-tooltip="Mini Player"
|
||||
>
|
||||
<PictureInPicture2 size={15} />
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, HardDrive } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
@@ -8,6 +8,7 @@ import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import LyricsPane from './LyricsPane';
|
||||
@@ -267,6 +268,8 @@ export default function QueuePanel() {
|
||||
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
|
||||
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -451,28 +454,53 @@ export default function QueuePanel() {
|
||||
})(),
|
||||
].filter(Boolean) as string[];
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const techLine = [...baseParts, ...rgParts].join(' · ');
|
||||
if (!techLine && !playbackSource) return null;
|
||||
const baseLine = baseParts.join(' · ');
|
||||
const rgLine = rgParts.join(' · ');
|
||||
if (!baseLine && !rgLine && !playbackSource) return null;
|
||||
const showRgLine = expandReplayGain && !!rgLine;
|
||||
return (
|
||||
<div className="queue-current-tech">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
<span className="queue-current-tech-main">{techLine}</span>
|
||||
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
|
||||
<div className="queue-current-tech-stack">
|
||||
<div className="queue-current-tech-row">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
|
||||
{rgLine && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
|
||||
aria-expanded={showRgLine}
|
||||
aria-label={t('queue.replayGain')}
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
RG
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRgLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
|
||||
{' · '}{rgLine}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
import WhatsNewBanner from './WhatsNewBanner';
|
||||
import { getPlaylists } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
@@ -294,13 +295,18 @@ export default function Sidebar({
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
||||
<div className="sidebar-bottom-spacer" />
|
||||
|
||||
{/* What's New banner — only visible while the current release hasn't been seen. */}
|
||||
<WhatsNewBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
<NavLink
|
||||
to="/now-playing"
|
||||
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ marginTop: 'auto' }}
|
||||
>
|
||||
<span className="nav-np-icon-wrap">
|
||||
<AudioLines size={isCollapsed ? 22 : 18} />
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownUp, Check } from 'lucide-react';
|
||||
|
||||
export interface SortOption<V extends string> {
|
||||
value: V;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<V extends string> {
|
||||
value: V;
|
||||
options: SortOption<V>[];
|
||||
onChange: (value: V) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const current = options.find(o => o.value === value);
|
||||
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = Math.max(rect.width, 220);
|
||||
const MAX_H = 300;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => updatePopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!popRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
{current?.label ?? value}
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="genre-filter-popover"
|
||||
style={popStyle}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="genre-filter-popover__list">
|
||||
{options.map(opt => {
|
||||
const isSel = opt.value === value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
>
|
||||
<span className="genre-filter-popover__check">
|
||||
{isSel && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version } from '../../package.json';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar pill shown above Now Playing while the current app version hasn't
|
||||
* been opened yet. Clicking opens the What's New page; X dismisses.
|
||||
*
|
||||
* Uses a fixed neutral palette so it looks identical across every theme.
|
||||
*/
|
||||
export default function WhatsNewBanner({ collapsed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const lastSeen = useAuthStore(s => s.lastSeenChangelogVersion);
|
||||
const setLastSeen = useAuthStore(s => s.setLastSeenChangelogVersion);
|
||||
const showOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
|
||||
|
||||
if (!showOnUpdate || lastSeen === version) return null;
|
||||
|
||||
const dismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setLastSeen(version);
|
||||
};
|
||||
|
||||
const open = () => navigate('/whats-new');
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="whats-new-banner whats-new-banner--collapsed"
|
||||
onClick={open}
|
||||
data-tooltip={t('whatsNew.bannerCollapsed', { version })}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="whats-new-banner" onClick={open}>
|
||||
<Sparkles size={14} className="whats-new-banner__icon" />
|
||||
<span className="whats-new-banner__text">
|
||||
<span className="whats-new-banner__title">{t('whatsNew.bannerTitle')}</span>
|
||||
<span className="whats-new-banner__version">v{version}</span>
|
||||
</span>
|
||||
<span
|
||||
className="whats-new-banner__dismiss"
|
||||
role="button"
|
||||
aria-label={t('whatsNew.dismiss')}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CalendarRange, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
export default function YearFilterButton({ from, to, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const fromRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fromNum = parseInt(from, 10);
|
||||
const toNum = parseInt(to, 10);
|
||||
const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 260;
|
||||
const MAX_H = 200;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
setTimeout(() => fromRef.current?.focus(), 0);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => updatePopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!popRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const clear = () => {
|
||||
onChange('', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
}}
|
||||
>
|
||||
<CalendarRange size={14} />
|
||||
{active ? `${fromNum}–${toNum}` : t('albums.yearFilterLabel')}
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="genre-filter-popover"
|
||||
style={popStyle}
|
||||
role="dialog"
|
||||
>
|
||||
<div style={{ padding: '0.75rem 0.75rem 0.5rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
|
||||
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t('albums.yearFrom')}
|
||||
</label>
|
||||
<input
|
||||
ref={fromRef}
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder="1970"
|
||||
value={from}
|
||||
onChange={e => onChange(e.target.value, to)}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}>–</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
|
||||
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t('albums.yearTo')}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={String(CURRENT_YEAR)}
|
||||
value={to}
|
||||
onChange={e => onChange(from, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div className="genre-filter-popover__footer">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clear}
|
||||
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
<X size={13} />
|
||||
{t('albums.yearFilterClear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+62
-8
@@ -218,6 +218,9 @@ export const deTranslation = {
|
||||
clearArtistFilter: 'Künstlerfilter zurücksetzen',
|
||||
noFilterResults: 'Keine Ergebnisse mit den ausgewählten Filtern.',
|
||||
allArtists: 'Alle Künstler',
|
||||
topArtists: 'Top-Künstler nach Favoriten',
|
||||
topArtistsSongCount_one: '{{count}} Song',
|
||||
topArtistsSongCount_other: '{{count}} Songs',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix erstellen',
|
||||
@@ -287,6 +290,12 @@ export const deTranslation = {
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
compilationLabel: 'Sampler',
|
||||
compilationOnly: 'Nur Sampler',
|
||||
compilationHide: 'Ohne Sampler',
|
||||
compilationTooltipAll: 'Alle Alben · klicken: nur Sampler',
|
||||
compilationTooltipOnly: 'Nur Sampler · klicken: Sampler ausblenden',
|
||||
compilationTooltipHide: 'Sampler ausgeblendet · klicken: alle zeigen',
|
||||
select: 'Mehrfachauswahl',
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
@@ -375,6 +384,7 @@ export const deTranslation = {
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Zurück',
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
save: 'Speichern',
|
||||
delete: 'Löschen',
|
||||
use: 'Verwenden',
|
||||
@@ -407,6 +417,15 @@ export const deTranslation = {
|
||||
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterInstallNow: 'Jetzt installieren',
|
||||
updaterMacReadyTitle: 'Bereit zur Installation',
|
||||
updaterMacReady: 'Das Update wird heruntergeladen, verifiziert und direkt übernommen — keine DMG nötig. Die App startet anschließend automatisch neu.',
|
||||
updaterTrustNotarized: 'Von Apple beglaubigt',
|
||||
updaterTrustSignature: 'Signatur verifiziert',
|
||||
updaterMacDoneTitle: 'Update installiert',
|
||||
updaterRestartingIn: 'Neustart in {{n}}s …',
|
||||
updaterRestarting: 'Neustart läuft …',
|
||||
updaterRestartNow: 'Jetzt neu starten',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||
durationMinutesOnly: '{{minutes}} Min.',
|
||||
@@ -534,6 +553,8 @@ export const deTranslation = {
|
||||
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
preloadMiniPlayer: 'Mini-Player vorladen',
|
||||
preloadMiniPlayerDesc: 'Baut das Mini-Player-Fenster beim App-Start im Hintergrund auf, damit es beim ersten Öffnen sofort Inhalt zeigt. Kostet etwas mehr RAM.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
useCustomTitlebar: 'Eigene Titelleiste',
|
||||
@@ -580,11 +601,13 @@ export const deTranslation = {
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
|
||||
aboutReleaseNotesLabel: 'Release Notes',
|
||||
aboutReleaseNotesLink: 'Neuigkeiten dieser Version öffnen',
|
||||
aboutContributorsLabel: 'Mitwirkende',
|
||||
aboutSpecialThanksLabel: 'Besonderer Dank',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
|
||||
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
|
||||
randomMixTitle: 'Zufallsmix',
|
||||
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
|
||||
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
|
||||
@@ -621,6 +644,7 @@ export const deTranslation = {
|
||||
shortcutOpenFolderBrowser: '{{folderBrowser}} öffnen',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
shortcutOpenMiniPlayer: 'Mini-Player öffnen',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
ratingsSectionTitle: 'Bewertungen',
|
||||
@@ -720,6 +744,14 @@ export const deTranslation = {
|
||||
dontShowAgain: 'Nicht mehr anzeigen',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Was ist neu',
|
||||
empty: 'Für diese Version liegt noch kein Changelog-Eintrag vor.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: 'Neu in v{{version}}',
|
||||
dismiss: 'Ausblenden',
|
||||
close: 'Schließen',
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe',
|
||||
s1: 'Erste Schritte',
|
||||
@@ -875,6 +907,7 @@ export const deTranslation = {
|
||||
trackPlural: 'Titel',
|
||||
showRemaining: 'Restzeit anzeigen',
|
||||
showTotal: 'Gesamtzeit anzeigen',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
@@ -882,6 +915,15 @@ export const deTranslation = {
|
||||
sourceHot: 'Wiedergabe aus dem Cache',
|
||||
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Warteschlange einblenden',
|
||||
hideQueue: 'Warteschlange ausblenden',
|
||||
pinOnTop: 'Im Vordergrund halten',
|
||||
pinOff: 'Vordergrund deaktivieren',
|
||||
openMainWindow: 'Hauptfenster öffnen',
|
||||
close: 'Schließen',
|
||||
emptyQueue: 'Die Warteschlange ist leer',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
recentlyPlayed: 'Zuletzt gehört',
|
||||
@@ -956,6 +998,8 @@ export const deTranslation = {
|
||||
lyricsSourceLrclib: 'Quelle: LRCLIB',
|
||||
lyricsSourceNetease: 'Quelle: Netease',
|
||||
lyricsSourceLyricsplus: 'Quelle: YouLyPlus',
|
||||
showDuration: 'Dauer anzeigen',
|
||||
showRemainingTime: 'Verbleibende Zeit anzeigen',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
@@ -1135,9 +1179,24 @@ export const deTranslation = {
|
||||
free: 'frei',
|
||||
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
|
||||
chooseFolder: 'Auswählen…',
|
||||
filenameTemplate: 'Dateinamen-Vorlage',
|
||||
targetDevice: 'Zielgerät',
|
||||
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
schemaLabel: 'Benennungsschema',
|
||||
schemaHint: 'Festes Schema für zuverlässige Synchronisation über verschiedene Betriebssysteme hinweg. Playlists werden als .m3u8-Dateien geschrieben und referenzieren die Album-Tracks — keine Duplikate auf dem Gerät.',
|
||||
migrateButton: 'Bestehende Dateien re-organisieren…',
|
||||
migrateTooltip: 'Vorhandene Dateien auf dem Gerät nach dem neuen Schema umbenennen (basierend auf der alten Namensvorlage).',
|
||||
migrateTitle: 'Bestehende Dateien re-organisieren',
|
||||
migrateLoading: 'Analysiere vorhandene Dateien…',
|
||||
migrateNothingToDo: 'Alle vorhandenen Dateien entsprechen bereits dem neuen Schema — nichts zu tun.',
|
||||
migrateNoTemplate: 'Keine alte Namensvorlage auf dem Gerät gefunden. Migration ist nur möglich, wenn der Stick mit einer Psysonic-Version synchronisiert wurde, die konfigurierbare Vorlagen unterstützte.',
|
||||
migrateFilesToRename: 'Dateien werden umbenannt',
|
||||
migrateUnchanged: '{{n}} Dateien liegen bereits am korrekten Pfad',
|
||||
migrateCollisions: '{{n}} Dateien können nicht automatisch umbenannt werden (mehrere Tracks zeigen auf dasselbe Ziel). Sie bleiben unverändert — beim nächsten Sync werden sie neu heruntergeladen.',
|
||||
migratePreviewNote: 'Alte Vorlage: {{tpl}}',
|
||||
migrateExecuting: 'Dateien werden umbenannt…',
|
||||
migrateSuccess: '{{n}} Dateien erfolgreich umbenannt',
|
||||
migrateFailed: '{{n}} Umbenennungen fehlgeschlagen',
|
||||
migrateShowErrors: 'Fehler anzeigen',
|
||||
migrateStart: 'Umbenennen starten',
|
||||
onDevice: 'Auf dem Gerät',
|
||||
addSources: 'Hinzufügen…',
|
||||
colName: 'Name',
|
||||
@@ -1158,11 +1217,6 @@ export const deTranslation = {
|
||||
actionTransfer: 'Auf Gerät übertragen',
|
||||
actionDelete: 'Vom Gerät löschen',
|
||||
actionApplyAll: 'Änderungen synchronisieren',
|
||||
templatePreview: 'Vorschau',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Ordner',
|
||||
tokenSlashHint: '/ = Ordner-Trennzeichen',
|
||||
cancel: 'Abbrechen',
|
||||
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
|
||||
noSources: 'Bitte mindestens eine Quelle auswählen.',
|
||||
|
||||
+62
-8
@@ -219,6 +219,9 @@ export const enTranslation = {
|
||||
clearArtistFilter: 'Clear artist filter',
|
||||
noFilterResults: 'No results with selected filters.',
|
||||
allArtists: 'All Artists',
|
||||
topArtists: 'Top Artists by Favorites',
|
||||
topArtistsSongCount_one: '{{count}} song',
|
||||
topArtistsSongCount_other: '{{count}} songs',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Build a Mix',
|
||||
@@ -288,6 +291,12 @@ export const enTranslation = {
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
compilationLabel: 'Compilations',
|
||||
compilationOnly: 'Only compilations',
|
||||
compilationHide: 'Hide compilations',
|
||||
compilationTooltipAll: 'All albums · click: only compilations',
|
||||
compilationTooltipOnly: 'Only compilations · click: hide compilations',
|
||||
compilationTooltipHide: 'Compilations hidden · click: show all',
|
||||
select: 'Multi-select',
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
@@ -376,6 +385,7 @@ export const enTranslation = {
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Back',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
use: 'Use',
|
||||
@@ -409,6 +419,15 @@ export const enTranslation = {
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
updaterInstallNow: 'Install now',
|
||||
updaterMacReadyTitle: 'Ready to install',
|
||||
updaterMacReady: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
updaterTrustNotarized: 'Notarized by Apple',
|
||||
updaterTrustSignature: 'Signature verified',
|
||||
updaterMacDoneTitle: 'Update installed',
|
||||
updaterRestartingIn: 'Restarting in {{n}}s…',
|
||||
updaterRestarting: 'Restarting…',
|
||||
updaterRestartNow: 'Restart now',
|
||||
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||
durationMinutesOnly: '{{minutes}}m',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
@@ -536,6 +555,8 @@ export const enTranslation = {
|
||||
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
preloadMiniPlayer: 'Preload mini player',
|
||||
preloadMiniPlayerDesc: 'Build the mini player window in the background at app start so it shows content instantly on first open. Uses a little extra memory.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
useCustomTitlebar: 'Custom title bar',
|
||||
@@ -582,11 +603,13 @@ export const enTranslation = {
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
aboutReleaseNotesLabel: 'Release notes',
|
||||
aboutReleaseNotesLink: "Open this version's what's-new",
|
||||
aboutContributorsLabel: 'Contributors',
|
||||
aboutSpecialThanksLabel: 'Special Thanks',
|
||||
changelog: 'Changelog',
|
||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
|
||||
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
|
||||
randomMixTitle: 'Random Mix',
|
||||
randomMixBlacklistTitle: 'Custom Filter Keywords',
|
||||
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
|
||||
@@ -646,6 +669,7 @@ export const enTranslation = {
|
||||
shortcutOpenFolderBrowser: 'Open {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Fullscreen player',
|
||||
shortcutNativeFullscreen: 'Native fullscreen',
|
||||
shortcutOpenMiniPlayer: 'Open mini player',
|
||||
playbackTitle: 'Playback',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
|
||||
@@ -722,6 +746,14 @@ export const enTranslation = {
|
||||
dontShowAgain: "Don't show again",
|
||||
close: 'Got it',
|
||||
},
|
||||
whatsNew: {
|
||||
title: "What's New",
|
||||
empty: 'No changelog entry for this version yet.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: "What's new in v{{version}}",
|
||||
dismiss: 'Dismiss',
|
||||
close: 'Close',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
s1: 'Getting Started',
|
||||
@@ -877,6 +909,7 @@ export const enTranslation = {
|
||||
trackPlural: 'tracks',
|
||||
showRemaining: 'Show remaining time',
|
||||
showTotal: 'Show total time',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
@@ -884,6 +917,15 @@ export const enTranslation = {
|
||||
sourceHot: 'Playing from cache',
|
||||
sourceStream: 'Playing from network stream',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Show queue',
|
||||
hideQueue: 'Hide queue',
|
||||
pinOnTop: 'Pin on top',
|
||||
pinOff: 'Unpin',
|
||||
openMainWindow: 'Open main window',
|
||||
close: 'Close',
|
||||
emptyQueue: 'Queue is empty',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
recentlyPlayed: 'Recently Played',
|
||||
@@ -958,6 +1000,8 @@ export const enTranslation = {
|
||||
lyricsSourceLrclib: 'Source: LRCLIB',
|
||||
lyricsSourceNetease: 'Source: Netease',
|
||||
lyricsSourceLyricsplus: 'Source: YouLyPlus',
|
||||
showDuration: 'Show duration',
|
||||
showRemainingTime: 'Show remaining time',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
@@ -1137,9 +1181,24 @@ export const enTranslation = {
|
||||
free: 'free',
|
||||
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
|
||||
chooseFolder: 'Choose…',
|
||||
filenameTemplate: 'Filename Template',
|
||||
targetDevice: 'Target Device',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
schemaLabel: 'Naming scheme',
|
||||
schemaHint: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
|
||||
migrateButton: 'Reorganize existing files…',
|
||||
migrateTooltip: 'Rename existing files on the device into the new scheme (from the old filename template).',
|
||||
migrateTitle: 'Reorganize existing files',
|
||||
migrateLoading: 'Analyzing existing files…',
|
||||
migrateNothingToDo: 'All existing files already match the new scheme — nothing to do.',
|
||||
migrateNoTemplate: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.',
|
||||
migrateFilesToRename: 'files will be renamed',
|
||||
migrateUnchanged: '{{n}} files are already at the correct path',
|
||||
migrateCollisions: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
|
||||
migratePreviewNote: 'Old template: {{tpl}}',
|
||||
migrateExecuting: 'Renaming files…',
|
||||
migrateSuccess: '{{n}} files renamed successfully',
|
||||
migrateFailed: '{{n}} renames failed',
|
||||
migrateShowErrors: 'Show errors',
|
||||
migrateStart: 'Start renaming',
|
||||
onDevice: 'Device Manager',
|
||||
addSources: 'Add…',
|
||||
colName: 'Name',
|
||||
@@ -1161,11 +1220,6 @@ export const enTranslation = {
|
||||
actionTransfer: 'Transfer to Device',
|
||||
actionDelete: 'Delete from Device',
|
||||
actionApplyAll: 'Apply All Changes',
|
||||
templatePreview: 'Preview',
|
||||
templatePresetStandard: 'Standard',
|
||||
templatePresetMultiDisc: 'Multi-Disc',
|
||||
templatePresetAltFolder: 'Alt. Folder',
|
||||
tokenSlashHint: '/ = folder separator',
|
||||
cancel: 'Cancel',
|
||||
noTargetDir: 'Please choose a target folder first.',
|
||||
noSources: 'Please select at least one source.',
|
||||
|
||||
+35
-1
@@ -219,6 +219,9 @@ export const esTranslation = {
|
||||
clearArtistFilter: 'Limpiar filtro de artista',
|
||||
noFilterResults: 'No hay resultados con los filtros seleccionados.',
|
||||
allArtists: 'Todos los Artistas',
|
||||
topArtists: 'Artistas favoritos principales',
|
||||
topArtistsSongCount_one: '{{count}} canción',
|
||||
topArtistsSongCount_other: '{{count}} canciones',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Crear Mezcla',
|
||||
@@ -288,6 +291,12 @@ export const esTranslation = {
|
||||
yearTo: 'Hasta',
|
||||
yearFilterClear: 'Limpiar filtro de año',
|
||||
yearFilterLabel: 'Año',
|
||||
compilationLabel: 'Recopilatorios',
|
||||
compilationOnly: 'Solo recopilatorios',
|
||||
compilationHide: 'Ocultar recopilatorios',
|
||||
compilationTooltipAll: 'Todos los álbumes · clic: solo recopilatorios',
|
||||
compilationTooltipOnly: 'Solo recopilatorios · clic: ocultar recopilatorios',
|
||||
compilationTooltipHide: 'Recopilatorios ocultos · clic: mostrar todo',
|
||||
select: 'Selección múltiple',
|
||||
startSelect: 'Activar selección múltiple',
|
||||
cancelSelect: 'Cancelar',
|
||||
@@ -537,6 +546,8 @@ export const esTranslation = {
|
||||
showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.',
|
||||
minimizeToTray: 'Minimizar a Bandeja',
|
||||
minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.',
|
||||
preloadMiniPlayer: 'Precargar mini reproductor',
|
||||
preloadMiniPlayerDesc: 'Crea la ventana del mini reproductor en segundo plano al iniciar la aplicación para que muestre contenido al instante la primera vez que se abre. Consume un poco más de memoria.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
|
||||
useCustomTitlebar: 'Barra de título personalizada',
|
||||
@@ -583,11 +594,13 @@ export const esTranslation = {
|
||||
aboutVersion: 'Versión',
|
||||
aboutBuiltWith: 'Construido con Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Desarrollado con el apoyo de Claude Code by Anthropic',
|
||||
aboutReleaseNotesLabel: 'Notas de la versión',
|
||||
aboutReleaseNotesLink: 'Abrir las novedades de esta versión',
|
||||
aboutContributorsLabel: 'Contribuidores',
|
||||
aboutSpecialThanksLabel: 'Agradecimientos Especiales',
|
||||
changelog: 'Registro de Cambios',
|
||||
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
|
||||
showChangelogOnUpdateDesc: "Muestra automáticamente las novedades cuando se lanza una nueva versión por primera vez.",
|
||||
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
|
||||
randomMixTitle: 'Mezcla Aleatoria',
|
||||
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
|
||||
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
|
||||
@@ -647,6 +660,7 @@ export const esTranslation = {
|
||||
shortcutOpenFolderBrowser: 'Abrir {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Reproductor pantalla completa',
|
||||
shortcutNativeFullscreen: 'Pantalla completa nativa',
|
||||
shortcutOpenMiniPlayer: 'Abrir mini reproductor',
|
||||
playbackTitle: 'Reproducción',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normalizar volumen de pistas usando metadatos ReplayGain',
|
||||
@@ -723,6 +737,14 @@ export const esTranslation = {
|
||||
dontShowAgain: 'No mostrar de nuevo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Novedades',
|
||||
empty: 'Aún no hay entrada de changelog para esta versión.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: 'Novedades en v{{version}}',
|
||||
dismiss: 'Ocultar',
|
||||
close: 'Cerrar',
|
||||
},
|
||||
help: {
|
||||
title: 'Ayuda',
|
||||
s1: 'Primeros Pasos',
|
||||
@@ -878,6 +900,7 @@ export const esTranslation = {
|
||||
trackPlural: 'pistas',
|
||||
showRemaining: 'Mostrar tiempo restante',
|
||||
showTotal: 'Mostrar tiempo total',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pico {{pk}}',
|
||||
@@ -885,6 +908,15 @@ export const esTranslation = {
|
||||
sourceHot: 'Reproducción desde la caché',
|
||||
sourceStream: 'Reproducción desde la transmisión en red',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Mostrar cola',
|
||||
hideQueue: 'Ocultar cola',
|
||||
pinOnTop: 'Mantener encima',
|
||||
pinOff: 'Desfijar',
|
||||
openMainWindow: 'Abrir ventana principal',
|
||||
close: 'Cerrar',
|
||||
emptyQueue: 'La cola está vacía',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Estadísticas',
|
||||
recentlyPlayed: 'Reproducidos Recientemente',
|
||||
@@ -959,6 +991,8 @@ export const esTranslation = {
|
||||
lyricsSourceLrclib: 'Fuente: LRCLIB',
|
||||
lyricsSourceNetease: 'Fuente: Netease',
|
||||
lyricsSourceLyricsplus: 'Fuente: YouLyPlus',
|
||||
showDuration: 'Mostrar duración',
|
||||
showRemainingTime: 'Mostrar tiempo restante',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Información de Canción',
|
||||
|
||||
+35
-1
@@ -218,6 +218,9 @@ export const frTranslation = {
|
||||
clearArtistFilter: 'Effacer le filtre artiste',
|
||||
noFilterResults: 'Aucun résultat avec les filtres sélectionnés.',
|
||||
allArtists: 'Tous les artistes',
|
||||
topArtists: 'Artistes favoris principaux',
|
||||
topArtistsSongCount_one: '{{count}} morceau',
|
||||
topArtistsSongCount_other: '{{count}} morceaux',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Créer un mix',
|
||||
@@ -287,6 +290,12 @@ export const frTranslation = {
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
compilationLabel: 'Compilations',
|
||||
compilationOnly: 'Uniquement compilations',
|
||||
compilationHide: 'Masquer compilations',
|
||||
compilationTooltipAll: 'Tous les albums · clic : uniquement compilations',
|
||||
compilationTooltipOnly: 'Uniquement compilations · clic : masquer compilations',
|
||||
compilationTooltipHide: 'Compilations masquées · clic : tout afficher',
|
||||
select: 'Sélection multiple',
|
||||
startSelect: 'Activer la sélection multiple',
|
||||
cancelSelect: 'Annuler',
|
||||
@@ -534,6 +543,8 @@ export const frTranslation = {
|
||||
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
preloadMiniPlayer: 'Précharger le mini-lecteur',
|
||||
preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.',
|
||||
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -578,11 +589,13 @@ export const frTranslation = {
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
|
||||
aboutReleaseNotesLabel: 'Notes de version',
|
||||
aboutReleaseNotesLink: 'Ouvrir les nouveautés de cette version',
|
||||
aboutContributorsLabel: 'Contributeurs',
|
||||
aboutSpecialThanksLabel: 'Remerciements spéciaux',
|
||||
changelog: 'Journal des modifications',
|
||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
|
||||
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
|
||||
randomMixTitle: 'Mix aléatoire',
|
||||
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
|
||||
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
|
||||
@@ -619,6 +632,7 @@ export const frTranslation = {
|
||||
shortcutOpenFolderBrowser: 'Ouvrir {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
shortcutOpenMiniPlayer: 'Ouvrir le mini-lecteur',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
ratingsSectionTitle: 'Notes',
|
||||
@@ -718,6 +732,14 @@ export const frTranslation = {
|
||||
dontShowAgain: 'Ne plus afficher',
|
||||
close: 'Compris',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Quoi de neuf',
|
||||
empty: 'Aucune entrée de changelog pour cette version.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: 'Nouveau dans v{{version}}',
|
||||
dismiss: 'Ignorer',
|
||||
close: 'Fermer',
|
||||
},
|
||||
help: {
|
||||
title: 'Aide',
|
||||
s1: 'Démarrage',
|
||||
@@ -873,6 +895,7 @@ export const frTranslation = {
|
||||
trackPlural: 'pistes',
|
||||
showRemaining: 'Afficher le temps restant',
|
||||
showTotal: 'Afficher la durée totale',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pic {{pk}}',
|
||||
@@ -880,6 +903,15 @@ export const frTranslation = {
|
||||
sourceHot: 'Lecture depuis le cache',
|
||||
sourceStream: 'Lecture depuis le flux réseau',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Afficher la file',
|
||||
hideQueue: 'Masquer la file',
|
||||
pinOnTop: 'Toujours visible',
|
||||
pinOff: 'Désépingler',
|
||||
openMainWindow: 'Ouvrir la fenêtre principale',
|
||||
close: 'Fermer',
|
||||
emptyQueue: 'La file est vide',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiques',
|
||||
recentlyPlayed: 'Écoutés récemment',
|
||||
@@ -954,6 +986,8 @@ export const frTranslation = {
|
||||
lyricsSourceLrclib: 'Source : LRCLIB',
|
||||
lyricsSourceNetease: 'Source : Netease',
|
||||
lyricsSourceLyricsplus: 'Source : YouLyPlus',
|
||||
showDuration: 'Afficher la durée',
|
||||
showRemainingTime: 'Afficher le temps restant',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
|
||||
+35
-1
@@ -218,6 +218,9 @@ export const nbTranslation = {
|
||||
clearArtistFilter: 'Tøm artistfilter',
|
||||
noFilterResults: 'Ingen resultater med valgte filtre.',
|
||||
allArtists: 'Alle artister',
|
||||
topArtists: 'Toppartister etter favoritter',
|
||||
topArtistsSongCount_one: '{{count}} sang',
|
||||
topArtistsSongCount_other: '{{count}} sanger',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Lag en miks',
|
||||
@@ -287,6 +290,12 @@ export const nbTranslation = {
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
compilationLabel: 'Samleplater',
|
||||
compilationOnly: 'Kun samleplater',
|
||||
compilationHide: 'Skjul samleplater',
|
||||
compilationTooltipAll: 'Alle album · klikk: kun samleplater',
|
||||
compilationTooltipOnly: 'Kun samleplater · klikk: skjul samleplater',
|
||||
compilationTooltipHide: 'Samleplater skjult · klikk: vis alle',
|
||||
select: 'Multivalg',
|
||||
startSelect: 'Aktiver multivalg',
|
||||
cancelSelect: 'Avbryt',
|
||||
@@ -533,6 +542,8 @@ export const nbTranslation = {
|
||||
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
|
||||
minimizeToTray: 'Minimer til oppgavelinjen',
|
||||
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
|
||||
preloadMiniPlayer: 'Forhåndslast miniavspiller',
|
||||
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
|
||||
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -577,11 +588,13 @@ export const nbTranslation = {
|
||||
aboutVersion: 'Versjon',
|
||||
aboutBuiltWith: 'Bygget med Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Utviklet med støtte fra Claude Code laget av Anthropic',
|
||||
aboutReleaseNotesLabel: 'Versjonsnotater',
|
||||
aboutReleaseNotesLink: 'Åpne nyhetene for denne versjonen',
|
||||
aboutContributorsLabel: 'Bidragsytere',
|
||||
aboutSpecialThanksLabel: 'Spesiell takk',
|
||||
changelog: 'Endringslogg',
|
||||
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
|
||||
showChangelogOnUpdateDesc: "Vis automatisk hva som er nytt når en ny versjon lanseres for første gang.",
|
||||
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
|
||||
randomMixTitle: 'Tilfeldig miks',
|
||||
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
|
||||
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
|
||||
@@ -641,6 +654,7 @@ export const nbTranslation = {
|
||||
shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Fullskjermsspiller',
|
||||
shortcutNativeFullscreen: 'Naturlig fullskjerm',
|
||||
shortcutOpenMiniPlayer: 'Åpne minispiller',
|
||||
playbackTitle: 'Avspilling',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata',
|
||||
@@ -717,6 +731,14 @@ export const nbTranslation = {
|
||||
dontShowAgain: "Ikke vis igjen",
|
||||
close: 'Forstått',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Nyheter',
|
||||
empty: 'Ingen endringslogg for denne versjonen ennå.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: 'Nyheter i v{{version}}',
|
||||
dismiss: 'Skjul',
|
||||
close: 'Lukk',
|
||||
},
|
||||
help: {
|
||||
title: 'Hjelp',
|
||||
s1: 'Komme i gang',
|
||||
@@ -872,6 +894,7 @@ export const nbTranslation = {
|
||||
trackPlural: 'spor',
|
||||
showRemaining: 'Vis gjenværende tid',
|
||||
showTotal: 'Vis total tid',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Topp {{pk}}',
|
||||
@@ -879,6 +902,15 @@ export const nbTranslation = {
|
||||
sourceHot: 'Spiller fra cache',
|
||||
sourceStream: 'Spiller fra nettverksstrøm',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Vis kø',
|
||||
hideQueue: 'Skjul kø',
|
||||
pinOnTop: 'Hold øverst',
|
||||
pinOff: 'Løsne',
|
||||
openMainWindow: 'Åpne hovedvindu',
|
||||
close: 'Lukk',
|
||||
emptyQueue: 'Køen er tom',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistikk',
|
||||
recentlyPlayed: 'Nylig spilt',
|
||||
@@ -953,6 +985,8 @@ export const nbTranslation = {
|
||||
lyricsSourceLrclib: 'Kilde: LRCLIB',
|
||||
lyricsSourceNetease: 'Kilde: Netease',
|
||||
lyricsSourceLyricsplus: 'Kilde: YouLyPlus',
|
||||
showDuration: 'Vis varighet',
|
||||
showRemainingTime: 'Vis gjenværende tid',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Sanginfo',
|
||||
|
||||
+35
-1
@@ -217,6 +217,9 @@ export const nlTranslation = {
|
||||
clearArtistFilter: 'Artiestfilter wissen',
|
||||
noFilterResults: 'Geen resultaten met de geselecteerde filters.',
|
||||
allArtists: 'Alle artiesten',
|
||||
topArtists: 'Top-artiesten op favorieten',
|
||||
topArtistsSongCount_one: '{{count}} nummer',
|
||||
topArtistsSongCount_other: '{{count}} nummers',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Mix samenstellen',
|
||||
@@ -286,6 +289,12 @@ export const nlTranslation = {
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
compilationLabel: 'Compilaties',
|
||||
compilationOnly: 'Alleen compilaties',
|
||||
compilationHide: 'Compilaties verbergen',
|
||||
compilationTooltipAll: 'Alle albums · klik: alleen compilaties',
|
||||
compilationTooltipOnly: 'Alleen compilaties · klik: compilaties verbergen',
|
||||
compilationTooltipHide: 'Compilaties verborgen · klik: toon alles',
|
||||
select: 'Meervoudige selectie',
|
||||
startSelect: 'Meervoudige selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
@@ -533,6 +542,8 @@ export const nlTranslation = {
|
||||
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
preloadMiniPlayer: 'Mini-speler vooraf laden',
|
||||
preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.',
|
||||
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -577,11 +588,13 @@ export const nlTranslation = {
|
||||
aboutVersion: 'Versie',
|
||||
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
|
||||
aboutReleaseNotesLabel: 'Release-notities',
|
||||
aboutReleaseNotesLink: 'Nieuws van deze versie openen',
|
||||
aboutContributorsLabel: 'Bijdragers',
|
||||
aboutSpecialThanksLabel: 'Speciale dank',
|
||||
changelog: 'Wijzigingslog',
|
||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
|
||||
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
|
||||
randomMixTitle: 'Willekeurige mix',
|
||||
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
|
||||
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
|
||||
@@ -618,6 +631,7 @@ export const nlTranslation = {
|
||||
shortcutOpenFolderBrowser: '{{folderBrowser}} openen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
shortcutOpenMiniPlayer: 'Mini-speler openen',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
ratingsSectionTitle: 'Beoordelingen',
|
||||
@@ -717,6 +731,14 @@ export const nlTranslation = {
|
||||
dontShowAgain: 'Niet meer weergeven',
|
||||
close: 'Begrepen',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Wat is nieuw',
|
||||
empty: 'Nog geen changelog-item voor deze versie.',
|
||||
bannerTitle: 'Changelog',
|
||||
bannerCollapsed: 'Nieuw in v{{version}}',
|
||||
dismiss: 'Verbergen',
|
||||
close: 'Sluiten',
|
||||
},
|
||||
help: {
|
||||
title: 'Help',
|
||||
s1: 'Aan de slag',
|
||||
@@ -872,6 +894,7 @@ export const nlTranslation = {
|
||||
trackPlural: 'nummers',
|
||||
showRemaining: 'Resterende tijd tonen',
|
||||
showTotal: 'Totale tijd tonen',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Piek {{pk}}',
|
||||
@@ -879,6 +902,15 @@ export const nlTranslation = {
|
||||
sourceHot: 'Afspelen vanuit cache',
|
||||
sourceStream: 'Afspelen vanuit netwerkstream',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Wachtrij tonen',
|
||||
hideQueue: 'Wachtrij verbergen',
|
||||
pinOnTop: 'Altijd boven',
|
||||
pinOff: 'Losmaken',
|
||||
openMainWindow: 'Hoofdvenster openen',
|
||||
close: 'Sluiten',
|
||||
emptyQueue: 'Wachtrij is leeg',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistieken',
|
||||
recentlyPlayed: 'Recent afgespeeld',
|
||||
@@ -953,6 +985,8 @@ export const nlTranslation = {
|
||||
lyricsSourceLrclib: 'Bron: LRCLIB',
|
||||
lyricsSourceNetease: 'Bron: Netease',
|
||||
lyricsSourceLyricsplus: 'Bron: YouLyPlus',
|
||||
showDuration: 'Toon duur',
|
||||
showRemainingTime: 'Toon resterende tijd',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
|
||||
+37
-1
@@ -222,6 +222,11 @@ export const ruTranslation = {
|
||||
clearArtistFilter: 'Сбросить фильтр исполнителя',
|
||||
noFilterResults: 'Нет результатов с выбранными фильтрами.',
|
||||
allArtists: 'Все исполнители',
|
||||
topArtists: 'Топ исполнителей по избранному',
|
||||
topArtistsSongCount_one: '{{count}} трек',
|
||||
topArtistsSongCount_few: '{{count}} трека',
|
||||
topArtistsSongCount_many: '{{count}} треков',
|
||||
topArtistsSongCount_other: '{{count}} трека',
|
||||
},
|
||||
randomLanding: {
|
||||
title: 'Собрать микс',
|
||||
@@ -296,6 +301,12 @@ export const ruTranslation = {
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
compilationLabel: 'Сборники',
|
||||
compilationOnly: 'Только сборники',
|
||||
compilationHide: 'Скрыть сборники',
|
||||
compilationTooltipAll: 'Все альбомы · клик: только сборники',
|
||||
compilationTooltipOnly: 'Только сборники · клик: скрыть сборники',
|
||||
compilationTooltipHide: 'Сборники скрыты · клик: показать всё',
|
||||
select: 'Множественный выбор',
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
@@ -552,6 +563,8 @@ export const ruTranslation = {
|
||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||
minimizeToTray: 'Сворачивать в трей',
|
||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||
preloadMiniPlayer: 'Предзагрузка мини-плеера',
|
||||
preloadMiniPlayerDesc: 'Создаёт окно мини-плеера в фоне при запуске приложения, чтобы при первом открытии содержимое отображалось мгновенно. Использует немного больше памяти.',
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
|
||||
@@ -602,11 +615,13 @@ export const ruTranslation = {
|
||||
aboutVersion: 'Версия',
|
||||
aboutBuiltWith: 'Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'При поддержке Claude Code (Anthropic)',
|
||||
aboutReleaseNotesLabel: 'Примечания к выпуску',
|
||||
aboutReleaseNotesLink: 'Открыть «Что нового» этой версии',
|
||||
aboutContributorsLabel: 'Участники',
|
||||
aboutSpecialThanksLabel: 'Особая благодарность',
|
||||
changelog: 'Список изменений',
|
||||
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
||||
showChangelogOnUpdateDesc: 'Автоматически при первом запуске новой версии.',
|
||||
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
|
||||
randomMixTitle: 'Случайный микс',
|
||||
randomMixBlacklistTitle: 'Свои слова-фильтры',
|
||||
randomMixBlacklistDesc:
|
||||
@@ -669,6 +684,7 @@ export const ruTranslation = {
|
||||
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: 'Полноэкранный плеер',
|
||||
shortcutNativeFullscreen: 'Системный полный экран',
|
||||
shortcutOpenMiniPlayer: 'Открыть мини-плеер',
|
||||
playbackTitle: 'Воспроизведение',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Выравнивание громкости по метаданным ReplayGain',
|
||||
@@ -745,6 +761,14 @@ export const ruTranslation = {
|
||||
dontShowAgain: 'Больше не показывать',
|
||||
close: 'Понятно',
|
||||
},
|
||||
whatsNew: {
|
||||
title: 'Что нового',
|
||||
empty: 'Для этой версии пока нет записи в журнале изменений.',
|
||||
bannerTitle: 'Журнал изменений',
|
||||
bannerCollapsed: 'Что нового в v{{version}}',
|
||||
dismiss: 'Скрыть',
|
||||
close: 'Закрыть',
|
||||
},
|
||||
help: {
|
||||
title: 'Справка',
|
||||
s1: 'С чего начать',
|
||||
@@ -923,6 +947,7 @@ export const ruTranslation = {
|
||||
trackPlural: 'треков',
|
||||
showRemaining: 'Осталось',
|
||||
showTotal: 'Всего',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: 'Т {{db}} дБ',
|
||||
rgAlbum: 'А {{db}} дБ',
|
||||
rgPeak: 'Пик {{pk}}',
|
||||
@@ -930,6 +955,15 @@ export const ruTranslation = {
|
||||
sourceHot: 'Играет из кэша',
|
||||
sourceStream: 'Играет из сетевого потока',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: 'Показать очередь',
|
||||
hideQueue: 'Скрыть очередь',
|
||||
pinOnTop: 'Поверх окон',
|
||||
pinOff: 'Открепить',
|
||||
openMainWindow: 'Открыть главное окно',
|
||||
close: 'Закрыть',
|
||||
emptyQueue: 'Очередь пуста',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Статистика',
|
||||
recentlyPlayed: 'Недавно проиграно',
|
||||
@@ -1012,6 +1046,8 @@ export const ruTranslation = {
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceNetease: 'Источник: Netease',
|
||||
lyricsSourceLyricsplus: 'Источник: YouLyPlus',
|
||||
showDuration: 'Показать длительность',
|
||||
showRemainingTime: 'Показать оставшееся время',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
|
||||
+35
-1
@@ -217,6 +217,9 @@ export const zhTranslation = {
|
||||
clearArtistFilter: '清除艺术家筛选',
|
||||
noFilterResults: '所选筛选条件下无结果。',
|
||||
allArtists: '全部艺术家',
|
||||
topArtists: '按收藏数排行的艺术家',
|
||||
topArtistsSongCount_one: '{{count}} 首歌曲',
|
||||
topArtistsSongCount_other: '{{count}} 首歌曲',
|
||||
},
|
||||
randomLanding: {
|
||||
title: '创建混音',
|
||||
@@ -286,6 +289,12 @@ export const zhTranslation = {
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
compilationLabel: '合辑',
|
||||
compilationOnly: '仅合辑',
|
||||
compilationHide: '隐藏合辑',
|
||||
compilationTooltipAll: '所有专辑 · 点击:仅合辑',
|
||||
compilationTooltipOnly: '仅合辑 · 点击:隐藏合辑',
|
||||
compilationTooltipHide: '已隐藏合辑 · 点击:显示全部',
|
||||
select: '多选',
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
@@ -529,6 +538,8 @@ export const zhTranslation = {
|
||||
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
preloadMiniPlayer: '预加载迷你播放器',
|
||||
preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。',
|
||||
linuxWebkitSmoothScroll: '滚轮平滑(Linux)',
|
||||
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
@@ -573,11 +584,13 @@ export const zhTranslation = {
|
||||
aboutVersion: '版本',
|
||||
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
|
||||
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
|
||||
aboutReleaseNotesLabel: '版本说明',
|
||||
aboutReleaseNotesLink: '打开此版本的新功能',
|
||||
aboutContributorsLabel: '贡献者',
|
||||
aboutSpecialThanksLabel: '特别感谢',
|
||||
changelog: '更新日志',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
|
||||
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
|
||||
randomMixTitle: '随机混音',
|
||||
randomMixBlacklistTitle: '自定义过滤关键词',
|
||||
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
|
||||
@@ -637,6 +650,7 @@ export const zhTranslation = {
|
||||
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
|
||||
shortcutFullscreenPlayer: '全屏播放器',
|
||||
shortcutNativeFullscreen: '原生全屏',
|
||||
shortcutOpenMiniPlayer: '打开迷你播放器',
|
||||
playbackTitle: '播放',
|
||||
replayGain: '回放增益',
|
||||
replayGainDesc: '使用 ReplayGain 元数据标准化曲目音量',
|
||||
@@ -713,6 +727,14 @@ export const zhTranslation = {
|
||||
dontShowAgain: '不再显示',
|
||||
close: '知道了',
|
||||
},
|
||||
whatsNew: {
|
||||
title: '新功能',
|
||||
empty: '此版本暂无更新日志。',
|
||||
bannerTitle: '更新日志',
|
||||
bannerCollapsed: 'v{{version}} 新功能',
|
||||
dismiss: '关闭',
|
||||
close: '关闭',
|
||||
},
|
||||
help: {
|
||||
title: '帮助',
|
||||
s1: '入门指南',
|
||||
@@ -868,6 +890,7 @@ export const zhTranslation = {
|
||||
trackPlural: '首曲目',
|
||||
showRemaining: '显示剩余时间',
|
||||
showTotal: '显示总时间',
|
||||
replayGain: 'ReplayGain',
|
||||
rgTrack: '曲目 {{db}} dB',
|
||||
rgAlbum: '专辑 {{db}} dB',
|
||||
rgPeak: '峰值 {{pk}}',
|
||||
@@ -875,6 +898,15 @@ export const zhTranslation = {
|
||||
sourceHot: '正在从缓存播放',
|
||||
sourceStream: '正在从网络流播放',
|
||||
},
|
||||
miniPlayer: {
|
||||
showQueue: '显示队列',
|
||||
hideQueue: '隐藏队列',
|
||||
pinOnTop: '置顶',
|
||||
pinOff: '取消置顶',
|
||||
openMainWindow: '打开主窗口',
|
||||
close: '关闭',
|
||||
emptyQueue: '队列为空',
|
||||
},
|
||||
statistics: {
|
||||
title: '统计',
|
||||
recentlyPlayed: '最近播放',
|
||||
@@ -949,6 +981,8 @@ export const zhTranslation = {
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
lyricsSourceNetease: '来源:网易云',
|
||||
lyricsSourceLyricsplus: '来源:YouLyPlus',
|
||||
showDuration: '显示时长',
|
||||
showRemainingTime: '显示剩余时间',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './styles/theme.css';
|
||||
import './styles/layout.css';
|
||||
import './styles/components.css';
|
||||
|
||||
// Expose the Tauri window label synchronously so App() can pick its root
|
||||
// component (main app vs mini player) on first render without flicker.
|
||||
try {
|
||||
(window as any).__PSY_WINDOW_LABEL__ = getCurrentWindow().label;
|
||||
} catch {
|
||||
(window as any).__PSY_WINDOW_LABEL__ = 'main';
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+51
-53
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import YearFilterButton from '../components/YearFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -11,12 +13,12 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3 } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
type CompFilter = 'all' | 'only' | 'hide';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||
@@ -44,6 +46,7 @@ export default function Albums() {
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [compFilter, setCompFilter] = useState<CompFilter>('all');
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
@@ -68,9 +71,19 @@ export default function Albums() {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const visibleAlbums = useMemo(() => {
|
||||
if (compFilter === 'all') return albums;
|
||||
if (compFilter === 'only') return albums.filter(a => a.isCompilation);
|
||||
return albums.filter(a => !a.isCompilation);
|
||||
}, [albums, compFilter]);
|
||||
|
||||
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const cycleCompFilter = () => {
|
||||
setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all');
|
||||
};
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||||
@@ -179,8 +192,6 @@ export default function Albums() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const clearYear = () => { setYearFrom(''); setYearTo(''); };
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
@@ -188,7 +199,7 @@ export default function Albums() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
@@ -208,54 +219,41 @@ export default function Albums() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setSort(o.value)}
|
||||
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
{!yearActive && (
|
||||
<SortDropdown
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onChange={setSort}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<YearFilterButton
|
||||
from={yearFrom}
|
||||
to={yearTo}
|
||||
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
|
||||
/>
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
data-tooltip={
|
||||
compFilter === 'all' ? t('albums.compilationTooltipAll')
|
||||
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
|
||||
: t('albums.compilationTooltipHide')
|
||||
}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
}}
|
||||
>
|
||||
<Disc3 size={14} />
|
||||
{compFilter === 'all' ? t('albums.compilationLabel')
|
||||
: compFilter === 'only' ? t('albums.compilationOnly')
|
||||
: t('albums.compilationHide')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -279,7 +277,7 @@ export default function Albums() {
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => (
|
||||
{visibleAlbums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
|
||||
+68
-66
@@ -172,77 +172,79 @@ export default function Artists() {
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('artists.selectionCount', { count: selectedIds.size })
|
||||
: t('artists.title')}
|
||||
</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder={t('artists.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="artist-filter-input"
|
||||
/>
|
||||
<div className="page-sticky-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<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 }}
|
||||
placeholder={t('artists.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="artist-filter-input"
|
||||
/>
|
||||
</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${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)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||
</button>
|
||||
</div>
|
||||
</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${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)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
|
||||
{ALPHABET.map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
||||
>
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '2rem' }}>
|
||||
{ALPHABET.map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
||||
>
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
|
||||
+376
-152
@@ -17,6 +17,7 @@ import {
|
||||
SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
|
||||
} from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
@@ -24,6 +25,40 @@ type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
function uuid(): string { return crypto.randomUUID(); }
|
||||
|
||||
// Same sanitize rules the Rust side uses (`sanitize_path_component`): strip
|
||||
// Windows-illegal chars and control chars, trim leading/trailing dots + spaces.
|
||||
// Kept in JS only for the migration flow — computes the *old* path under a
|
||||
// user-supplied template so we can diff against the current files on disk.
|
||||
function sanitizeComponent(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
||||
}
|
||||
|
||||
interface OldTemplateTrack {
|
||||
artist: string;
|
||||
album: string;
|
||||
title: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
year?: number;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
/** Renders a track's path under a legacy (user-configurable) template. Used only
|
||||
* for the migration preview — the live sync flow goes through Rust's fixed
|
||||
* `build_track_path`. */
|
||||
function applyLegacyTemplate(template: string, track: OldTemplateTrack): string {
|
||||
const relative = template
|
||||
.replace(/\{artist\}/g, sanitizeComponent(track.artist))
|
||||
.replace(/\{album\}/g, sanitizeComponent(track.album))
|
||||
.replace(/\{title\}/g, sanitizeComponent(track.title))
|
||||
.replace(/\{track_number\}/g, track.trackNumber != null ? String(track.trackNumber).padStart(2, '0') : '')
|
||||
.replace(/\{disc_number\}/g, track.discNumber != null ? String(track.discNumber) : '')
|
||||
.replace(/\{year\}/g, track.year != null ? String(track.year) : '');
|
||||
const withExt = `${relative}.${track.suffix}`;
|
||||
return IS_WINDOWS ? withExt.replace(/\//g, '\\') : withExt;
|
||||
}
|
||||
|
||||
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
||||
@@ -33,16 +68,31 @@ async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicS
|
||||
return all;
|
||||
}
|
||||
|
||||
function trackToSyncInfo(track: SubsonicSong, url: string) {
|
||||
/** Tracks that came from `calculate_sync_payload` may carry embedded playlist
|
||||
* context so the follow-up `sync_batch_to_device` call knows to place them
|
||||
* under `Playlists/{Name}/` instead of the album tree. */
|
||||
type SyncTrackMaybePlaylist = SubsonicSong & { _playlistName?: string; _playlistIndex?: number };
|
||||
|
||||
function trackToSyncInfo(
|
||||
track: SyncTrackMaybePlaylist,
|
||||
url: string,
|
||||
playlistCtx?: { name: string; index: number },
|
||||
) {
|
||||
// Fall back to track artist when the file has no albumArtist tag — not every
|
||||
// library is tagged with it. Treat empty strings as missing (some Subsonic
|
||||
// servers return "" rather than omitting the field).
|
||||
const albumArtist = (track.albumArtist?.trim() || track.artist?.trim() || '');
|
||||
return {
|
||||
id: track.id, url,
|
||||
suffix: track.suffix ?? 'mp3',
|
||||
artist: track.artist ?? '',
|
||||
albumArtist,
|
||||
album: track.album ?? '',
|
||||
title: track.title ?? '',
|
||||
trackNumber: track.track,
|
||||
discNumber: track.discNumber,
|
||||
year: track.year,
|
||||
duration: track.duration,
|
||||
playlistName: playlistCtx?.name ?? track._playlistName,
|
||||
playlistIndex: playlistCtx?.index ?? track._playlistIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,14 +121,13 @@ export default function DeviceSync() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const targetDir = useDeviceSyncStore(s => s.targetDir);
|
||||
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
|
||||
const sources = useDeviceSyncStore(s => s.sources);
|
||||
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
|
||||
const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion);
|
||||
const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths);
|
||||
const scanning = useDeviceSyncStore(s => s.scanning);
|
||||
const {
|
||||
setTargetDir, setFilenameTemplate, addSource, removeSource,
|
||||
setTargetDir, addSource, removeSource,
|
||||
clearSources, toggleChecked, setCheckedIds, markForDeletion,
|
||||
unmarkDeletion, removeSources, setDeviceFilePaths, setScanning,
|
||||
} = useDeviceSyncStore.getState();
|
||||
@@ -103,9 +152,6 @@ export default function DeviceSync() {
|
||||
|
||||
// Map source IDs → computed device paths (for status derivation)
|
||||
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
|
||||
// Template stored in the manifest — may differ from local filenameTemplate when reading a
|
||||
// manifest written on another OS. Used only for status checks, not for new syncs.
|
||||
const [deviceManifestTemplate, setDeviceManifestTemplate] = useState<string | null>(null);
|
||||
|
||||
// ─── Removable drive detection ──────────────────────────────────────────
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
@@ -115,6 +161,15 @@ export default function DeviceSync() {
|
||||
const [preSyncLoading, setPreSyncLoading] = useState(false);
|
||||
const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
|
||||
|
||||
// ─── Migration (rename existing files into the fixed scheme) ────────────
|
||||
type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
|
||||
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
|
||||
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
|
||||
const [migrationPairs, setMigrationPairs] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationCollisions, setMigrationCollisions] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
|
||||
const [migrationResult, setMigrationResult] = useState<{ ok: number; failed: number; errors: string[] } | null>(null);
|
||||
|
||||
const refreshDrives = useCallback(async () => {
|
||||
setDrivesLoading(true);
|
||||
try {
|
||||
@@ -170,13 +225,12 @@ export default function DeviceSync() {
|
||||
useEffect(() => {
|
||||
if (!targetDir || !driveDetected || manifestImportedRef.current) return;
|
||||
manifestImportedRef.current = true;
|
||||
invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
|
||||
invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
).then(manifest => {
|
||||
if (manifest?.sources?.length) {
|
||||
useDeviceSyncStore.getState().clearSources();
|
||||
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
|
||||
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
|
||||
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -186,7 +240,6 @@ export default function DeviceSync() {
|
||||
useEffect(() => {
|
||||
if (!driveDetected) {
|
||||
setDeviceFilePaths([]);
|
||||
setDeviceManifestTemplate(null);
|
||||
manifestImportedRef.current = false;
|
||||
}
|
||||
}, [driveDetected]);
|
||||
@@ -197,9 +250,7 @@ export default function DeviceSync() {
|
||||
setSourcePathsMap(new Map());
|
||||
return;
|
||||
}
|
||||
// Use the template from the manifest (written by the original sync machine) so that
|
||||
// status checks work correctly even when the local template differs (e.g. Windows→Linux).
|
||||
const templateForStatus = deviceManifestTemplate ?? filenameTemplate;
|
||||
// Path schema is fixed in the Rust backend now — no template parameter.
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
@@ -208,9 +259,11 @@ export default function DeviceSync() {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map(t => trackToSyncInfo(t, '')),
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
template: templateForStatus,
|
||||
});
|
||||
map.set(source.id, paths);
|
||||
} catch {
|
||||
@@ -220,7 +273,7 @@ export default function DeviceSync() {
|
||||
if (!cancelled) setSourcePathsMap(map);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [targetDir, filenameTemplate, deviceManifestTemplate, sources]);
|
||||
}, [targetDir, sources]);
|
||||
|
||||
// Derive sync status per source
|
||||
const sourceStatuses = useMemo(() => {
|
||||
@@ -300,8 +353,24 @@ export default function DeviceSync() {
|
||||
5000, 'info'
|
||||
);
|
||||
// Write manifest so another machine can read the synced sources from the stick
|
||||
const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState();
|
||||
if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {});
|
||||
const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState();
|
||||
if (dir) {
|
||||
invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {});
|
||||
// For every playlist source, write an Extended-M3U next to the
|
||||
// playlist-folder tracks. Context carries the playlist name +
|
||||
// per-track index so the filenames match the files we just synced.
|
||||
const playlistSources = srcs.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: dir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* m3u8 failure is non-fatal — skip silently */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-scan the device after sync completes (cancelled or not)
|
||||
scanDevice();
|
||||
@@ -380,6 +449,119 @@ export default function DeviceSync() {
|
||||
const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]);
|
||||
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
|
||||
|
||||
// ─── Migration handlers ─────────────────────────────────────────────────
|
||||
const startMigrationPreview = async () => {
|
||||
if (!targetDir || sources.length === 0) return;
|
||||
setMigrationPhase('loading');
|
||||
setMigrationResult(null);
|
||||
try {
|
||||
// Look up the old template from the v1 manifest on disk.
|
||||
const manifest = await invoke<{ version: number; filenameTemplate?: string } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
);
|
||||
const oldTemplate = manifest?.filenameTemplate?.trim() || '';
|
||||
if (!oldTemplate) {
|
||||
// v2 manifest or missing — nothing to migrate from.
|
||||
setMigrationPhase('nothing');
|
||||
return;
|
||||
}
|
||||
setMigrationOldTemplate(oldTemplate);
|
||||
|
||||
// Migration only renames tracks that came from album/artist sources —
|
||||
// under the old template all tracks lived in a flat album tree. Playlist
|
||||
// sources get their own `Playlists/{name}/…` folder under the new scheme,
|
||||
// so the files they need are a subset (or copies) of the album tracks and
|
||||
// are cleaner to just re-download on the next sync.
|
||||
const albumSourceTracks: SubsonicSong[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const source of sources.filter(s => s.type !== 'playlist')) {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
for (const tr of tracks) {
|
||||
if (seenIds.has(tr.id)) continue;
|
||||
seenIds.add(tr.id);
|
||||
albumSourceTracks.push(tr);
|
||||
}
|
||||
} catch { /* skip unreachable source */ }
|
||||
}
|
||||
|
||||
// New paths via Rust (fixed album-tree schema).
|
||||
const newAbsPaths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')),
|
||||
destDir: targetDir,
|
||||
});
|
||||
const sepChar = IS_WINDOWS ? '\\' : '/';
|
||||
const prefix = targetDir.endsWith(sepChar) ? targetDir : targetDir + sepChar;
|
||||
const newRelPaths = newAbsPaths.map(p => p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
||||
|
||||
// Old paths via the legacy template (JS).
|
||||
const oldRelPaths = albumSourceTracks.map(tr => applyLegacyTemplate(oldTemplate, {
|
||||
artist: tr.artist ?? '',
|
||||
album: tr.album ?? '',
|
||||
title: tr.title ?? '',
|
||||
trackNumber: tr.track,
|
||||
discNumber: tr.discNumber,
|
||||
year: tr.year,
|
||||
suffix: tr.suffix ?? 'mp3',
|
||||
}));
|
||||
|
||||
const pairs: { old: string; new: string }[] = [];
|
||||
const collisions: { old: string; new: string }[] = [];
|
||||
const newPathCounts = new Map<string, number>();
|
||||
let unchanged = 0;
|
||||
|
||||
for (let i = 0; i < albumSourceTracks.length; i++) {
|
||||
const o = oldRelPaths[i];
|
||||
const n = newRelPaths[i];
|
||||
if (o === n) { unchanged += 1; continue; }
|
||||
newPathCounts.set(n, (newPathCounts.get(n) ?? 0) + 1);
|
||||
pairs.push({ old: o, new: n });
|
||||
}
|
||||
// Two separate old files mapping onto the same new path → collision.
|
||||
const colliding = new Set([...newPathCounts.entries()].filter(([, c]) => c > 1).map(([p]) => p));
|
||||
const cleanPairs = pairs.filter(p => !colliding.has(p.new));
|
||||
for (const p of pairs.filter(p => colliding.has(p.new))) collisions.push(p);
|
||||
|
||||
setMigrationPairs(cleanPairs);
|
||||
setMigrationCollisions(collisions);
|
||||
setMigrationUnchanged(unchanged);
|
||||
setMigrationPhase(cleanPairs.length === 0 && collisions.length === 0 ? 'nothing' : 'preview');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: 0, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
};
|
||||
|
||||
const executeMigration = async () => {
|
||||
if (!targetDir || migrationPairs.length === 0) { setMigrationPhase('closed'); return; }
|
||||
setMigrationPhase('executing');
|
||||
try {
|
||||
const results = await invoke<{ oldPath: string; newPath: string; ok: boolean; error: string | null }[]>(
|
||||
'rename_device_files',
|
||||
{ targetDir, pairs: migrationPairs.map(p => [p.old, p.new]) }
|
||||
);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const errors = results.filter(r => !r.ok).map(r => `${r.oldPath}: ${r.error ?? 'unknown'}`);
|
||||
setMigrationResult({ ok, failed, errors });
|
||||
// Bump manifest to v2 (no template field) + rescan the device.
|
||||
invoke('write_device_manifest', { destDir: targetDir, sources }).catch(() => {});
|
||||
scanDevice();
|
||||
setMigrationPhase('done');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: migrationPairs.length, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
};
|
||||
|
||||
const closeMigration = () => {
|
||||
setMigrationPhase('closed');
|
||||
setMigrationPairs([]);
|
||||
setMigrationCollisions([]);
|
||||
setMigrationResult(null);
|
||||
setMigrationOldTemplate('');
|
||||
};
|
||||
|
||||
const handleChooseFolder = async () => {
|
||||
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
||||
if (sel) {
|
||||
@@ -388,13 +570,12 @@ export default function DeviceSync() {
|
||||
// If the device has a psysonic-sync.json, always import it — replacing any
|
||||
// sources from a previous device so switching sticks works correctly.
|
||||
try {
|
||||
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
|
||||
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
|
||||
'read_device_manifest', { destDir: dir }
|
||||
);
|
||||
if (manifest?.sources?.length) {
|
||||
useDeviceSyncStore.getState().clearSources();
|
||||
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
|
||||
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
|
||||
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
|
||||
}
|
||||
} catch { /* no manifest, that's fine */ }
|
||||
@@ -422,7 +603,6 @@ export default function DeviceSync() {
|
||||
deletionIds: pendingDeletion,
|
||||
auth: { baseUrl, ...params },
|
||||
targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
|
||||
setSyncDelta(payload);
|
||||
@@ -442,16 +622,20 @@ export default function DeviceSync() {
|
||||
if (deletionSources.length > 0) {
|
||||
try {
|
||||
const allPaths: string[] = [];
|
||||
const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s)));
|
||||
const deletionTracks = trackArrays.flat();
|
||||
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: deletionTracks.map(t => trackToSyncInfo(t, '')),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
|
||||
// Compute paths per source so playlist sources delete from their own
|
||||
// folder (Playlists/{Name}/…) rather than from the album tree.
|
||||
for (const source of deletionSources) {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
}
|
||||
|
||||
await invoke<number>('delete_device_files', { paths: allPaths });
|
||||
removeSources(deletionSources.map(s => s.id));
|
||||
// Update manifest so it stays in sync after deletions
|
||||
@@ -468,6 +652,21 @@ export default function DeviceSync() {
|
||||
|
||||
const allTracks = syncDelta.tracks;
|
||||
if (allTracks.length === 0) {
|
||||
// No new downloads needed, but the user may still have added a
|
||||
// playlist source — (re)write its .m3u8 against the existing files.
|
||||
if (targetDir) {
|
||||
const playlistSources = sources.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: targetDir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
});
|
||||
}
|
||||
scanDevice();
|
||||
return;
|
||||
}
|
||||
@@ -480,7 +679,6 @@ export default function DeviceSync() {
|
||||
invoke('sync_batch_to_device', {
|
||||
tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))),
|
||||
destDir: targetDir,
|
||||
template: filenameTemplate,
|
||||
jobId,
|
||||
expectedBytes: syncDelta.addBytes,
|
||||
}).catch((err: string) => {
|
||||
@@ -524,63 +722,6 @@ export default function DeviceSync() {
|
||||
(!driveDetected && !!targetDir) ||
|
||||
(pendingCount === 0 && deletionCount === 0);
|
||||
|
||||
// ─── Template presets & token insertion ────────────────────────────────
|
||||
const TEMPLATE_PRESETS = useMemo(() => [
|
||||
{ key: 'standard', value: '{artist}/{album}/{track_number} - {title}', label: t('deviceSync.templatePresetStandard') },
|
||||
{ key: 'multidisc', value: '{artist}/{album}/{disc_number}-{track_number} - {title}', label: t('deviceSync.templatePresetMultiDisc') },
|
||||
{ key: 'altfolder', value: '{artist} - {album}/{track_number} - {title}', label: t('deviceSync.templatePresetAltFolder') },
|
||||
], [t]);
|
||||
|
||||
const TEMPLATE_TOKENS = ['{artist}', '{album}', '{title}', '{track_number}', '{disc_number}', '{year}', '/', '-'];
|
||||
|
||||
const activePreset = TEMPLATE_PRESETS.find(p => p.value === filenameTemplate)?.key ?? null;
|
||||
|
||||
const templateInputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPosRef = useRef<number>(filenameTemplate.length);
|
||||
|
||||
const insertToken = useCallback((token: string) => {
|
||||
const input = templateInputRef.current;
|
||||
const pos = cursorPosRef.current;
|
||||
const next = filenameTemplate.slice(0, pos) + token + filenameTemplate.slice(pos);
|
||||
setFilenameTemplate(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
const newPos = pos + token.length;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
cursorPosRef.current = newPos;
|
||||
});
|
||||
}, [filenameTemplate, setFilenameTemplate]);
|
||||
|
||||
const trackCursor = useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length);
|
||||
}, [filenameTemplate.length]);
|
||||
|
||||
// ─── Template preview (dummy track) ─────────────────────────────────────
|
||||
const PREVIEW_TRACK = {
|
||||
artist: 'Artist Name',
|
||||
album: 'Album Title',
|
||||
title: 'Track Title',
|
||||
track_number: '01',
|
||||
disc_number: '1',
|
||||
year: '2024',
|
||||
} as const;
|
||||
|
||||
const templatePreviewText = useMemo(() => {
|
||||
try {
|
||||
const result = filenameTemplate
|
||||
.replace(/\{artist\}/g, PREVIEW_TRACK.artist)
|
||||
.replace(/\{album\}/g, PREVIEW_TRACK.album)
|
||||
.replace(/\{title\}/g, PREVIEW_TRACK.title)
|
||||
.replace(/\{track_number\}/g, PREVIEW_TRACK.track_number)
|
||||
.replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number)
|
||||
.replace(/\{year\}/g, PREVIEW_TRACK.year);
|
||||
return `${result}.mp3`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [filenameTemplate]);
|
||||
|
||||
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
|
||||
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
|
||||
@@ -598,63 +739,30 @@ export default function DeviceSync() {
|
||||
</div>
|
||||
|
||||
<div className="device-sync-config-row">
|
||||
|
||||
{/* ── Left: Template ── */}
|
||||
<div className="device-sync-template-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
|
||||
<div className="device-sync-template-presets">
|
||||
{TEMPLATE_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`device-sync-template-preset-btn${activePreset === p.key ? ' active' : ''}`}
|
||||
onClick={() => setFilenameTemplate(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="device-sync-template-input-wrap">
|
||||
<div className="device-sync-template-input-row">
|
||||
<input
|
||||
ref={templateInputRef}
|
||||
className="input device-sync-template-input"
|
||||
value={filenameTemplate}
|
||||
onChange={e => { setFilenameTemplate(e.target.value); trackCursor(e); }}
|
||||
onSelect={trackCursor}
|
||||
onKeyUp={trackCursor}
|
||||
onClick={trackCursor}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{filenameTemplate && (
|
||||
<button
|
||||
className="device-sync-template-clear"
|
||||
onClick={() => { setFilenameTemplate(''); cursorPosRef.current = 0; templateInputRef.current?.focus(); }}
|
||||
data-tooltip={t('common.clear')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-template-tokens">
|
||||
{TEMPLATE_TOKENS.map(tok => (
|
||||
<button
|
||||
key={tok}
|
||||
className="device-sync-template-token"
|
||||
onClick={() => insertToken(tok)}
|
||||
data-tooltip={tok === '/' ? t('deviceSync.tokenSlashHint') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{templatePreviewText && (
|
||||
<span className="device-sync-template-preview">
|
||||
{t('deviceSync.templatePreview')}: {templatePreviewText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Left: Fixed schema info ── */}
|
||||
<div className="device-sync-schema-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })}</span>
|
||||
<code className="device-sync-schema-code">
|
||||
{'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'}
|
||||
</code>
|
||||
<span className="device-sync-schema-hint">
|
||||
{t('deviceSync.schemaHint', {
|
||||
defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
|
||||
})}
|
||||
</span>
|
||||
{targetDir && sources.length > 0 && (
|
||||
<button
|
||||
className="btn btn-ghost device-sync-migrate-btn"
|
||||
onClick={startMigrationPreview}
|
||||
data-tooltip={t('deviceSync.migrateTooltip', {
|
||||
defaultValue: 'Rename existing files on the device into the new scheme (from the old filename template).',
|
||||
})}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{t('deviceSync.migrateButton', { defaultValue: 'Reorganize existing files…' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Drive config ── */}
|
||||
@@ -764,7 +872,7 @@ export default function DeviceSync() {
|
||||
{activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => (
|
||||
<BrowserRow key={al.id} name={al.name} meta={al.artist}
|
||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} />
|
||||
))}
|
||||
{activeTab === 'artists' && filteredArtists.map(ar => (
|
||||
<React.Fragment key={ar.id}>
|
||||
@@ -788,7 +896,7 @@ export default function DeviceSync() {
|
||||
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
|
||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||
indent
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
|
||||
))
|
||||
}
|
||||
</React.Fragment>
|
||||
@@ -882,7 +990,10 @@ export default function DeviceSync() {
|
||||
onChange={() => toggleChecked(s.id)}
|
||||
disabled={status === 'deletion'}
|
||||
/>
|
||||
<span className="device-sync-row-name">{s.name}</span>
|
||||
<span className="device-sync-row-name">
|
||||
{s.name}
|
||||
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
|
||||
</span>
|
||||
<span className="device-sync-source-type">{s.type}</span>
|
||||
<span className={`device-sync-status-icon ${status}`}>
|
||||
{status === 'synced' && <CheckCircle2 size={13} />}
|
||||
@@ -1039,6 +1150,117 @@ export default function DeviceSync() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Migration modal (rename existing files into the fixed scheme) ── */}
|
||||
{migrationPhase !== 'closed' && (
|
||||
<div className="modal-overlay" onClick={migrationPhase === 'executing' ? undefined : closeMigration}>
|
||||
<div className="modal-content device-sync-migrate-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="modal-title">{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}</h2>
|
||||
<div className="device-sync-migrate-body">
|
||||
{migrationPhase === 'loading' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'nothing' && (
|
||||
<div className="device-sync-migrate-nothing">
|
||||
{migrationOldTemplate ? (
|
||||
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
|
||||
) : (
|
||||
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<div className="device-sync-migrate-summary">
|
||||
<div>
|
||||
<strong>{migrationPairs.length}</strong>{' '}
|
||||
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
|
||||
</div>
|
||||
{migrationUnchanged > 0 && (
|
||||
<div className="muted">
|
||||
{t('deviceSync.migrateUnchanged', {
|
||||
defaultValue: '{{n}} files are already at the correct path',
|
||||
n: migrationUnchanged,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationCollisions.length > 0 && (
|
||||
<div className="device-sync-migrate-warning">
|
||||
<AlertCircle size={14} />
|
||||
{t('deviceSync.migrateCollisions', {
|
||||
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
|
||||
n: migrationCollisions.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-preview-note">
|
||||
{t('deviceSync.migratePreviewNote', {
|
||||
defaultValue: 'Old template: {{tpl}}',
|
||||
tpl: migrationOldTemplate,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{migrationPhase === 'executing' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'done' && migrationResult && (
|
||||
<div className="device-sync-migrate-result">
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<CheckCircle2 size={14} className="positive" />
|
||||
{t('deviceSync.migrateSuccess', {
|
||||
defaultValue: '{{n}} files renamed successfully',
|
||||
n: migrationResult.ok,
|
||||
})}
|
||||
</div>
|
||||
{migrationResult.failed > 0 && (
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<AlertCircle size={14} className="danger" />
|
||||
{t('deviceSync.migrateFailed', {
|
||||
defaultValue: '{{n}} renames failed',
|
||||
n: migrationResult.failed,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationResult.errors.length > 0 && (
|
||||
<details className="device-sync-migrate-errors">
|
||||
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
|
||||
<ul>
|
||||
{migrationResult.errors.slice(0, 50).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
{migrationResult.errors.length > 50 && (
|
||||
<li>… {migrationResult.errors.length - 50} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-footer">
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
|
||||
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(migrationPhase === 'done' || migrationPhase === 'nothing') && (
|
||||
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1053,8 +1275,10 @@ function BrowserRow({ name, meta, selected, onToggle, indent }: {
|
||||
<span className="device-sync-row-check">
|
||||
{selected ? <CheckCircle2 size={14} /> : <span className="device-sync-row-circle" />}
|
||||
</span>
|
||||
<span className="device-sync-row-name">{name}</span>
|
||||
{meta && <span className="device-sync-row-meta">{meta}</span>}
|
||||
<span className="device-sync-row-name">
|
||||
{name}
|
||||
{meta && <span className="device-sync-row-artist"> · {meta}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+154
-1
@@ -10,7 +10,7 @@ import {
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
@@ -25,6 +25,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ 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: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
|
||||
@@ -205,6 +206,30 @@ export default function Favorites() {
|
||||
loadAll();
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// ── Top Favorite Artists aggregated from favorited songs ─────────────
|
||||
const topFavoriteArtists = useMemo(() => {
|
||||
const counts = new Map<string, { id: string; name: string; count: number; coverArtId: string }>();
|
||||
for (const s of songs) {
|
||||
if (starredOverrides[s.id] === false) continue;
|
||||
const key = s.artistId || s.artist;
|
||||
if (!key) continue;
|
||||
const existing = counts.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(key, {
|
||||
id: key,
|
||||
name: s.artist || key,
|
||||
count: 1,
|
||||
coverArtId: s.artistId || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(counts.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 12);
|
||||
}, [songs, starredOverrides]);
|
||||
|
||||
// ── Filter & sort logic ──────────────────────────────────────────────────
|
||||
const filteredSongs = useMemo(() => {
|
||||
return songs.filter(s => {
|
||||
@@ -309,6 +334,15 @@ export default function Favorites() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{topFavoriteArtists.length >= 2 && (
|
||||
<TopFavoriteArtistsRow
|
||||
title={t('favorites.topArtists')}
|
||||
artists={topFavoriteArtists}
|
||||
selectedKey={selectedArtist}
|
||||
onToggle={key => setSelectedArtist(prev => prev === key ? null : key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
|
||||
<section className="album-row-section">
|
||||
{/* ── Section Header with Stats & Filters ───────────────────────── */}
|
||||
@@ -656,6 +690,11 @@ export default function Favorites() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'genre': return (
|
||||
<div key="genre" className="track-genre">
|
||||
{song.genre ?? '—'}
|
||||
</div>
|
||||
);
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && (
|
||||
@@ -709,6 +748,120 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Top Favorite Artists Row ──────────────────────────────────────────────────
|
||||
|
||||
interface TopFavoriteArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
coverArtId: string;
|
||||
}
|
||||
|
||||
interface TopFavoriteArtistsRowProps {
|
||||
title: string;
|
||||
artists: TopFavoriteArtist[];
|
||||
selectedKey: string | null;
|
||||
onToggle: (key: string) => void;
|
||||
}
|
||||
|
||||
function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [artists]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<TopFavoriteArtistCard
|
||||
key={a.id}
|
||||
artist={a}
|
||||
isSelected={selectedKey === a.id}
|
||||
onClick={() => onToggle(a.id)}
|
||||
songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface TopFavoriteArtistCardProps {
|
||||
artist: TopFavoriteArtist;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
songCountLabel: string;
|
||||
}
|
||||
|
||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||
const coverId = artist.coverArtId;
|
||||
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`artist-card${isSelected ? ' artist-card-selected' : ''}`}
|
||||
onClick={onClick}
|
||||
style={isSelected ? { outline: '2px solid var(--accent)', outlineOffset: '-2px', borderRadius: 12 } : undefined}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
<span className="artist-card-meta">{songCountLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radio Station Row ─────────────────────────────────────────────────────────
|
||||
|
||||
interface RadioStationRowProps {
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function NewReleases() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
|
||||
+36
-1
@@ -23,7 +23,7 @@ import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||
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, IS_MACOS } from '../utils/platform';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
||||
@@ -121,6 +121,8 @@ const CONTRIBUTORS = [
|
||||
'ReplayGain values in Queue tech strip (PR #196)',
|
||||
'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
|
||||
'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
|
||||
'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German',
|
||||
'NixOS / flake install guide with Cachix setup (PR #209)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -157,6 +159,7 @@ const CONTRIBUTORS = [
|
||||
'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
|
||||
'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
|
||||
'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
|
||||
'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1124,6 +1127,25 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{!IS_WINDOWS && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadMiniPlayerDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.preloadMiniPlayer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.preloadMiniPlayer}
|
||||
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{IS_LINUX && !isTilingWm && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
@@ -2008,6 +2030,7 @@ export default function Settings() {
|
||||
['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })],
|
||||
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
|
||||
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
|
||||
['open-mini-player', t('settings.shortcutOpenMiniPlayer')],
|
||||
] as [KeyAction, string][]).map(([action, label]) => {
|
||||
const bound = kb.bindings[action];
|
||||
const isListening = listeningFor === action;
|
||||
@@ -2443,6 +2466,18 @@ export default function Settings() {
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutReleaseNotesLabel')}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
useAuthStore.getState().setLastSeenChangelogVersion('');
|
||||
navigate('/whats-new');
|
||||
}}
|
||||
style={{ color: 'var(--accent)', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
{t('settings.aboutReleaseNotesLink')}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { renderChangelogBody } from '../utils/changelogMarkdown';
|
||||
|
||||
export default function WhatsNew() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const close = () => {
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const entry = useMemo(() => {
|
||||
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
|
||||
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
|
||||
if (!block) return null;
|
||||
const lines = block.split('\n');
|
||||
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="whats-new">
|
||||
<header className="whats-new__header">
|
||||
<div className="whats-new__title-row">
|
||||
<Sparkles size={20} className="whats-new__icon" />
|
||||
<div>
|
||||
<h1 className="whats-new__title">{t('whatsNew.title')}</h1>
|
||||
<div className="whats-new__subtitle">
|
||||
v{entry?.version ?? version}
|
||||
{entry?.date && <span className="whats-new__date"> · {entry.date}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="whats-new__close"
|
||||
onClick={close}
|
||||
aria-label={t('whatsNew.close')}
|
||||
data-tooltip={t('whatsNew.close')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="whats-new__body">
|
||||
{entry ? (
|
||||
renderChangelogBody(entry.body)
|
||||
) : (
|
||||
<p className="whats-new__empty">{t('whatsNew.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,9 @@ interface AuthState {
|
||||
discordTemplateState: string;
|
||||
discordTemplateLargeText: string;
|
||||
useCustomTitlebar: boolean;
|
||||
/** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly
|
||||
* on first open. Ignored on Windows — that platform always pre-creates as a hang workaround. */
|
||||
preloadMiniPlayer: boolean;
|
||||
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
|
||||
linuxWebkitKineticScroll: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
@@ -212,6 +215,7 @@ interface AuthState {
|
||||
setDiscordTemplateState: (v: string) => void;
|
||||
setDiscordTemplateLargeText: (v: string) => void;
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setPreloadMiniPlayer: (v: boolean) => void;
|
||||
setLinuxWebkitKineticScroll: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
@@ -318,6 +322,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
discordTemplateState: '{album}',
|
||||
discordTemplateLargeText: '{album}',
|
||||
useCustomTitlebar: false,
|
||||
preloadMiniPlayer: false,
|
||||
linuxWebkitKineticScroll: true,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
@@ -448,6 +453,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
|
||||
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
||||
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
|
||||
@@ -5,11 +5,12 @@ export interface DeviceSyncSource {
|
||||
type: 'album' | 'playlist' | 'artist';
|
||||
id: string;
|
||||
name: string;
|
||||
/** Album artist — only set when type === 'album'. Shown as a subtitle in the device list. */
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
filenameTemplate: string;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
@@ -17,7 +18,6 @@ interface DeviceSyncState {
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
setFilenameTemplate: (t: string) => void;
|
||||
addSource: (source: DeviceSyncSource) => void;
|
||||
removeSource: (id: string) => void;
|
||||
clearSources: () => void;
|
||||
@@ -35,7 +35,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
targetDir: null,
|
||||
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
pendingDeletion: [],
|
||||
@@ -43,7 +42,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
|
||||
|
||||
addSource: (source) =>
|
||||
set((s) => ({
|
||||
@@ -97,7 +95,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
name: 'psysonic_device_sync',
|
||||
partialize: (s) => ({
|
||||
targetDir: s.targetDir,
|
||||
filenameTemplate: s.filenameTemplate,
|
||||
sources: s.sources,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export type KeyAction =
|
||||
| 'toggle-queue'
|
||||
| 'open-folder-browser'
|
||||
| 'fullscreen-player'
|
||||
| 'native-fullscreen';
|
||||
| 'native-fullscreen'
|
||||
| 'open-mini-player';
|
||||
|
||||
/** Physical keys only — ignore for binding capture */
|
||||
export const MODIFIER_KEY_CODES = [
|
||||
@@ -35,6 +36,7 @@ export const DEFAULT_BINDINGS: Bindings = {
|
||||
'open-folder-browser': null,
|
||||
'fullscreen-player': null,
|
||||
'native-fullscreen': 'F11',
|
||||
'open-mini-player': null,
|
||||
};
|
||||
|
||||
interface KeybindingsState {
|
||||
|
||||
@@ -22,6 +22,10 @@ interface ThemeState {
|
||||
setEnablePlaylistCoverPhoto: (v: boolean) => void;
|
||||
showBitrate: boolean;
|
||||
setShowBitrate: (v: boolean) => void;
|
||||
showRemainingTime: boolean;
|
||||
setShowRemainingTime: (v: boolean) => void;
|
||||
expandReplayGain: boolean;
|
||||
setExpandReplayGain: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||
@@ -59,6 +63,10 @@ export const useThemeStore = create<ThemeState>()(
|
||||
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
|
||||
showBitrate: true,
|
||||
setShowBitrate: (v) => set({ showBitrate: v }),
|
||||
showRemainingTime: false,
|
||||
setShowRemainingTime: (v) => set({ showRemainingTime: v }),
|
||||
expandReplayGain: false,
|
||||
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
|
||||
+792
-81
@@ -1868,7 +1868,7 @@
|
||||
}
|
||||
|
||||
.track-size {
|
||||
color: var(--ctp-overlay0);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
@@ -3690,13 +3690,11 @@
|
||||
right: 0;
|
||||
width: 230px;
|
||||
z-index: 9;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: #14141d;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 12px 44px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
animation: fslmIn 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
@@ -3737,18 +3735,18 @@
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fslm-style-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fslm-style-active {
|
||||
@@ -6048,97 +6046,110 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
}
|
||||
|
||||
/* ─ Genre Filter Bar ─ */
|
||||
.genre-filter-tagbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
min-width: 220px;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.genre-filter-tagbox:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-chip {
|
||||
.genre-filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.3rem 0.15rem 0.5rem;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
margin-left: 0.35rem;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.genre-filter-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.75;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.genre-filter-chip button:hover { opacity: 1; }
|
||||
|
||||
.genre-filter-input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.genre-filter-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.genre-filter-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
.genre-filter-popover {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.genre-filter-option {
|
||||
padding: 0.45rem 0.75rem;
|
||||
.genre-filter-popover__search {
|
||||
padding: 0.55rem 0.6rem 0.4rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.genre-filter-popover__search input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.genre-filter-popover__search input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-popover__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.genre-filter-popover__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.genre-filter-option:hover {
|
||||
.genre-filter-popover__option:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-empty {
|
||||
padding: 0.6rem 0.75rem;
|
||||
.genre-filter-popover__option--selected {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.genre-filter-popover__check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--border-subtle);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--ctp-crust);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.genre-filter-popover__option--selected .genre-filter-popover__check {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.genre-filter-popover__empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.genre-filter-popover__footer {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ─ Genre Cards ─ */
|
||||
@@ -8035,6 +8046,116 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.device-sync-schema-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
.device-sync-schema-code {
|
||||
display: inline-block;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.device-sync-schema-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
max-width: 520px;
|
||||
}
|
||||
.device-sync-migrate-btn {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Migration modal ──────────────────────────────────────────────────── */
|
||||
.device-sync-migrate-modal {
|
||||
min-width: 440px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.device-sync-migrate-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.device-sync-migrate-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-nothing {
|
||||
padding: 12px 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.device-sync-migrate-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-summary .muted { color: var(--text-muted); font-size: 12px; }
|
||||
.device-sync-migrate-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--warning, var(--accent));
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.device-sync-migrate-warning svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.device-sync-migrate-preview-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
.device-sync-migrate-result { display: flex; flex-direction: column; gap: 6px; }
|
||||
.device-sync-migrate-result-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.device-sync-migrate-result-line .positive { color: var(--positive, var(--accent)); }
|
||||
.device-sync-migrate-result-line .danger { color: var(--danger); }
|
||||
.device-sync-migrate-errors {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.device-sync-migrate-errors summary { cursor: pointer; color: var(--accent); }
|
||||
.device-sync-migrate-errors ul { max-height: 180px; overflow-y: auto; padding-left: 18px; margin: 6px 0 0; }
|
||||
.device-sync-migrate-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.device-sync-target-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8357,6 +8478,16 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
|
||||
.device-sync-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-sync-row-artist {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.device-sync-device-row.checked { background: var(--accent-dim); }
|
||||
@@ -8694,3 +8825,583 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
|
||||
.device-sync-row-name { flex: 1; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }
|
||||
|
||||
/* ── Pause CSS animations when the window is hidden / minimized ────────────
|
||||
Set via App.tsx on `visibilitychange`. WebView2 on Windows keeps compositing
|
||||
infinite CSS animations (mesh-aura, portrait-drift, eq-bounce, track-pulse,
|
||||
led-pulse, …) even when the app is minimized, causing constant GPU use.
|
||||
Pausing them cuts that to near zero while hidden without touching the
|
||||
running Rust/audio threads. */
|
||||
/* ─ What's New — banner + page ────────────────────────────────────────────
|
||||
Fixed neutral palette so it reads identically on every theme (light and
|
||||
dark). The banner sits in the sidebar just above Now Playing; the page
|
||||
opens in the main content area. */
|
||||
|
||||
.sidebar-bottom-spacer {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.whats-new-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin: 0 0 6px 0;
|
||||
background: #1f2233;
|
||||
color: #dfe3f6;
|
||||
border: 1px solid #3a3f5a;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.whats-new-banner:hover {
|
||||
background: #272b40;
|
||||
border-color: #4a5072;
|
||||
}
|
||||
|
||||
.whats-new-banner__icon {
|
||||
flex-shrink: 0;
|
||||
color: #8ec8ff;
|
||||
}
|
||||
|
||||
.whats-new-banner__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.whats-new-banner__title {
|
||||
font-weight: 600;
|
||||
color: #f1f3fb;
|
||||
}
|
||||
|
||||
.whats-new-banner__version {
|
||||
font-size: 10.5px;
|
||||
color: #9aa0bd;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.whats-new-banner__dismiss {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
color: #9aa0bd;
|
||||
cursor: pointer;
|
||||
}
|
||||
.whats-new-banner__dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e4e7f5;
|
||||
}
|
||||
|
||||
.whats-new-banner--collapsed {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 6px;
|
||||
}
|
||||
|
||||
/* ─── Page ─── */
|
||||
.whats-new {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 48px;
|
||||
color: #e4e7f5;
|
||||
background: #16182a;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 8px 24px rgba(0,0,0,0.25);
|
||||
border: 1px solid #2a2d44;
|
||||
}
|
||||
|
||||
.whats-new__header {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #2a2d44;
|
||||
}
|
||||
.whats-new__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.whats-new__close {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3f5a;
|
||||
background: transparent;
|
||||
color: #9aa0bd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.whats-new__close:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #e4e7f5;
|
||||
border-color: #4a5072;
|
||||
}
|
||||
.whats-new__icon {
|
||||
color: #8ec8ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.whats-new__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #f1f3fb;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.whats-new__subtitle {
|
||||
font-size: 13px;
|
||||
color: #9aa0bd;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.whats-new__date {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.whats-new__body {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.whats-new__empty {
|
||||
color: #9aa0bd;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.whats-new-h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #f1f3fb;
|
||||
margin: 22px 0 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #2a2d44;
|
||||
}
|
||||
.whats-new-h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #dfe3f6;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
|
||||
.whats-new-p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.whats-new-list {
|
||||
list-style: disc;
|
||||
padding-left: 22px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.whats-new-list li {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
.whats-new-list li::marker {
|
||||
color: #6b7192;
|
||||
}
|
||||
|
||||
.whats-new-quote {
|
||||
border-left: 3px solid #3a3f5a;
|
||||
padding: 6px 12px;
|
||||
margin: 10px 0;
|
||||
color: #b6bcd8;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.whats-new-hr {
|
||||
border: none;
|
||||
border-top: 1px solid #2a2d44;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.whats-new-code {
|
||||
background: #262a3e;
|
||||
color: #d4d7f0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.whats-new-link {
|
||||
color: #8ec8ff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed #4a5072;
|
||||
cursor: pointer;
|
||||
}
|
||||
.whats-new-link:hover {
|
||||
color: #b6dbff;
|
||||
border-bottom-color: #8ec8ff;
|
||||
}
|
||||
|
||||
/* ─ Mini Player window ───────────────────────────────────────────────────── */
|
||||
/* ── Mini player shell ── outermost wrapper that fills the window ── */
|
||||
.mini-player-shell {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Custom in-page titlebar.
|
||||
Win/Linux: full bar with drag region, track title, and action buttons.
|
||||
macOS: slim transparent bar holding only the action buttons; the
|
||||
native titlebar above already shows title + close. ── */
|
||||
.mini-player__titlebar {
|
||||
flex-shrink: 0;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: color-mix(in srgb, var(--bg-app) 85%, var(--bg-card) 15%);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.mini-player__titlebar--mac {
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.mini-player__titlebar-title {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mini-player__titlebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.mini-player__titlebar-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.mini-player__titlebar-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mini-player__titlebar-btn--active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-player__titlebar-btn--close:hover {
|
||||
background: var(--danger);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.mini-player {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
grid-template-rows: 84px auto;
|
||||
gap: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mini-player--queue-open {
|
||||
grid-template-rows: 84px auto 1fr;
|
||||
}
|
||||
|
||||
.mini-player__art {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
aspect-ratio: 1;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.mini-player__art img,
|
||||
.mini-player__art-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.mini-player__art-fallback {
|
||||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
|
||||
}
|
||||
|
||||
.mini-player__body {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
padding: 0 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mini-player__titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mini-player__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mini-player__artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mini-player__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.mini-player__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.mini-player__btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.mini-player__btn--primary {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
.mini-player__btn--primary:hover {
|
||||
background: var(--accent);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.mini-player__progress {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.mini-player__progress-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 30px;
|
||||
}
|
||||
.mini-player__progress-track {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--ctp-surface1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-player__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
|
||||
.mini-queue-wrap {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mini-queue {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
box-sizing: border-box;
|
||||
/* Hide native scrollbar — we render an overlay thumb beside it. */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mini-queue::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mini-queue__thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 3px;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
.mini-queue-wrap:hover .mini-queue__thumb,
|
||||
.mini-queue.is-scrolling ~ .mini-queue__thumb {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mini-queue__empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-queue__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mini-queue__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mini-queue__item--ctx {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mini-queue-wrap--drop-active .mini-queue {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.mini-queue__item--current {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mini-queue__num {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mini-queue__item--current .mini-queue__num {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini-queue__meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mini-queue__title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mini-queue__artist {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mini-queue__item--current .mini-queue__artist {
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.mini-player__tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-player__tool:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mini-player__tool--active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
html[data-app-hidden="true"] *,
|
||||
html[data-app-hidden="true"] *::before,
|
||||
html[data-app-hidden="true"] *::after {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
@@ -769,6 +769,53 @@
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* macOS Tauri Updater — idle state info block */
|
||||
.update-modal-mac-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-mac-info-main {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.update-modal-mac-info-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.update-modal-trust-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.update-modal-trust-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--positive, var(--accent));
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.update-modal-trust-badge svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* macOS Tauri Updater — done state (after install, before/during restart) */
|
||||
.update-modal-done-icon {
|
||||
align-self: center;
|
||||
color: var(--positive, var(--accent));
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.update-modal-done-countdown {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* AUR hint */
|
||||
.update-modal-aur {
|
||||
display: flex;
|
||||
@@ -984,6 +1031,33 @@
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
/* Every page re-uses .content-body as its outer wrapper, but App.tsx already
|
||||
renders one around <Routes /> as the scroll container. The inner one must
|
||||
not establish its own scroll ancestor — otherwise `position: sticky`
|
||||
children anchor to a container that never scrolls, and the header just
|
||||
scrolls along with the content. Padding stays: the outer App.tsx
|
||||
content-body has `padding: 0` inline, the inner one is what gives each
|
||||
page its breathing room. */
|
||||
.content-body .content-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Sticky page header: keeps page title + filter/search bar visible while the
|
||||
body scrolls. Negative horizontal margins (+ matching padding) make the
|
||||
background flush with the scroll container edges so the sticky block looks
|
||||
like a real bar, not a floating island. `top: 0` clamps below the content-
|
||||
body's own padding-top; the residual 24px gap above the sticky block while
|
||||
scrolling is intentional — gives the bar visual breathing room. */
|
||||
.page-sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: var(--bg-app);
|
||||
margin: 0 calc(-1 * var(--space-6)) var(--space-5);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ─── Offline Banner ─── */
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
@@ -1071,6 +1145,11 @@
|
||||
height: var(--player-height);
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
/* WebKitGTK (software compositing) occasionally paints the bar fully
|
||||
black for one frame when an unrelated layer in the page invalidates.
|
||||
`contain` isolates layout/paint so the bar's region cannot
|
||||
participate in the surrounding dirty rect. */
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.player-track-info {
|
||||
@@ -1279,6 +1358,25 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Clickable time toggle with swap indicator */
|
||||
.player-time-toggle {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.player-time-toggle:hover {
|
||||
background-color: var(--surface-hover, rgba(128, 128, 128, 0.2));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.player-time-toggle:active {
|
||||
background-color: var(--surface-active, rgba(128, 128, 128, 0.3));
|
||||
}
|
||||
|
||||
/* Volume section */
|
||||
.player-volume-section {
|
||||
display: flex;
|
||||
@@ -1627,6 +1725,66 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Two-line layout: base info + RG badge on top, optional ReplayGain
|
||||
values underneath when the badge is expanded. The stack wraps both
|
||||
rows so the source icon stays vertically centered next to the whole
|
||||
text block. */
|
||||
.queue-current-tech-stack {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.queue-current-tech-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Pill-shaped toggle that signals "this track has ReplayGain metadata"
|
||||
without committing screen space to the numbers — values appear in the
|
||||
tooltip and on the second line when expanded. */
|
||||
.queue-current-tech-rg-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 5px 1px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||
}
|
||||
.queue-current-tech-rg-badge:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.queue-current-tech-rg-badge svg {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.queue-current-tech-rg-badge--open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.queue-current-tech-rg {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 8px;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
+25
-6
@@ -4035,7 +4035,7 @@ select.input.input:focus {
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4584,9 +4584,10 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--accent-glow: rgba(69, 255, 0, 0.45);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #dce8ff;
|
||||
--text-muted: #b8d0f8;
|
||||
/* lighter so readable on #3a62a5 */
|
||||
--border: #2a5090;
|
||||
--text-muted: #e8f0ff;
|
||||
/* lifted from #b8d0f8 — clears AA on Luna-blue bg-app and dark sidebar */
|
||||
--border: #071027;
|
||||
/* darkened from #2a5090 to meet 3:1 UI threshold on all main surfaces */
|
||||
--border-subtle: #4a72b8;
|
||||
--positive: #30dd00;
|
||||
--warning: #ffdd44;
|
||||
@@ -10780,11 +10781,13 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--accent-glow: rgba(21, 101, 200, 0.40);
|
||||
--text-primary: #0d1d3c;
|
||||
--text-secondary: #2a4878;
|
||||
--text-muted: #4870a8;
|
||||
--text-muted: #3f6aa0;
|
||||
/* slight lift from #4870a8 to clear AA on bg-card (4.23 → 4.65) */
|
||||
--border: rgba(100, 160, 220, 0.45);
|
||||
--border-subtle: rgba(140, 190, 240, 0.30);
|
||||
--positive: #1a8020;
|
||||
--warning: #c8980c;
|
||||
--warning: #735a00;
|
||||
/* deeper Vista gold — #c8980c was 2.37:1 on bg-app, now 5.91:1 */
|
||||
--danger: #c02020;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 6px;
|
||||
@@ -11122,6 +11125,22 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
color: #6090b8;
|
||||
}
|
||||
|
||||
/* Lyrics pane sits inside the queue panel (bg-sidebar = #0e1e3e deep navy).
|
||||
Default --text-primary / --text-muted are near-navy and invisible there —
|
||||
explicit light tokens get the pane back to AA+. */
|
||||
[data-theme='wista'] .lyrics-line,
|
||||
[data-theme='wista'] .lyrics-status,
|
||||
[data-theme='wista'] .lyrics-word-synced .lyrics-line {
|
||||
color: #aac8f0;
|
||||
}
|
||||
[data-theme='wista'] .lyrics-line.active,
|
||||
[data-theme='wista'] .lyrics-word-synced .lyrics-line.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
[data-theme='wista'] .lyrics-line.completed {
|
||||
color: #aac8f0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────── */
|
||||
|
||||
[data-theme='aqua-quartz'] {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
|
||||
/**
|
||||
* Render inline markdown segments: **bold**, *italic*, `code`, [text](url).
|
||||
* External links open in the user's default browser via the Tauri shell plugin.
|
||||
*/
|
||||
export function renderInlineMarkdown(text: string, keyPrefix = 'i'): React.ReactNode[] {
|
||||
// Tokenize — order matters: links first (no recursion), then emphasis/code.
|
||||
const tokens: React.ReactNode[] = [];
|
||||
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
|
||||
const pushInline = (segment: string) => {
|
||||
const parts = segment.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
tokens.push(<strong key={`${keyPrefix}-${i++}`}>{part.slice(2, -2)}</strong>);
|
||||
} else if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
|
||||
tokens.push(<em key={`${keyPrefix}-${i++}`}>{part.slice(1, -1)}</em>);
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
tokens.push(<code key={`${keyPrefix}-${i++}`} className="whats-new-code">{part.slice(1, -1)}</code>);
|
||||
} else {
|
||||
tokens.push(part);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while ((match = linkRe.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) pushInline(text.slice(lastIndex, match.index));
|
||||
const [full, label, url] = match;
|
||||
tokens.push(
|
||||
<a
|
||||
key={`${keyPrefix}-link-${i++}`}
|
||||
href={url}
|
||||
onClick={(e) => { e.preventDefault(); open(url).catch(() => {}); }}
|
||||
className="whats-new-link"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.index + full.length;
|
||||
}
|
||||
if (lastIndex < text.length) pushInline(text.slice(lastIndex));
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a subset of GitHub-flavored Markdown used by our CHANGELOG: headings
|
||||
* (### / ####), bullets (- / *), blockquotes, horizontal rules, and inline
|
||||
* formatting (bold/italic/code/links).
|
||||
*/
|
||||
export function renderChangelogBody(body: string): React.ReactNode[] {
|
||||
const lines = body.split('\n');
|
||||
const out: React.ReactNode[] = [];
|
||||
let bulletBuffer: React.ReactNode[] = [];
|
||||
let quoteBuffer: string[] = [];
|
||||
|
||||
const flushBullets = () => {
|
||||
if (bulletBuffer.length === 0) return;
|
||||
out.push(<ul key={`ul-${out.length}`} className="whats-new-list">{bulletBuffer}</ul>);
|
||||
bulletBuffer = [];
|
||||
};
|
||||
const flushQuote = () => {
|
||||
if (quoteBuffer.length === 0) return;
|
||||
out.push(
|
||||
<blockquote key={`q-${out.length}`} className="whats-new-quote">
|
||||
{renderInlineMarkdown(quoteBuffer.join(' '), `q-${out.length}`)}
|
||||
</blockquote>
|
||||
);
|
||||
quoteBuffer = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '') { flushBullets(); flushQuote(); continue; }
|
||||
|
||||
if (trimmed === '---') {
|
||||
flushBullets(); flushQuote();
|
||||
out.push(<hr key={`hr-${out.length}`} className="whats-new-hr" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
flushBullets(); flushQuote();
|
||||
out.push(<h3 key={`h3-${out.length}`} className="whats-new-h3">{renderInlineMarkdown(line.slice(4), `h3-${i}`)}</h3>);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('#### ')) {
|
||||
flushBullets(); flushQuote();
|
||||
out.push(<h4 key={`h4-${out.length}`} className="whats-new-h4">{renderInlineMarkdown(line.slice(5), `h4-${i}`)}</h4>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('> ')) {
|
||||
flushBullets();
|
||||
quoteBuffer.push(line.slice(2));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
flushQuote();
|
||||
bulletBuffer.push(
|
||||
<li key={`li-${i}`}>{renderInlineMarkdown(line.slice(2), `li-${i}`)}</li>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph / plain line
|
||||
flushBullets(); flushQuote();
|
||||
out.push(<p key={`p-${i}`} className="whats-new-p">{renderInlineMarkdown(line, `p-${i}`)}</p>);
|
||||
}
|
||||
flushBullets(); flushQuote();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export const MINI_WINDOW_LABEL = 'mini';
|
||||
|
||||
export interface MiniTrackInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
export interface MiniSyncPayload {
|
||||
track: MiniTrackInfo | null;
|
||||
queue: MiniTrackInfo[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
isMobile: false;
|
||||
}
|
||||
|
||||
export type MiniControlAction =
|
||||
| 'toggle'
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'show-main';
|
||||
|
||||
function toMini(t: any): MiniTrackInfo {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
coverArt: t.coverArt,
|
||||
duration: t.duration,
|
||||
starred: !!t.starred,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(): MiniSyncPayload {
|
||||
const s = usePlayerStore.getState();
|
||||
return {
|
||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||
queue: (s.queue ?? []).map(toMini),
|
||||
queueIndex: s.queueIndex ?? 0,
|
||||
isPlaying: s.isPlaying,
|
||||
isMobile: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge initialised on the main window. Pushes track/state changes to the
|
||||
* mini window whenever they matter, and handles control events coming back
|
||||
* from the mini window.
|
||||
*
|
||||
* Returns a cleanup function.
|
||||
*/
|
||||
export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
// Only run on the main window
|
||||
if (getCurrentWindow().label !== 'main') return () => {};
|
||||
|
||||
// Push state to the mini window on every relevant store change.
|
||||
let last = '';
|
||||
const push = () => {
|
||||
const payload = snapshot();
|
||||
const queueIds = payload.queue.map(q => q.id).join(',');
|
||||
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}|${payload.queueIndex}|${queueIds}`;
|
||||
if (key === last) return;
|
||||
last = key;
|
||||
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
|
||||
};
|
||||
|
||||
const unsub = usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.currentTrack?.id !== prev.currentTrack?.id
|
||||
|| state.isPlaying !== prev.isPlaying
|
||||
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
||||
|| state.queueIndex !== prev.queueIndex
|
||||
|| state.queue !== prev.queue) {
|
||||
push();
|
||||
}
|
||||
});
|
||||
|
||||
// Push an initial snapshot whenever a new mini window announces itself.
|
||||
const readyUnlisten = listen('mini:ready', () => {
|
||||
last = '';
|
||||
push();
|
||||
});
|
||||
|
||||
// Receive control actions from the mini window.
|
||||
const controlUnlisten = listen<MiniControlAction>('mini:control', (e) => {
|
||||
const action = e.payload;
|
||||
const store = usePlayerStore.getState();
|
||||
switch (action) {
|
||||
case 'toggle': store.togglePlay(); break;
|
||||
case 'next': store.next(true); break;
|
||||
case 'prev': store.previous(); break;
|
||||
case 'show-main': {
|
||||
const w = getCurrentWindow();
|
||||
w.unminimize().catch(() => {});
|
||||
w.show().catch(() => {});
|
||||
w.setFocus().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Jump to a specific queue index.
|
||||
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
|
||||
const store = usePlayerStore.getState();
|
||||
const idx = e.payload?.index ?? -1;
|
||||
if (idx < 0 || idx >= store.queue.length) return;
|
||||
const track = store.queue[idx];
|
||||
if (track) store.playTrack(track, store.queue, true);
|
||||
});
|
||||
|
||||
// PsyDnD reorder forwarded from the mini queue.
|
||||
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
|
||||
const store = usePlayerStore.getState();
|
||||
const { from, to } = e.payload ?? { from: -1, to: -1 };
|
||||
if (from < 0 || from >= store.queue.length) return;
|
||||
if (to < 0 || to > store.queue.length) return;
|
||||
if (from === to) return;
|
||||
store.reorderQueue(from, to);
|
||||
});
|
||||
|
||||
// Remove a track at index (context menu → "Remove from queue").
|
||||
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
|
||||
const store = usePlayerStore.getState();
|
||||
const idx = e.payload?.index ?? -1;
|
||||
if (idx < 0 || idx >= store.queue.length) return;
|
||||
store.removeTrack(idx);
|
||||
});
|
||||
|
||||
// Navigate the main app to a route. Used by mini context menu actions
|
||||
// like "Open Album" / "Go to Artist" — those need the full main UI.
|
||||
const navigateUnlisten = listen<{ to: string }>('mini:navigate', (e) => {
|
||||
const to = e.payload?.to;
|
||||
if (!to) return;
|
||||
// Surface the main window first so the navigation is visible.
|
||||
const w = getCurrentWindow();
|
||||
w.unminimize().catch(() => {});
|
||||
w.show().catch(() => {});
|
||||
w.setFocus().catch(() => {});
|
||||
// React Router lives in main; route via a custom event the AppShell
|
||||
// picks up (defined in App.tsx).
|
||||
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
|
||||
});
|
||||
|
||||
// Open the SongInfo modal in main for a given track id.
|
||||
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
|
||||
const id = e.payload?.id;
|
||||
if (!id) return;
|
||||
const w = getCurrentWindow();
|
||||
w.unminimize().catch(() => {});
|
||||
w.show().catch(() => {});
|
||||
w.setFocus().catch(() => {});
|
||||
usePlayerStore.getState().openSongInfo(id);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
readyUnlisten.then(fn => fn()).catch(() => {});
|
||||
controlUnlisten.then(fn => fn()).catch(() => {});
|
||||
jumpUnlisten.then(fn => fn()).catch(() => {});
|
||||
reorderUnlisten.then(fn => fn()).catch(() => {});
|
||||
removeUnlisten.then(fn => fn()).catch(() => {});
|
||||
navigateUnlisten.then(fn => fn()).catch(() => {});
|
||||
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user