Compare commits

...

32 Commits

Author SHA1 Message Date
Psychotoxical ab35ef5eb4 chore(release): v1.41.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:43:29 +02:00
Psychotoxical 4ff4ea0df0 fix(i18n): use t('artists.albumCount') in ArtistCardLocal
The album count on local artist cards was rendered with hardcoded German
("Album"/"Alben"). Switched to the existing plural-aware i18n key which
covers all 8 locales (including Russian Slavic plurals).

Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:35:01 +02:00
Psychotoxical cef2db92cb feat(mini-player): floating mini window — early alpha (#162)
A second webview window (label "mini") with a compact player: album art,
title, artist, prev/play/next, progress bar, pin-on-top toggle, expand
back to main, close. Main minimizes on open and restores when the mini
is hidden or closed. Spacebar toggles, arrow keys skip tracks.

Cross-window sync: main window subscribes to playerStore and pushes
`mini:sync` via emitTo on track / play-state change. Audio progress
already broadcasts to all windows via `audio:progress`. Control actions
(prev/next/toggle) come back via `mini:control`; main-window restore
goes through a direct Rust command because WebKitGTK pauses JS in a
minimized webview.

Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top
is skipped on Hyprland/Sway/i3 since it's ignored there anyway.

Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept
deliberately tight for v1; follow-ups planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:32:20 +02:00
Psychotoxical 72e193cf2c ci(release): explicitly push psysonic closure to Cachix
cachix-action installs a post-build-hook via NIX_USER_CONF_FILES, but
the Determinate Nix daemon reads system nix.conf and never fires the
hook — only two early prep paths ever reached the cache, never the
actual psysonic output. Add an explicit closure push after the build;
Cachix dedupes, so redundancy is cheap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:04:57 +02:00
Psychotoxical 6b3e809d12 feat(device-sync): show album artist in both panels
Adds optional artist field to DeviceSyncSource and renders it inline
next to the album name ("Album · Artist") in the on-device list.
BrowserRow (left panel) uses the same inline format so albums in search,
random picks and under expanded artists all read consistently. Playlists
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:58:51 +02:00
Psychotoxical 2e5a34178b feat(ux): collapse Albums sort buttons into a dropdown
Two sort buttons (A–Z Album / A–Z Artist) become one SortDropdown —
same portal popover pattern as the year and genre filters. Button
shows the current choice with an up/down arrow icon. Generic
component, reusable for other pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:52:38 +02:00
Psychotoxical 25537f2743 fix(fullscreen): lyrics menu toggle + readable panel
Clicking the mic button now toggles the lyrics settings panel instead
of outside-handler closing it and click re-opening it — trigger ref is
excluded from the outside-click check.

Panel is now a solid surface (no backdrop-blur, near-opaque background,
higher-contrast button text) so settings stay readable over the busy
fullscreen background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:48:21 +02:00
Psychotoxical 8b7bce5b85 feat(ux): year filter as portal popover
Replaces the inline From/To number inputs in the Albums header with a
single button that opens a popover — same pattern as the genre filter.
Button shows the active range (e.g. 2020–2024) with accent styling.
Header is now a homogeneous row of buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:43:56 +02:00
Psychotoxical 89e8f43add feat(albums): compilation filter toggle in All Albums
Tri-state button (all / only compilations / hide compilations) in the
Albums page header, using the OpenSubsonic isCompilation tag from
Navidrome. Client-side filter via useMemo, no extra server calls.
Closes #65.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:33:45 +02:00
Psychotoxical da38b411b0 feat(ux): redesign genre filter as portal popover
Trigger button with count badge, portal-rendered popover with search
input and full scrollable checkbox list (no 60-item cap). Selected
genres sort to the top. Replaces the inline tagbox that ate header
space and cut off the alphabetical list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:32 +02:00
Psychotoxical 4f2c313bb7 feat(ux): sticky header on Albums, NewReleases, Artists
Header with search/sort/genre/year controls now pins to top while
scrolling, so filters stay reachable. Nested .content-body (App.tsx
wraps routes) was becoming the sticky anchor and swallowing the effect —
fixed with .content-body .content-body { overflow: visible }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:22 +02:00
Psychotoxical c96eb0a805 feat(favorites): add genre column + Top Favorite Artists row
Genre column (toggleable via column picker) and a horizontally scrolling
Top Favorite Artists section between Radio Stations and Songs, aggregated
from starred tracks. Closes #87.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:24:14 +02:00
Psychotoxical 66c0ecbc1f fix(windows): tray double-click flicker + GPU use when minimized
1. Tray double-click (reported by @cucadmuh's brother-in-law):
   Windows fires a Click event on *both* halves of a double-click, so
   our left-click handler toggled visibility twice — the window popped
   up and immediately vanished. Switch the Windows branch to the
   `TrayIconEvent::DoubleClick` variant (Windows-only in tray-icon);
   other platforms keep the Click-on-Up behaviour. Matches the standard
   Windows tray convention (Discord, Telegram, etc).

2. GPU use while minimized:
   WebView2 on Windows keeps compositing infinite CSS animations
   (mesh-aura-a/b, portrait-drift, eq-bounce, track-pulse, led-pulse …)
   even when the window is minimized — steady visible GPU load on
   systems that should be idle. App.tsx now toggles a
   `data-app-hidden="true"` attribute on <html> on `visibilitychange`,
   and a single CSS rule pauses every `animation` at once via
   `animation-play-state: paused !important`. Zero cost when visible,
   catches all current and future infinite animations without
   per-element listeners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 20:20:24 +02:00
Psychotoxical 225609e93c fix(i18n): add common.close to en + de locales
The device-sync migration modal uses t('common.close') for its dismiss
button, but the key lived only inside scoped namespaces (where it had
different translations like "Verstanden" / "Got it"). Add the
straightforward "Schließen" / "Close" under common so the migration
modal shows the right label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:54:51 +02:00
Frank Stellmacher 4773f1ca8c Update CHANGELOG.md 2026-04-18 19:51:03 +02:00
Psychotoxical 129cd3ea23 chore(aur): bump pkgver to 1.40.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:43:51 +02:00
Psychotoxical 98352bb656 chore(release): v1.40.0
Major release consolidating several months of work into a single coherent
version. The 1.34.x patch series accumulated many small features; 1.40.0
packages them up and signals the infrastructure milestone (macOS signing
+ notarization + auto-updater) clearly.

Highlights (see CHANGELOG for the full rundown):
- macOS builds are signed with a Developer ID certificate and notarized
  by Apple — no more Gatekeeper "unidentified developer" dialog
- In-app auto-update on macOS via the Tauri Updater plugin; polished
  modal with trust badges, restart countdown, and state-dependent buttons
- Linux WebKitGTK wheel scroll toggle (PR #207 by cucadmuh)
- Device Sync: user-configurable filename template replaced with a
  fixed cross-OS scheme; playlists now live in their own self-contained
  folders with sibling-referencing .m3u8; one-shot migration tool for
  existing sticks
- WCAG contrast audits for middle-earth and nucleo themes
- Contributors list in Settings → About updated (PRs #205, #206, #207)

Windows signing + Windows auto-updater are the remaining gap; 2.0.0 is
planned once both are active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:41:16 +02:00
Psychotoxical a2f880da0d feat(device-sync): fixed cross-OS naming scheme + playlist folders
Replace the user-configurable filename template with a fixed scheme so
syncing the same library across Linux/Windows/macOS no longer re-downloads
files just because the template string differs by OS.

Track layout on the device:
  {AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}
  Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}
  Playlists/{PlaylistName}/{PlaylistName}.m3u8

Playlist tracks go into a self-contained folder with a sibling-referencing
Extended M3U, instead of being scattered across the album tree with an
external .m3u8 — the stick stays tidy even for 50-artist playlists.

Rust (lib.rs):
- TrackSyncInfo: drop disc_number/year (not used in fixed schema), add
  album_artist, duration, playlist_name, playlist_index
- sanitize_path_component strengthens empty-result handling via new
  sanitize_or() which falls back to "Unknown Artist/Album/Title"
- build_track_path() dispatches on playlist context — playlist tracks
  get the "Playlists/{Name}/…" shape, album tracks the traditional tree
- albumArtist resolution: the server's albumArtist tag, falling back to
  the track artist (empty-string treated as missing)
- calculate_sync_payload: dedup key is now (source_id, track_id) instead
  of track_id alone, so a track appearing in both an album and a playlist
  ends up on the device in both locations; playlist context is embedded
  into the response JSON as _playlistName / _playlistIndex so the follow-
  up sync_batch_to_device call can route each track correctly
- write_playlist_m3u8: rewritten — writes into the playlist folder with
  sibling filenames instead of a top-level .m3u8 with ../relative paths
- New rename_device_files command: atomic renames (per-entry result,
  skip-if-exists, skip-if-source-missing) plus empty-dir cleanup. Used
  by the migration flow
- write_device_manifest: v2 format, drops filenameTemplate field
- Drop the `template` parameter from sync_track_to_device,
  compute_sync_paths, calculate_sync_payload, sync_batch_to_device

Frontend:
- DeviceSyncStore: drop filenameTemplate from state and persist
- DeviceSync.tsx: strip template editor UI (presets, token buttons, live
  preview); replace with a compact read-only schema info card
- trackToSyncInfo(track, url, playlistCtx?): optional playlist context
  parameter. Tracks coming back from calculate_sync_payload with embedded
  _playlistName/_playlistIndex fall through via the same parameter
- compute_sync_paths / write_playlist_m3u8 / delete paths now pass the
  playlist context per-source so status checks + deletions hit the right
  files (album tree vs playlist folder)
- Album-Artist fallback: frontend falls back to track artist when the
  server has no albumArtist tag (empty strings treated as missing) —
  "Unknown Artist" is only used as a last-resort placeholder
- New migration flow: "Reorganize existing files…" button opens a modal
  that reads the legacy filenameTemplate from the v1 manifest, computes
  per-track (old, new) rename pairs, detects collisions (two old files
  mapping onto one new path), and executes via rename_device_files.
  Playlist-only tracks are left for the next sync to re-download into
  the new playlist folder
- Small JS-side applyLegacyTemplate() helper: only used by the migration
  preview so we can diff old paths against the current files on disk
- CSS: device-sync-schema-section / -code / -hint, migrate-modal +
  summary + warning + result layout

i18n (en + de):
- Remove dead keys: filenameTemplate, templatePreview, templateHint,
  templatePresetStandard/MultiDisc/AltFolder, tokenSlashHint
- Add schemaLabel, schemaHint, migrateButton/Title/Tooltip/Loading/
  NothingToDo/NoTemplate/FilesToRename/Unchanged/Collisions/
  PreviewNote/Executing/Success/Failed/ShowErrors/Start

Other locales (fr/nl/zh/nb/ru/es) fall through to the English defaultValue
until translated in a follow-up pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:30:25 +02:00
Psychotoxical 95f714654d ci(release): re-enable Windows, Linux and verify-nix jobs
The macOS updater pipeline is stable now, so restore full-platform
builds + the Nix cache refresh on every release. These were disabled
temporarily during v1.34.14–v1.34.23 iteration to cut turnaround time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:46:45 +02:00
Psychotoxical e201bc630c feat(updater): polish macOS update modal with countdown, trust badges, clean button states
- Footer buttons are now state-dependent: Skip / Remind later only show
  during idle; the install button no longer leaves a gap when it
  disappears, and "Remind later" stops shifting between states
- Post-install on macOS shows a 3-second visible restart countdown with
  a "Restart now" button instead of triggering an invisible relaunch
  that sometimes didn't actually fire
- macOS idle state now explains the in-place update model (no DMG
  download) and shows trust badges: "Notarized by Apple" + "Signature
  verified" as an at-a-glance trust signal
- Download state during install hides all secondary buttons so the
  progress bar stands alone
- New i18n keys in en/de; fr/nl/zh/nb/ru/es fall back to the English
  defaultValue until translated in a follow-up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:44:50 +02:00
Psychotoxical 36f0ef79fe chore(release): v1.34.23 — update-target for v1.34.22 auto-update test
No functional changes. Throwaway build to serve as the "latest" release
that v1.34.22 will find, download, verify, and install via the Tauri
Updater. Will be deleted once the updater round-trip is confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:18:33 +02:00
Psychotoxical 939ed3d412 chore(release): v1.34.22 — fix truncated updater pubkey
A character was lost when the base64-encoded minisign public key was
transcribed into tauri.conf.json, producing a 41-byte decoded key
instead of the required 42 bytes. Every release built against that key
(v1.34.15 through v1.34.21) rejects any update manifest signature with
"Invalid encoding in minisign data". Replaced with the correct pubkey
read directly from ~/.tauri/psysonic-updater.key.pub.

v1.34.19 installed manually for testing cannot receive auto-updates
because the broken pubkey is baked into its binary; a fresh v1.34.22
install is required as the base for the updater test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:04:51 +02:00
Psychotoxical bddfd45086 chore(release): v1.34.21 — macOS updater UI fix + second test build
- AppUpdater.tsx: macOS now has a dedicated branch in the update modal —
  no architecture-specific DMG asset shown (the Tauri Updater picks the
  right platform from latest.json), clearer wording ("Downloads, verifies
  and installs automatically"), and the primary button reads "Install
  now" instead of "Download". Strings use defaultValue so locales without
  the key fall back to English until translations catch up.
- Test release for the updater pipeline; CHANGELOG entry asks users to
  ignore it. Will be deleted after the test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:05:02 +02:00
Psychotoxical 48c10e5619 chore(release): v1.34.20 — test build for auto-updater pipeline
Throwaway release to validate the macOS Tauri Updater end-to-end:
v1.34.19 (installed manually) → check() → latest.json → download +
install → relaunch. Contains no actual changes. CHANGELOG entry asks
users to ignore it; will be deleted after the test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:46:03 +02:00
Psychotoxical 216e6c957f ci(release): re-sign updater tarball after tauri-action's repack
tauri-action always re-packs Psysonic.app into .app.tar.gz after tauri
CLI finishes (regardless of includeUpdaterJson), so the .sig that tauri
CLI produces never matches the final tarball — and the sig tauri-action
was supposed to produce silently goes missing ("Signature not found for
the updater JSON. Skipping upload...").

Rather than fight the repack, sign the repacked tarball ourselves in a
follow-up step using `tauri signer sign` against the same private key
(from TAURI_SIGNING_PRIVATE_KEY), then upload the fresh .sig. tauri-
action still handles the DMG + .app.tar.gz upload; we only add the .sig.

Also removes an unused `std::rc::Rc` import in the vendored cpal patch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:31:17 +02:00
Psychotoxical 425addffa0 ci(release): disable tauri-action updater repack, upload .sig ourselves
Root cause: tauri-action runs a second "Packaging" pass after tauri CLI
finishes, which rewrites src-tauri/target/<target>/release/bundle/macos/
Psysonic.app.tar.gz with different bytes. The .sig produced by tauri CLI
no longer matches the new hash, so tauri-action silently deletes it —
leaving the directory with only .app and .app.tar.gz (no .sig for our
manifest generator to consume).

Fix: pass `includeUpdaterJson: false` to tauri-action so it skips the
repack + updater JSON upload entirely, then copy both the .app.tar.gz
and .app.tar.gz.sig produced by tauri CLI into the workspace root with
the expected asset names and `gh release upload` them.

Also disables build-linux and the Windows matrix entry during testing
to cut iteration time roughly in half.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:19:33 +02:00
Psychotoxical 43df830960 ci(release): debug .sig discovery, disable verify-nix during testing
- Replace the glob find for the .sig file with an explicit target-based
  path ($matrix → src-tauri/target/<target>-apple-darwin/release/bundle/
  macos/Psysonic.app.tar.gz.sig) and dump directory listings so we can
  see what tauri-action actually leaves behind if the path is wrong
- Skip verify-nix while we iterate fast on signing + updater (its auto-
  commits of flake.lock / npmDepsHash cause rebase friction on every
  release)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:08:56 +02:00
Psychotoxical 6ed41070a5 ci(release): upload updater .sig as release asset
tauri-action on macOS cross-target builds produces the .app.tar.gz.sig
locally but silently skips its upload ("Signature not found for the
updater JSON. Skipping upload..."), which caused generate-manifest to
abort on v1.34.15 because the .sig asset was absent.

New step after tauri-action locates the .sig under src-tauri/target/*/
release/bundle/macos/ and uploads it as Psysonic_aarch64.app.tar.gz.sig
or Psysonic_x64.app.tar.gz.sig — the exact filenames generate-update-
manifest.js expects.

Also bumps version to 1.34.16.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:46:58 +02:00
Psychotoxical 061c96a89f chore(release): v1.34.15
- feat(updater): macOS auto-update via Tauri Updater

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:29:11 +02:00
Psychotoxical 31d6e5bd77 feat(updater): macOS auto-update via Tauri Updater
- tauri-plugin-updater wired into lib.rs with pubkey + GitHub Releases
  endpoint in tauri.conf.json; updater:default capability granted
- AppUpdater.tsx: on macOS, the download button now invokes the updater
  plugin (check + downloadAndInstall) which downloads the signed
  .app.tar.gz, verifies the minisign signature against the bundled
  pubkey, replaces /Applications/Psysonic.app, and relaunches. Windows
  and Linux keep the existing "download DMG/EXE/AppImage via reqwest
  then point to the folder" flow
- CI: pass TAURI_SIGNING_PRIVATE_KEY + _PASSWORD to tauri-action so the
  .sig files are produced alongside the update bundles
- New generate-manifest job (after build-macos-windows) runs
  scripts/generate-update-manifest.js which downloads the .sig files
  from the release, assembles latest.json for darwin-aarch64 and
  darwin-x86_64, and uploads it back as a release asset

Windows will be added to latest.json once the Certum cert is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:29:11 +02:00
github-actions[bot] df91396ba1 chore(nix): refresh lock + npmDepsHash for v1.34.14 2026-04-18 11:45:16 +00:00
Psychotoxical fb927c5a2e chore(release): v1.34.14
- feat(linux): WebKitGTK smooth/linear wheel scroll toggle (PR #207 by cucadmuh)
- ci(release): Apple Developer ID signing + notarization for macOS
- fix(themes): WCAG contrast audits for middle-earth + nucleo
- docs(contributors): PRs #205, #206, #207 in Settings About

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:31:46 +02:00
41 changed files with 3049 additions and 603 deletions
+63 -1
View File
@@ -122,9 +122,63 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
# Tauri Updater signing — produces .sig files alongside the update bundles
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
# done, which invalidates the .sig tauri CLI created (different hash).
# We can't stop the repack (it's tied to includeUpdaterJson), so we
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
VERSION=${{ needs.create-release.outputs.package_version }}
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "app-v${VERSION}" \
"Psysonic_${ARCH}.app.tar.gz.sig" \
--clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
build-linux:
needs: create-release
@@ -227,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: |
+85 -1
View File
@@ -5,7 +5,91 @@ 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 the last release 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.
>
> **⚠️ 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.41.0] - 2026-04-18
### Added
- **Mini player window — early alpha** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: A small always-on-top companion window with album art, title, artist, prev/play/next, progress bar, and a pin-on-top toggle. Opens via the new picture-in-picture icon in the player bar. The main window auto-minimizes when the mini opens and is restored when the mini is hidden or closed; an "expand" button in the mini jumps back without closing it. Spacebar toggles playback, arrow keys skip tracks. On tiling WMs (Hyprland/Sway/i3) the always-on-top flag is skipped since it wouldn't be honoured anyway. Lyrics, EQ, queue expand and drag-snap are deliberately out of scope for this first cut.
- **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.
### 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. `20202024`) in accent colour.
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`AZ (Album)`, `AZ (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.
### Fixed
- **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
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal.
---
## [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.
### Contributors
- **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.
---
## [1.34.13] - 2026-04-17
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-K6qJmD7XIUCfSA9US3PDt2VfyIMMbqTIdkpy7DGxLSM="
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.13",
"version": "1.41.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.12
pkgver=1.41.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// macOS-only for now — Windows + Linux are added once their signing pipelines
// (Certum cert for Windows, native package managers for Linux) are wired up.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
};
const platforms = {};
// A real minisign .sig file is multi-line and ~200+ chars.
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
function validateSignature(sig, platform, sigFile) {
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
throw new Error(
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
` Got: ${sig}\n` +
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
);
}
if (sig.length < 80) {
throw new Error(
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
);
}
}
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
validateSignature(signature, platform, sigFile);
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
+204 -9
View File
@@ -69,6 +69,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -968,6 +977,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1316,6 +1336,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2511,7 +2542,10 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.4",
]
[[package]]
@@ -2703,6 +2737,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2985,6 +3025,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3114,6 +3166,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3163,7 +3229,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -3396,6 +3462,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
@@ -3581,7 +3653,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.13"
version = "1.41.0"
dependencies = [
"biquad",
"discord-rich-presence",
@@ -3608,6 +3680,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thread-priority",
"tokio",
@@ -3833,6 +3906,15 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3951,15 +4033,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4120,6 +4207,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.12"
@@ -4602,7 +4716,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -5031,6 +5145,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5288,6 +5413,39 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -6028,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]]
@@ -6041,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]]
@@ -6181,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",
@@ -6235,6 +6393,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@@ -6972,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"
@@ -7139,6 +7312,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
@@ -7385,6 +7568,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.13"
version = "1.41.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -40,6 +40,7 @@ biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
url = "2"
+3 -2
View File
@@ -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",
@@ -37,6 +37,7 @@
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:webview:allow-create-webview-window",
"process:allow-restart"
"process:allow-restart",
"updater:default"
]
}
@@ -33,7 +33,6 @@ use std::fmt;
use std::mem;
use std::os::raw::c_char;
use std::ptr::null;
use std::rc::Rc;
use std::slice;
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::sync::{Arc, Mutex};
+394 -82
View File
@@ -1499,9 +1499,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 +1521,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 +1694,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 +1733,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 +1745,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 +1784,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 +1837,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 +1925,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 +1989,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 +2010,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 +2045,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 +2067,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 +2176,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 +2241,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 +2254,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 +2432,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 +2597,103 @@ 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.
/// Open (or toggle) the mini player window. Creates it on first call; on
/// subsequent calls, hides it if visible, shows + focuses it if hidden.
/// Opening the mini player minimizes the main window; hiding the mini player
/// restores the main window.
#[tauri::command]
fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
let visible = win.is_visible().unwrap_or(false);
if visible {
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();
}
} else {
win.show().map_err(|e| e.to_string())?;
let _ = win.set_focus();
if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize();
}
}
return Ok(());
}
let use_always_on_top = {
#[cfg(target_os = "linux")]
{ !is_tiling_wm() }
#[cfg(not(target_os = "linux"))]
{ true }
};
let win = tauri::WebviewWindowBuilder::new(
&app,
"mini",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 140.0)
.min_inner_size(320.0, 120.0)
.resizable(true)
.decorations(true)
.always_on_top(use_always_on_top)
.skip_taskbar(false)
.build()
.map_err(|e| format!("failed to build mini player window: {e}"))?;
let _ = win.set_focus();
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.
#[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
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.
#[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") {
win.set_always_on_top(on_top).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")]
@@ -2425,6 +2720,7 @@ pub fn run() {
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
.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_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -2595,6 +2891,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();
}
}
}
})
@@ -2610,6 +2916,10 @@ pub fn run() {
set_linux_webkit_smooth_scrolling,
no_compositing_mode,
is_tiling_wm_cmd,
open_mini_player,
close_mini_player,
set_mini_player_always_on_top,
show_main_window,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
@@ -2664,6 +2974,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,
+12 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.13",
"version": "1.41.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -31,6 +31,17 @@
"csp": null
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCNzk5MUNCRDQ4N0UwODgKUldTSTRJZlV5NUY1aThucWM3RTh4ZmpwblR1amh4R2lER3NjZDgrQTQwVGNFaWFtVStsUVBhYzkK",
"endpoints": [
"https://github.com/Psychotoxical/psysonic/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
},
"bundle": {
"active": true,
"targets": "all",
+32
View File
@@ -38,6 +38,8 @@ import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser';
import DeviceSync from './pages/DeviceSync';
import NowPlayingPage from './pages/NowPlaying';
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';
@@ -325,6 +327,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 (
@@ -930,6 +946,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 +960,16 @@ 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]);
if (isMiniWindow) {
return <MiniPlayer />;
}
// 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
+2
View File
@@ -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`). */
+177 -18
View File
@@ -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,20 +155,93 @@ 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
// relaunches. No manual "open the DMG" step needed.
if (IS_MACOS) {
setDlState('downloading');
setDlProgress({ bytes: 0, total: 0 });
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'));
setDlState('error');
return;
}
let downloaded = 0;
let total = 0;
await update.downloadAndInstall(event => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0;
setDlProgress({ bytes: 0, total });
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
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);
}
});
} catch (e) {
setDlError(String(e));
setDlState('error');
}
return;
}
if (!asset) return;
setDlState('downloading');
setDlProgress({ bytes: 0, total: asset.size });
@@ -273,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' && (
@@ -320,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>
+3 -1
View File
@@ -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>
+12 -6
View File
@@ -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
View File
@@ -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>
</>
);
}
+151
View File
@@ -0,0 +1,151 @@
import React, { useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X } from 'lucide-react';
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import type { MiniSyncPayload, MiniControlAction } from '../utils/miniPlayerBridge';
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 [state, setState] = useState<MiniSyncPayload>({ track: null, isPlaying: false, isMobile: false });
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const ticker = useRef<number | null>(null);
// Announce to main window that we're mounted; it replies with a snapshot.
useEffect(() => {
emit('mini:ready', {}).catch(() => {});
}, []);
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
const tag = tgt?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) 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 { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player">
<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">
<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')}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')}>
<SkipForward size={16} />
</button>
</div>
<div className="mini-player__progress">
<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>
</div>
<div className="mini-player__toolbar">
<button
className={`mini-player__tool${alwaysOnTop ? ' mini-player__tool--active' : ''}`}
onClick={toggleOnTop}
data-tooltip={alwaysOnTop ? 'Pin off' : 'Pin on top'}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button className="mini-player__tool" onClick={showMain} data-tooltip="Open main window">
<Maximize2 size={13} />
</button>
<button className="mini-player__tool" onClick={closeMini} data-tooltip="Close">
<X size={13} />
</button>
</div>
</div>
);
}
+13 -1
View File
@@ -2,8 +2,10 @@ 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,
} 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';
@@ -310,6 +312,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
+133
View File
@@ -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,
)}
</>
);
}
+167
View File
@@ -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,
)}
</>
);
}
+36 -7
View File
@@ -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.',
@@ -1135,9 +1154,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 +1192,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.',
+36 -7
View File
@@ -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',
@@ -1137,9 +1156,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 +1195,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.',
+9
View File
@@ -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',
+9
View File
@@ -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',
+9
View File
@@ -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',
+9
View File
@@ -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',
+11
View File
@@ -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: 'Отмена',
+9
View File
@@ -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: '取消',
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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 })
+2 -5
View File
@@ -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,
}),
}
+373 -80
View File
@@ -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,165 @@ 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. */
/* ─ Mini Player window ───────────────────────────────────────────────────── */
.mini-player {
width: 100%;
height: 100vh;
display: grid;
grid-template-columns: 112px 1fr;
grid-template-rows: 1fr auto;
gap: 10px;
padding: 12px;
background: var(--bg-app);
color: var(--text-primary);
overflow: hidden;
user-select: none;
box-sizing: border-box;
}
.mini-player__art {
grid-row: 1 / span 2;
aspect-ratio: 1;
width: 112px;
height: 112px;
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 {
padding: 0 2px;
display: flex;
flex-direction: column;
gap: 6px;
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;
margin-top: 2px;
}
.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 {
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-player__toolbar {
grid-column: 2;
display: flex;
gap: 4px;
justify-content: flex-end;
}
.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;
}
+74
View File
@@ -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;
+1 -1
View File
@@ -4035,7 +4035,7 @@ select.input.input:focus {
to {
opacity: 1;
transform: translateY(0);
transform: none;
}
}
+107
View File
@@ -0,0 +1,107 @@
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 MiniSyncPayload {
track: {
id: string;
title: string;
artist: string;
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
} | null;
isPlaying: boolean;
isMobile: false;
}
export type MiniControlAction =
| 'toggle'
| 'next'
| 'prev'
| 'show-main';
function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState();
const t = s.currentTrack;
return {
track: t ? {
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,
} : null,
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 key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}`;
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) {
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;
}
}
});
return () => {
unsub();
readyUnlisten.then(fn => fn()).catch(() => {});
controlUnlisten.then(fn => fn()).catch(() => {});
};
}