Compare commits

..

36 Commits

Author SHA1 Message Date
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
Psychotoxical 7ee03e917b ci(release): sign and notarize macOS builds
Wire up Developer ID Application certificate + App Store Connect API Key
to tauri-action on the macOS runner. Decodes the base64-encoded .p8 into
a file before the build, exports APPLE_API_KEY_PATH via $GITHUB_ENV, and
forwards the remaining credentials through the env block. Windows runner
receives the vars but tauri ignores them off-platform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 13:26:17 +02:00
Frank Stellmacher 21f65915db Update README.md 2026-04-18 12:31:16 +02:00
Psychotoxical 33ba1dfa28 fix(themes): WCAG contrast audit for nucleo
Palette tokens lifted to AA on bg-card (#eedbb8):
- --warning (#a88020 → #7a5b0e): 2.68 → 4.64
- --border  (#b89a69 → #765a38): 1.97 → 4.70 (darker bronze, 3:1 UI on
  all bg variants including bg-player)
- --text-muted (#8c7356 → #6b5636): 3.29 → 5.14
- --positive  (#587838 → #3e5820): 3.72 → 5.90

Component override for column resize grip (default --ctp-surface1 is
1.08:1 on bg-card, invisible on this warm cream palette) — switch to
--border (4.70:1) with 2px width for a perceptible bronze grip.

Warm brass/aged-parchment palette preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:26:03 +02:00
Psychotoxical f60dabbd31 fix(themes): WCAG contrast audit for middle-earth
- Lift --warning (#c88a10 → #805408) from 2.42:1 to 5.38:1 on bg-card;
  deeper tanner's gold in line with Rohan sun motifs
- Strengthen --border (rgba 0.38 → 0.85 alpha, umber) from 2.64:1 to 3.79:1
  for the 3:1 UI-component threshold
- Raise --text-muted (#6a5230 → #5a4428) to reach AA on card/surface1
- Component overrides on dark sidebar (#241a0e): .connection-type/-server
  (1.95 → 5.52), .nav-section-label, .lyrics-line/-status,
  .queue-item-duration, .queue-divider span, .player-time
- Darken --text-muted override inside parchment .glass panel (3.89 → 7.37)

Warm bronze/brass/aged-oak palette preserved — no cold tones introduced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:02:14 +02:00
Psychotoxical f8bc39036a docs(contributors): add PRs #205, #206, #207 to Settings About list
- cucadmuh: WebKitGTK wheel scroll mode (PR #207)
- kilyabin: Apple Music-style scrolling lyrics (PR #205)
- kilyabin: Golos Text and Unbounded fonts with Cyrillic support (PR #206)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:02:03 +02:00
Frank Stellmacher f9f39ccfc2 feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)
feat(linux): WebKitGTK wheel scroll mode (smooth default, optional linear)
2026-04-18 11:25:41 +02:00
Psychotoxical a8317f5877 fix(linux): guard webkit smooth scroll migration behind IS_LINUX
Migration key was written to localStorage on all platforms; the one-time
default-to-smooth logic is only relevant on Linux (WebKitGTK).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 11:25:09 +02:00
Frank Stellmacher 46bbae873e Aktualisieren von README.md 2026-04-18 10:11:36 +02:00
Maxim Isaev ba43ed867a feat(linux): add WebKitGTK smooth wheel scroll toggle in settings
Persist preference in auth store, sync from App on Linux, and expose a
Tauri command using webkit2gtk to toggle enable-smooth-scrolling at
runtime. Default on to match upstream; users may disable for discrete
GTK-style line steps.

Apply a one-time rehydrate migration so smooth scrolling stays on after
updates even if an older build persisted the wrong default.
2026-04-18 05:09:33 +03:00
Psychotoxical 832bacb569 feat(ui-scale): re-enable scoped interface scaling
Document-level zoom broke portal positioning and Tauri window measurement,
so the slider had been forced to 100 %. Scope the zoom to a wrapper inside
.main-content instead — sidebar, queue, player bar and the Linux custom
title bar live in sibling grid cells of .app-shell and stay 1:1.

- App.tsx: drop the document-level zoom + auto-reset; wrap main-content
  children in <div class="main-content-zoom" style={zoom: uiScale}>.
- layout.css: add .main-content-zoom (flex column, fills parent).
- Settings.tsx: re-enable the slider and snap it to the 6 preset stops
  (80/90/100/110/125/150) so the thumb lands directly under each preset
  button. Legacy off-preset values snap to the nearest stop on load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:34:49 +02:00
Psychotoxical b9093883a4 fix(themes): WCAG contrast audit for w98 + stark-hud
w98:
- Lift --positive (#008000#005000), --warning (#808000 → #5a4500),
  --danger (#ff0000 → #b00000) from ~2.6:1 to AA on bg-card. Brand
  --ctp-* palette preserved as canonical W98 system colours.
- --border-subtle was identical to --bg-card (1.0:1, invisible) →
  #a0a0a0 (1.73:1).
- Override .col-resize-handle::after to W98 dialog grey (was 1.23:1).
- text-secondary/muted intentionally kept at #000000: AlbumDetail
  renders text on bg-app teal where any non-black tone falls below AA.

stark-hud:
- Lift --text-muted (#5a718a → #7d92a8, 3.34 → 5.73:1) staying in the
  slate-blue family that matches the HUD aesthetic.
- --border-subtle was identical to --bg-card (invisible) → #243246.
- Override .col-resize-handle::after to overlay1 (was 1.09:1).
- Cyan accent, all glow shadows and chromatic --ctp-* untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:21:05 +02:00
Psychotoxical 77edfaa867 perf(fs-player): cut CPU under WebKitGTK software compositing
WebKitGTK on Linux runs with WEBKIT_DISABLE_COMPOSITING_MODE=1, so every
GPU-only effect is software-rasterised per frame. The fullscreen player
piled up large blur shadows, per-line scale transforms, oversized
radial-gradients with color-mix() and a 60 fps rAF spring on top — CPU
saturated and FPS dropped to ~10.

Linux-only via existing html.no-compositing class:
- Apple-style lyrics: drop transform: scale and 48 px / 32 px text-shadow
- Rail-style lyrics: drop 28 px text-shadow + scaleX
- Track title: 40 px glow → 6 px drop-shadow
- Album art wrap + play button: drop accent-coloured outer glows
- Mesh blobs: flatten 130 % radial-gradient + color-mix to solid #06060e
  and remove the 400 ms background transition
- Portrait wrap: drop translateZ(0) GPU-layer hint
- FS art: instant cover swap (no opacity crossfade)
- SpringScroller: replace rAF spring with native scrollTo({behavior:'smooth'})
- FsSeekbar: cache last applied values, skip identical DOM writes

Dynamic accent on title / seekbar / play button is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:06:04 +02:00
Psychotoxical bac0417e00 fix(themes): WCAG contrast audit for wnamp/ubuntu/w11/powerslave/dracula/vision-*
Raise text-muted, borders, grips and waveform tokens to AA/AAA where
they fell below threshold. Add component-level overrides (col-resize
grip, focus ring, scrollbar thumb) to themes whose --ctp-surface1
collided with --bg-card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:46:09 +02:00
Frank Stellmacher f30920bfeb Merge pull request #206 from kilyabin/add-fonts
feat: fonts with cyrillic support
2026-04-18 00:40:56 +02:00
Frank Stellmacher 8e72e7084b Merge pull request #205 from kilyabin/apple-music-style-lyric
feat(lyrics): Apple Music-style scrolling lyrics with per-style controls
2026-04-18 00:40:45 +02:00
Psychotoxical dcd356aee7 fix(lyrics): keep classic styles as default
Existing users keep the rail/classic experience they're used to;
the new Apple Music-like style is opt-in via the FS popover and
the Sidebar Lyrics Style setting in Appearance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:39:03 +02:00
kilyabin 4f188be792 feat: fonts with cyrillic support 2026-04-18 02:16:57 +04:00
kilyabin cd1417b604 feat: Apple-Music-like lyrics view with saving old-style 2026-04-18 01:58:17 +04:00
Psychotoxical ee437fca5a ci(nix): push store paths to Cachix during verify-nix
Wires cachix/cachix-action@v15 into the verify-nix job. The action
watches the nix store for the duration of the workflow and uploads
new paths to the `psysonic` cache on cachix.org automatically.

Using Cachix-managed signing (no signing key on our side) — Cachix
signs server-side with the cache's built-in keypair. End users will
pick up the public key automatically via `cachix use psysonic`, so
no manual nix.conf configuration is required.

Consequence: after the next `v*` release, `nix profile install
github:Psychotoxical/psysonic` pulls a prebuilt binary from Cachix
instead of recompiling Rust + npm deps locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:51:36 +02:00
github-actions[bot] e102320e32 chore(nix): refresh lock + npmDepsHash for v1.34.13 2026-04-17 20:48:19 +00:00
35 changed files with 3241 additions and 689 deletions
+84 -6
View File
@@ -103,14 +103,82 @@ jobs:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
# Apple signing + notarization (macOS runner only — ignored on Windows)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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
@@ -160,12 +228,13 @@ jobs:
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
# Verifies that `nix build .#psysonic` still works against the current source
# and refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
# (nixpkgs pin). Commits the refreshed files back to `main` when they change.
# Verifies that `nix build .#psysonic` still works against the current source,
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
# without having to compile locally.
#
# Tarball publishing / Cachix upload are intentionally out of scope here —
# those will live in a dedicated workflow tied to a binary cache setup.
# The refreshed lock/hash files are committed back to `main` when they change.
verify-nix:
needs: create-release
runs-on: ubuntu-24.04
@@ -183,6 +252,15 @@ jobs:
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
# server-side). The action watches the nix store during subsequent build
# steps and uploads new paths automatically.
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
@@ -202,7 +280,7 @@ jobs:
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build
- name: verify nix build + push to Cachix
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
- name: commit + push refreshed lock and hash (if changed)
+47 -1
View File
@@ -5,7 +5,53 @@ 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 months 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.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
+2 -2
View File
@@ -9,7 +9,7 @@
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/ckVPGPMS"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
@@ -19,7 +19,7 @@
---
<div align="center">
<a href="https://discord.gg/ckVPGPMS">
<a href="https://discord.gg/AMnDRErm4u">
<img src="https://img.shields.io/badge/Join%20the%20Psysonic%20Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"/>
</a>
<p>Have questions, ideas, or just want to hang out? Come chat in our Discord server!</p>
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776169885,
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-0000000000000000000000000000000000000000000="
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
}
+22 -2
View File
@@ -1,16 +1,17 @@
{
"name": "psysonic",
"version": "1.34.11",
"version": "1.34.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.34.11",
"version": "1.34.13",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/golos-text": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
@@ -20,6 +21,7 @@
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
@@ -814,6 +816,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/golos-text": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/golos-text/-/golos-text-5.2.8.tgz",
"integrity": "sha512-cLT8Gu9tSQTOjfPY+qnrqQwafUUJkZu0s9hTbQbtaeknTyV36c/FQc5hvTRvgeUOT4cp/Xf0dkHdZatwj3g2Nw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
@@ -895,6 +906,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/unbounded": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.2.8.tgz",
"integrity": "sha512-DWC/HEdNNbjMH6ngeeCAPExKMsedoY+pV3ZnRXzFcAzXuGHB6dEwsXNVQ4fiuuYMGguq9TSAEUat4Oy5prdwWQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.13",
"version": "1.40.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -15,6 +15,7 @@
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/golos-text": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
@@ -24,6 +25,7 @@
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
+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(', ')}`);
+193 -3
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.40.0"
dependencies = [
"biquad",
"discord-rich-presence",
@@ -3608,10 +3680,12 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thread-priority",
"tokio",
"url",
"webkit2gtk",
"windows 0.58.0",
"zbus 5.14.0",
]
@@ -3832,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"
@@ -3950,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",
@@ -4119,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"
@@ -4601,7 +4716,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -5030,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"
@@ -5287,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"
@@ -6234,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"
@@ -7138,6 +7306,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"
@@ -7384,6 +7562,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"
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.13"
version = "1.40.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"
@@ -54,6 +55,8 @@ libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
+2 -1
View File
@@ -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};
+293 -77
View File
@@ -90,6 +90,35 @@ fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
}
}
/// WebKitGTK: `enable-smooth-scrolling` also drives deferred / kinetic wheel scrolling.
#[cfg(target_os = "linux")]
fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, enabled: bool) -> Result<(), String> {
win.with_webview(move |platform| {
use webkit2gtk::{SettingsExt, WebViewExt};
if let Some(settings) = platform.inner().settings() {
settings.set_enable_smooth_scrolling(enabled);
}
})
.map_err(|e| e.to_string())
}
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
#[tauri::command]
fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(win) = app_handle.get_webview_window("main") {
linux_webkit_apply_smooth_scrolling(&win, enabled)?;
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (enabled, app_handle);
}
Ok(())
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -1470,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())
}
@@ -1486,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 `/`
@@ -1527,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.
@@ -1552,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
@@ -1563,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.
@@ -1596,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();
@@ -1650,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)
@@ -1742,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)]
@@ -1802,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))
@@ -1824,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); }
@@ -1858,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 {
@@ -1879,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);
}
}
}
@@ -1962,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,
@@ -2028,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();
@@ -2042,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();
@@ -2396,6 +2608,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())
@@ -2578,6 +2791,7 @@ pub fn run() {
cli_publish_server_list,
cli_publish_search_results,
set_window_decorations,
set_linux_webkit_smooth_scrolling,
no_compositing_mode,
is_tiling_wm_cmd,
register_global_shortcut,
@@ -2634,6 +2848,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.40.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",
+13 -13
View File
@@ -127,6 +127,7 @@ function AppShell() {
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const uiScale = useFontStore(s => s.uiScale);
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -138,6 +139,7 @@ function AppShell() {
const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
@@ -150,6 +152,11 @@ function AppShell() {
invoke('set_window_decorations', { enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => {
if (!IS_LINUX) return;
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
}, [linuxWebkitKineticScroll]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
@@ -341,6 +348,7 @@ function AppShell() {
/>
)}
<main className="main-content">
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
<header className="content-header">
<LiveSearch />
<div className="spacer" />
@@ -389,6 +397,7 @@ function AppShell() {
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
</div>
</div>
</main>
{!isMobile && (
<div
@@ -919,8 +928,6 @@ export default function App() {
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const setUiScale = useFontStore(s => s.setUiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
@@ -931,17 +938,10 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
// Reset any stored non-100% value so users aren't stuck at a broken scale.
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
useEffect(() => {
if (uiScale !== 1.0) setUiScale(1.0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
// 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
// — it broke portal positioning and Tauri window measurement.
useEffect(() => {
return initAudioListeners();
+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>
+340 -75
View File
@@ -13,6 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -21,19 +22,197 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ─── Fullscreen lyrics overlay ────────────────────────────────────────────────
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
// railY = (2 - activeIdx) * slotH centers slot `activeIdx` in a 5-slot window:
// activeIdx=0 → railY=+2×slotH (line 0 at slot 2)
// activeIdx=2 → railY=0 (line 2 at center)
// activeIdx=5 → railY=-3×slotH (line 5 at slot 2)
// ─── Apple Music-style fullscreen lyrics ─────────────────────────────────────
// Full-screen scrollable list. Active line auto-scrolls to ~35% from top.
// Word-sync runs imperatively (no React re-renders on every time tick).
// User scroll pauses auto-scroll for 4 s then resumes.
const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track | null }) {
const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, wordLines, plainLyrics, loading } = useLyrics(currentTrack);
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const lineSrc: LrcLine[] | null = useWords
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
: (syncedLines as LrcLine[] | null);
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
const linesRef = useRef<LrcLine[]>([]);
linesRef.current = hasSynced ? lineSrc! : [];
// React state only for the active line index — changes are infrequent.
const [activeIdx, setActiveIdx] = useState(-1);
const activeIdxRef = useRef(-1);
const containerRef = useRef<HTMLDivElement>(null);
const springRef = useRef<SpringScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Create/destroy the SpringScroller when the container mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (el) {
springRef.current = new SpringScroller(el, 0.1, 0.78);
} else {
springRef.current?.stop();
springRef.current = null;
}
}, []);
// Reset everything on track change.
useEffect(() => {
lineRefs.current = [];
wordRefs.current = [];
prevWord.current = { line: -1, word: -1 };
activeIdxRef.current = -1;
setActiveIdx(-1);
springRef.current?.jump(0);
}, [currentTrack?.id]);
// Subscribe to playback time — only triggers React setState when line changes.
useEffect(() => {
if (!hasSynced) return;
const apply = (time: number) => {
const ls = linesRef.current;
if (!ls.length) return;
const idx = ls.reduce((acc, line, i) => time >= line.time ? i : acc, -1);
if (idx !== activeIdxRef.current) {
activeIdxRef.current = idx;
setActiveIdx(idx);
}
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]);
// Spring-scroll active line to ~35% from the top of the container.
useEffect(() => {
if (activeIdx < 0 || isUserScroll.current) return;
const el = lineRefs.current[activeIdx];
const box = containerRef.current;
if (!el || !box || !springRef.current) return;
springRef.current.scrollTo(targetForFraction(box, el, 0.35));
}, [activeIdx]);
// Word-sync: imperative DOM updates, zero React re-renders per tick.
useEffect(() => {
wordRefs.current = [];
prevWord.current = { line: -1, word: -1 };
}, [currentTrack?.id, useWords]);
useEffect(() => {
if (!useWords) return;
const lines = wordLines as WordLyricsLine[];
const apply = (time: number) => {
let li = -1;
for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; }
let wi = -1;
if (li >= 0) {
const ws = lines[li].words;
for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; }
}
const prev = prevWord.current;
if (prev.line === li && prev.word === wi) return;
if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line])
for (const w of wordRefs.current[prev.line]) w.className = 'fsa-lyric-word';
if (li >= 0 && wordRefs.current[li]) {
const ws = wordRefs.current[li];
for (let j = 0; j < ws.length; j++)
ws[j].className = j < wi ? 'fsa-lyric-word played' : j === wi ? 'fsa-lyric-word active' : 'fsa-lyric-word';
}
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [useWords, wordLines]);
const handleUserScroll = useCallback(() => {
// Stop spring animation so it doesn't fight the user's scroll.
springRef.current?.stop();
isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
}, []);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
if (!target || duration <= 0) return;
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
if (!currentTrack || loading) return null;
return (
<div
className="fsa-lyrics-container"
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onClick={handleClick}
aria-hidden="true"
>
<div className="fsa-lyrics-top-pad" />
{hasSynced && (useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0
? line.words.map((w, j) => (
<span
key={j}
className="fsa-lyric-word"
ref={el => {
if (!wordRefs.current[i]) wordRefs.current[i] = [];
if (el) wordRefs.current[i][j] = el;
}}
>{w.text}</span>
))
: (line.text || '\u00A0')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
</div>
))
)}
{!hasSynced && plainLyrics && (
<div className="fsa-plain-lyrics">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="fsa-plain-line">{line || '\u00A0'}</p>
))}
</div>
)}
<div className="fsa-lyrics-bottom-pad" />
</div>
);
});
// ─── Classic 5-line rail lyrics (original "Rail" style) ──────────────────────
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
// Static-only hides the FS overlay entirely — the 5-line rail UX is inherently
// time-driven; without sync the pane view is the correct surface.
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const lineSrc: LrcLine[] | null = useWords
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
@@ -65,10 +244,8 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
// Per-word DOM refs keyed by line index; imperative updates skip re-renders
// on progress ticks. Only populated when `useWords` is true.
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef<{ line: number; word: number }>({ line: -1, word: -1 });
useEffect(() => {
wordRefs.current = [];
@@ -78,41 +255,27 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
useEffect(() => {
if (!useWords) return;
const lines = wordLines as WordLyricsLine[];
const apply = (time: number) => {
let lineIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (time >= lines[i].time) lineIdx = i;
else break;
}
let wordIdx = -1;
if (lineIdx >= 0) {
const words = lines[lineIdx].words;
for (let j = 0; j < words.length; j++) {
if (time >= words[j].time) wordIdx = j;
else break;
}
let li = -1;
for (let i = 0; i < lines.length; i++) { if (time >= lines[i].time) li = i; else break; }
let wi = -1;
if (li >= 0) {
const ws = lines[li].words;
for (let j = 0; j < ws.length; j++) { if (time >= ws[j].time) wi = j; else break; }
}
const prev = prevWord.current;
if (prev.line === lineIdx && prev.word === wordIdx) return;
// Clear previous line's word classes.
if (prev.line !== lineIdx && prev.line >= 0 && wordRefs.current[prev.line]) {
for (const w of wordRefs.current[prev.line]) w.className = 'fs-lyric-word';
if (prev.line === li && prev.word === wi) return;
if (prev.line !== li && prev.line >= 0 && wordRefs.current[prev.line])
for (const w of wordRefs.current[prev.line]) w.className = 'fsr-lyric-word';
if (li >= 0 && wordRefs.current[li]) {
const ws = wordRefs.current[li];
for (let j = 0; j < ws.length; j++)
ws[j].className = j < wi ? 'fsr-lyric-word played' : j === wi ? 'fsr-lyric-word active' : 'fsr-lyric-word';
}
if (lineIdx >= 0 && wordRefs.current[lineIdx]) {
const ws = wordRefs.current[lineIdx];
for (let j = 0; j < ws.length; j++) {
ws[j].className = j < wordIdx ? 'fs-lyric-word played'
: j === wordIdx ? 'fs-lyric-word active'
: 'fs-lyric-word';
}
}
prevWord.current = { line: lineIdx, word: wordIdx };
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
return unsub;
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null;
@@ -120,9 +283,9 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
return (
<div className="fs-lyrics-overlay" aria-hidden="true">
<div className="fsr-lyrics-overlay" aria-hidden="true">
<div
className="fs-lyrics-rail"
className="fsr-lyrics-rail"
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
@@ -130,27 +293,25 @@ const FsLyrics = memo(function FsLyrics({ currentTrack }: { currentTrack: Track
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="fs-lyric-word"
className="fsr-lyric-word"
ref={el => {
if (!wordRefs.current[i]) wordRefs.current[i] = [];
if (el) wordRefs.current[i][j] = el;
}}
>
{w.text}
</span>
>{w.text}</span>
)) : (line.text || '\u00A0')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
className={`fs-lyric-line${i === activeIdx ? ' fsl-active' : i < activeIdx ? ' fsl-past' : ''}`}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.text || '\u00A0'}
@@ -273,20 +434,40 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const s = usePlayerStore.getState();
const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress);
// Cache the last applied values so we can skip DOM writes when nothing
// changed. The store subscription fires on every state change (queue,
// volume, currentTrack…), not just progress, so without this guard we
// were writing identical width/textContent strings dozens of times per
// second — each one triggers a style invalidation in WebKitGTK.
let lastTime = -1;
let lastPct = -1;
let lastBufW = -1;
let lastProg = -1;
return usePlayerStore.subscribe(state => {
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
if (playedRef.current) playedRef.current.style.width = `${p}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(state.progress);
});
const apply = (s: ReturnType<typeof usePlayerStore.getState>) => {
const pct = s.progress * 100;
const bufW = Math.max(pct, s.buffered * 100);
const sec = s.currentTime | 0;
if (sec !== lastTime) {
lastTime = sec;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
}
if (pct !== lastPct) {
lastPct = pct;
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
}
if (bufW !== lastBufW) {
lastBufW = bufW;
if (bufRef.current) bufRef.current.style.width = `${bufW}%`;
}
if (s.progress !== lastProg) {
lastProg = s.progress;
if (inputRef.current) inputRef.current.value = String(s.progress);
}
};
apply(usePlayerStore.getState());
return usePlayerStore.subscribe(apply);
}, []);
const handleSeek = useCallback(
@@ -316,6 +497,77 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
);
});
// ─── Lyrics settings popover — shown above the mic button ────────────────────
interface FsLyricsMenuProps {
open: boolean;
onClose: () => void;
accentColor: string | null;
}
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
const { t } = useTranslation();
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
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.
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();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
return () => {
clearTimeout(t);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose]);
if (!open) return null;
const accent = accentColor ?? 'var(--accent)';
return (
<div className="fslm-panel" ref={panelRef}>
{/* Toggle row */}
<div className="fslm-row">
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
<input
type="checkbox"
checked={showLyrics}
onChange={e => setLyrics(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
{/* Style selector — dimmed when lyrics are off */}
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
{(['rail', 'apple'] as const).map(style => (
<button
key={style}
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
onClick={() => setStyle(style)}
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
>
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
</button>
))}
</div>
<div className="fslm-arrow" />
</div>
);
});
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
@@ -428,8 +680,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache.
// Selector returns only the coverArt id, so it only re-runs on actual changes.
@@ -445,6 +699,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
getCachedUrl(url, key).catch(() => {});
}, [nextCoverArt]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
// Idle-fade system — hides controls after 3 s of inactivity
const [isIdle, setIsIdle] = useState(false);
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -492,6 +750,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
data-lyrics={isAppleMode || undefined}
onMouseMove={handleMouseMove}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
@@ -505,7 +764,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
@@ -516,8 +775,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<ChevronDown size={28} />
</button>
{/* Lyrics overlay — upper-left quadrant, above cluster */}
{showFullscreenLyrics && <FsLyrics currentTrack={currentTrack} />}
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
@@ -575,15 +837,18 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className="fs-btn fs-btn-sm"
onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
<button
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
</div>
+58 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import type { LrcLine } from '../api/lrclib';
@@ -6,21 +6,25 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll';
interface Props {
currentTrack: Track | null;
}
/**
* Word-sync highlighting is driven imperatively via `usePlayerStore.subscribe`
* so the whole lyrics block doesn't re-render on every 500 ms progress tick.
* Active-line scroll is still React-state-driven lines change infrequently.
* Apple Music-style scroll: active line scrolls to ~35% from top.
* User scrolling pauses auto-scroll for 4 s then resumes.
* Word-sync and line highlighting are imperative (no React re-renders per tick).
*/
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const { syncedLines, wordLines, plainLyrics, source, loading, notFound } = useLyrics(currentTrack);
const { staticOnly } = useAuthStore(useShallow(s => ({ staticOnly: s.lyricsStaticOnly })));
const { staticOnly, sidebarLyricsStyle } = useAuthStore(useShallow(s => ({
staticOnly: s.lyricsStaticOnly,
sidebarLyricsStyle: s.sidebarLyricsStyle,
})));
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const hasSynced = !staticOnly && !useWords && syncedLines !== null && syncedLines.length > 0;
@@ -28,19 +32,52 @@ export default function LyricsPane({ currentTrack }: Props) {
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevActive = useRef({ line: -1, word: -1 });
const containerRef = useRef<HTMLDivElement | null>(null);
const springRef = useRef<SpringScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevActive = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Attach/detach SpringScroller when the pane mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
springRef.current?.stop();
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null;
}, []);
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
const handleUserScroll = useCallback(() => {
springRef.current?.stop();
isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
}, []);
// Scroll active line into view.
// Apple style: spring-animate to ~35% from top.
// Classic style: native scrollIntoView center.
const scrollToLine = useCallback((el: HTMLDivElement) => {
if (isUserScroll.current) return;
const container = containerRef.current;
if (!container) return;
if (sidebarLyricsStyle === 'apple' && springRef.current) {
springRef.current.scrollTo(targetForFraction(container, el, 0.35));
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [sidebarLyricsStyle]);
// Reset refs when track changes.
useEffect(() => {
lineRefs.current = [];
wordRefs.current = [];
lineRefs.current = [];
wordRefs.current = [];
prevActive.current = { line: -1, word: -1 };
springRef.current?.jump(0);
}, [currentTrack?.id]);
// Imperative tracker for line+word highlighting. Subscribes directly to the
// store to skip React render cycles for 500 ms progress ticks.
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
useEffect(() => {
if (!useWords && !hasSynced) return;
@@ -64,7 +101,6 @@ export default function LyricsPane({ currentTrack }: Props) {
const prev = prevActive.current;
if (prev.line === lineIdx && prev.word === wordIdx) return;
// Update line classes.
if (prev.line !== lineIdx) {
if (prev.line >= 0) {
const el = lineRefs.current[prev.line];
@@ -74,16 +110,14 @@ export default function LyricsPane({ currentTrack }: Props) {
const el = lineRefs.current[lineIdx];
if (el) {
el.className = lineClass(lineIdx, lineIdx);
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
scrollToLine(el);
}
}
// Reset all word classes on previous line.
if (useWords && prev.line >= 0 && wordRefs.current[prev.line]) {
for (const w of wordRefs.current[prev.line]) w.className = 'lyrics-word';
}
}
// Update word classes inside the active line.
if (useWords && lineIdx >= 0 && wordRefs.current[lineIdx]) {
const ws = wordRefs.current[lineIdx];
for (let j = 0; j < ws.length; j++) {
@@ -96,11 +130,9 @@ export default function LyricsPane({ currentTrack }: Props) {
prevActive.current = { line: lineIdx, word: wordIdx };
};
// Prime once from the current store value.
apply(usePlayerStore.getState().currentTime);
const unsub = usePlayerStore.subscribe(s => apply(s.currentTime));
return unsub;
}, [useWords, hasSynced, wordLines, syncedLines]);
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
if (!currentTrack) {
return (
@@ -120,14 +152,18 @@ export default function LyricsPane({ currentTrack }: Props) {
? t('player.lyricsSourceLyricsplus')
: null;
// Static-only + synced or words available → render line list as static text.
const renderAsStatic = staticOnly && (
(syncedLines !== null && syncedLines.length > 0) ||
(wordLines !== null && wordLines.length > 0)
);
return (
<div className="lyrics-pane">
<div
className="lyrics-pane"
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
>
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
+38 -7
View File
@@ -407,6 +407,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.',
@@ -538,6 +547,8 @@ export const deTranslation = {
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
useCustomTitlebar: 'Eigene Titelleiste',
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)',
linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.',
discordAppleCovers: 'Cover über Apple Music für Discord laden',
discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.',
discordOptions: 'Erweiterte Discord-Optionen',
@@ -673,6 +684,16 @@ export const deTranslation = {
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
fsPortraitDim: 'Abdunkelung des Fotos',
fsLyricsStyle: 'Liedtext-Stil',
fsLyricsStyleRail: 'Schiene',
fsLyricsStyleRailDesc: 'Klassische 5-Zeilen-Schiene.',
fsLyricsStyleApple: 'Scrollen',
fsLyricsStyleAppleDesc: 'Vollbild-Scrollliste.',
sidebarLyricsStyle: 'Text-Scroll-Stil',
sidebarLyricsStyleClassic: 'Klassisch',
sidebarLyricsStyleClassicDesc: 'Aktive Zeile wird zentriert.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
seekbarWaveform: 'Wellenform',
@@ -1123,9 +1144,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',
@@ -1146,11 +1182,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.',
+38 -7
View File
@@ -409,6 +409,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',
@@ -540,6 +549,8 @@ export const enTranslation = {
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
useCustomTitlebar: 'Custom title bar',
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
linuxWebkitSmoothScroll: 'Smooth wheel (Linux)',
linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.',
discordAppleCovers: 'Fetch covers from Apple Music for Discord',
discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.',
discordOptions: 'Advanced Discord options',
@@ -672,6 +683,16 @@ export const enTranslation = {
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental',
fsPlayerSection: 'Fullscreen Player',
fsLyricsStyle: 'Lyrics style',
fsLyricsStyleRail: 'Rail',
fsLyricsStyleRailDesc: 'Classic 5-line sliding rail.',
fsLyricsStyleApple: 'Scrolling',
fsLyricsStyleAppleDesc: 'Full-screen scrollable list.',
sidebarLyricsStyle: 'Lyrics scroll style',
sidebarLyricsStyleClassic: 'Classic',
sidebarLyricsStyleClassicDesc: 'Scroll active line to center.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
fsShowArtistPortrait: 'Show artist photo',
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
fsPortraitDim: 'Photo dimming',
@@ -1125,9 +1146,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',
@@ -1149,11 +1185,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.',
+12
View File
@@ -541,6 +541,8 @@ export const esTranslation = {
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
useCustomTitlebar: 'Barra de título personalizada',
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
discordAppleCovers: 'Obtener portadas de Apple Music para Discord',
discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.',
discordOptions: 'Opciones avanzadas de Discord',
@@ -676,6 +678,16 @@ export const esTranslation = {
fsShowArtistPortrait: 'Mostrar foto del artista',
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
fsPortraitDim: 'Oscurecimiento de foto',
fsLyricsStyle: 'Estilo de letra',
fsLyricsStyleRail: 'Carril',
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
fsLyricsStyleApple: 'Desplazamiento',
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
sidebarLyricsStyle: 'Estilo de desplazamiento de letra',
sidebarLyricsStyleClassic: 'Clásico',
sidebarLyricsStyleClassicDesc: 'La línea activa se centra.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
seekbarWaveform: 'Forma de Onda',
+12
View File
@@ -534,6 +534,8 @@ export const frTranslation = {
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord',
@@ -671,6 +673,16 @@ export const frTranslation = {
fsShowArtistPortrait: "Afficher la photo de l'artiste",
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
fsPortraitDim: "Assombrissement de la photo",
fsLyricsStyle: "Style des paroles",
fsLyricsStyleRail: "Rail",
fsLyricsStyleRailDesc: "Rail glissant classique de 5 lignes.",
fsLyricsStyleApple: "Défilement",
fsLyricsStyleAppleDesc: "Liste déroulante plein écran.",
sidebarLyricsStyle: "Style de défilement des paroles",
sidebarLyricsStyleClassic: "Classique",
sidebarLyricsStyleClassicDesc: "La ligne active est centrée.",
sidebarLyricsStyleApple: "Apple Music-like",
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
seekbarWaveform: 'Forme d\'onde',
+12
View File
@@ -533,6 +533,8 @@ export const nbTranslation = {
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen',
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
discordAppleCovers: 'Hent covere fra Apple Music til Discord',
@@ -670,6 +672,16 @@ export const nbTranslation = {
fsShowArtistPortrait: 'Vis artistbilde',
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
fsPortraitDim: 'Mørklegging av bilde',
fsLyricsStyle: 'Tekststil',
fsLyricsStyleRail: 'Skinner',
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
fsLyricsStyleApple: 'Rulling',
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
sidebarLyricsStyle: 'Rullestil for tekst',
sidebarLyricsStyleClassic: 'Klassisk',
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
seekbarWaveform: 'Bølgeform',
+12
View File
@@ -533,6 +533,8 @@ export const nlTranslation = {
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord',
@@ -670,6 +672,16 @@ export const nlTranslation = {
fsShowArtistPortrait: 'Artiestfoto tonen',
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
fsPortraitDim: 'Verduistering foto',
fsLyricsStyle: 'Tekststijl',
fsLyricsStyleRail: 'Rail',
fsLyricsStyleRailDesc: 'Klassieke 5-regels schuifrail.',
fsLyricsStyleApple: 'Scrollen',
fsLyricsStyleAppleDesc: 'Volledig scherm scrolllijst.',
sidebarLyricsStyle: 'Scrollstijl voor tekst',
sidebarLyricsStyleClassic: 'Klassiek',
sidebarLyricsStyleClassicDesc: 'Actieve regel wordt gecentreerd.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
seekbarWaveform: 'Golfvorm',
+12
View File
@@ -555,6 +555,8 @@ export const ruTranslation = {
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
linuxWebkitSmoothScroll: 'Плавное колесо (Linux)',
linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.',
discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
@@ -698,6 +700,16 @@ export const ruTranslation = {
fsShowArtistPortrait: 'Отображать фото артиста',
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
fsPortraitDim: 'Затемнение фото',
fsLyricsStyle: 'Стиль отображения текста',
fsLyricsStyleRail: 'Рельс',
fsLyricsStyleRailDesc: 'Классическая рамка из 5 строк.',
fsLyricsStyleApple: 'Прокрутка',
fsLyricsStyleAppleDesc: 'Полноэкранный список с прокруткой.',
sidebarLyricsStyle: 'Стиль прокрутки текста',
sidebarLyricsStyleClassic: 'Классический',
sidebarLyricsStyleClassicDesc: 'Активная строка центрируется.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
seekbarWaveform: 'Форма волны',
+12
View File
@@ -529,6 +529,8 @@ export const zhTranslation = {
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
linuxWebkitSmoothScroll: '滚轮平滑(Linux',
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
discordAppleCovers: '通过 Apple Music 为 Discord 获取封面',
@@ -666,6 +668,16 @@ export const zhTranslation = {
fsShowArtistPortrait: '显示艺术家照片',
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
fsPortraitDim: '照片暗化',
fsLyricsStyle: '歌词显示样式',
fsLyricsStyleRail: '轨道',
fsLyricsStyleRailDesc: '经典5行滑动轨道。',
fsLyricsStyleApple: '滚动',
fsLyricsStyleAppleDesc: '全屏可滚动列表。',
sidebarLyricsStyle: '歌词滚动样式',
sidebarLyricsStyleClassic: '经典',
sidebarLyricsStyleClassicDesc: '活动行居中显示。',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观',
seekbarWaveform: '波形',
+366 -147
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 ── */}
@@ -1039,6 +1147,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>
);
}
+104 -38
View File
@@ -120,6 +120,7 @@ const CONTRIBUTORS = [
'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)',
'ReplayGain values in Queue tech strip (PR #196)',
'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
],
},
{
@@ -135,6 +136,8 @@ const CONTRIBUTORS = [
'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
'Apple Music-style scrolling lyrics with spring-physics scroll for fullscreen player and sidebar; per-style controls (PR #205)',
'Golos Text and Unbounded fonts with Cyrillic support (PR #206)',
],
},
{
@@ -1136,6 +1139,25 @@ export default function Settings() {
</div>
</>
)}
{IS_LINUX && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
<input
type="checkbox"
checked={auth.linuxWebkitKineticScroll}
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
@@ -1739,50 +1761,56 @@ export default function Settings() {
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
{/* TODO: UI scaling is being reworked — disabled until fixed */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
Interface scaling is currently being reworked and will be available in a future update.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
{Math.round(fontStore.uiScale * 100)}%
</span>
</div>
<input
type="range"
min={0.8}
max={1.5}
step={0.05}
value={fontStore.uiScale}
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
className="ui-scale-slider"
/>
<div style={{ position: 'relative', height: 24 }}>
{[80, 90, 100, 110, 125, 150].map(p => {
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
const active = Math.round(fontStore.uiScale * 100) === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
position: 'absolute',
left: `${pct}%`,
transform: 'translateX(-50%)',
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
{(() => {
const presets = [80, 90, 100, 110, 125, 150];
const currentPct = Math.round(fontStore.uiScale * 100);
let idx = presets.indexOf(currentPct);
if (idx < 0) {
// Snap legacy off-preset values to the closest preset.
idx = presets.reduce((best, p, i) =>
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
}
return (
<>
<input
type="range"
min={0}
max={presets.length - 1}
step={1}
value={idx}
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
className="ui-scale-slider"
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{presets.map(p => {
const active = currentPct === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
</>
);
})()}
</div>
</div>
</section>
@@ -1812,6 +1840,8 @@ export default function Settings() {
{ id: 'lexend', label: 'Lexend' },
{ id: 'geist', label: 'Geist' },
{ id: 'jetbrains-mono', label: 'JetBrains Mono' },
{ id: 'golos-text', label: 'Golos Text' },
{ id: 'unbounded', label: 'Unbounded' },
] as { id: FontId; label: string }[]).find(f => f.id === fontStore.font)?.label ?? fontStore.font
}</span>
<ChevronDown size={14} style={{ color: 'var(--text-muted)', transform: fontPickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
@@ -1832,6 +1862,8 @@ export default function Settings() {
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
] as { id: FontId; label: string; stack: string }[]
).map(f => (
<button
@@ -1884,6 +1916,40 @@ export default function Settings() {
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Music2 size={18} />
<h2>{t('settings.sidebarLyricsStyle')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', gap: 8 }}>
{(['classic', 'apple'] as const).map(style => {
const key = style === 'classic' ? 'Classic' : 'Apple';
return (
<button
key={style}
onClick={() => auth.setSidebarLyricsStyle(style)}
style={{
flex: 1,
padding: '10px 14px',
borderRadius: 10,
border: `2px solid ${auth.sidebarLyricsStyle === style ? 'var(--accent)' : 'var(--border)'}`,
background: auth.sidebarLyricsStyle === style ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-secondary)',
cursor: 'pointer',
textAlign: 'left',
color: 'var(--text-primary)',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
</button>
);
})}
</div>
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Sliders size={18} />
+31
View File
@@ -8,6 +8,7 @@ import {
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
import { usePlayerStore } from './playerStore';
import { IS_LINUX } from '../utils/platform';
export interface ServerProfile {
id: string;
@@ -65,6 +66,8 @@ interface AuthState {
discordTemplateState: string;
discordTemplateLargeText: string;
useCustomTitlebar: boolean;
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
linuxWebkitKineticScroll: boolean;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
@@ -81,6 +84,10 @@ interface AuthState {
*/
lyricsStaticOnly: boolean;
showFullscreenLyrics: boolean;
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
fsLyricsStyle: 'rail' | 'apple';
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
sidebarLyricsStyle: 'classic' | 'apple';
showFsArtistPortrait: boolean;
/** Portrait dimming 0100 (percent), applied as CSS rgba alpha */
fsPortraitDim: number;
@@ -205,6 +212,7 @@ interface AuthState {
setDiscordTemplateState: (v: string) => void;
setDiscordTemplateLargeText: (v: string) => void;
setUseCustomTitlebar: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
@@ -212,6 +220,8 @@ interface AuthState {
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
setShowFsArtistPortrait: (v: boolean) => void;
setFsPortraitDim: (v: number) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
@@ -308,6 +318,7 @@ export const useAuthStore = create<AuthState>()(
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
linuxWebkitKineticScroll: true,
nowPlayingEnabled: false,
lyricsServerFirst: true,
enableNeteaselyrics: false,
@@ -315,6 +326,8 @@ export const useAuthStore = create<AuthState>()(
lyricsMode: 'standard',
lyricsStaticOnly: false,
showFullscreenLyrics: true,
fsLyricsStyle: 'rail',
sidebarLyricsStyle: 'classic',
showFsArtistPortrait: true,
fsPortraitDim: 28,
showChangelogOnUpdate: true,
@@ -435,6 +448,7 @@ export const useAuthStore = create<AuthState>()(
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
@@ -442,6 +456,8 @@ export const useAuthStore = create<AuthState>()(
setLyricsMode: (v) => set({ lyricsMode: v }),
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
@@ -620,6 +636,20 @@ export const useAuthStore = create<AuthState>()(
}
} catch { /* ignore */ }
// One-time: older builds could persist smooth=false as the default. Force smooth on once
// so updates do not leave users on discrete scrolling; after this flag exists, only an
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
const wheelSmoothMigrationKey = 'psysonic-linux-webkit-smooth-v1';
let wheelSmoothOneTime: { linuxWebkitKineticScroll?: boolean } = {};
if (IS_LINUX) {
try {
if (!localStorage.getItem(wheelSmoothMigrationKey)) {
wheelSmoothOneTime = { linuxWebkitKineticScroll: true };
localStorage.setItem(wheelSmoothMigrationKey, '1');
}
} catch { /* ignore */ }
}
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -629,6 +659,7 @@ export const useAuthStore = create<AuthState>()(
),
...conflictingLegacyState,
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
});
},
}
-5
View File
@@ -9,7 +9,6 @@ export interface DeviceSyncSource {
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 +16,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 +33,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
persist(
(set) => ({
targetDir: null,
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
pendingDeletion: [],
@@ -43,7 +40,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
scanning: false,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
addSource: (source) =>
set((s) => ({
@@ -97,7 +93,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
name: 'psysonic_device_sync',
partialize: (s) => ({
targetDir: s.targetDir,
filenameTemplate: s.filenameTemplate,
sources: s.sources,
}),
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono';
export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono' | 'golos-text' | 'unbounded';
interface FontState {
font: FontId;
+508 -88
View File
@@ -2645,8 +2645,9 @@
.lyrics-pane {
flex: 1;
overflow-y: auto;
/* scroll-behavior intentionally left to auto — JS drives smooth scroll imperatively */
padding: 16px 16px 8px;
scroll-behavior: smooth;
scrollbar-width: thin;
}
.lyrics-pane-empty {
@@ -2667,57 +2668,71 @@
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0 16px;
padding: 24px 0 40px; /* top pad lets first line start below centre, bottom lets last line scroll up */
}
.lyrics-line {
font-size: 14px;
font-weight: 400;
color: var(--text-secondary);
font-size: 15px;
font-weight: 500;
color: var(--text-muted);
text-align: center;
padding: 5px 8px;
border-radius: 6px;
line-height: 1.6;
transition: color 0.25s;
line-height: 1.55;
cursor: default;
transform-origin: center center;
/* All transitions are compositor-friendly */
transition:
color 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 350ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
text-shadow 350ms ease;
transform: scale(0.92);
opacity: 0.75;
}
.lyrics-line.completed {
color: var(--text-muted);
transform: scale(0.88);
opacity: 0.55;
}
.lyrics-line.active {
color: var(--accent);
color: var(--text-primary);
font-weight: 700;
transform: scale(1);
opacity: 1;
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 45%, transparent);
}
.lyrics-line:hover:not(.active) {
color: var(--text-secondary);
opacity: 0.9;
}
/* Word-sync (karaoke) mode per-word highlight inside the active line.
The parent `.lyrics-line.active` colour is overridden: only `.active` / `.played`
words carry accent tint, the remaining words stay at the line's base colour. */
/* Word-sync: neutralise line colour so only individual words pop */
.lyrics-word-synced .lyrics-line {
color: var(--text-secondary);
color: var(--text-muted);
}
.lyrics-word-synced .lyrics-line.active {
color: var(--text-secondary); /* line colour neutralised; words decide */
color: var(--text-muted);
text-shadow: none;
}
.lyrics-word-synced .lyrics-line.completed {
color: var(--text-muted);
}
.lyrics-word {
display: inline;
transition: color 0.18s ease;
transition: color 160ms ease, text-shadow 160ms ease;
color: inherit;
white-space: pre;
}
.lyrics-word.played {
color: var(--accent);
opacity: 0.65;
color: var(--text-secondary);
}
.lyrics-word.active {
color: var(--accent);
opacity: 1;
color: var(--text-primary);
text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
}
@@ -3502,51 +3517,296 @@
transition: opacity 200ms ease;
}
/* ── Lyrics overlay — upper-left quadrant, strictly 5 lines tall ── */
/* Slot = 6vh → 5 × 6vh = 30vh. JS reads window.innerHeight * 0.06. */
.fs-lyrics-overlay {
position: absolute;
top: 15vh; /* pushed down into the quadrant, clear of close button */
left: clamp(28px, 4vw, 64px);
right: 52%; /* stay out of portrait area */
height: 30vh; /* exactly 5 × 6vh — never grows, never overlaps cluster */
z-index: 3;
overflow: hidden;
pointer-events: none;
contain: layout style; /* isolate reflows from rest of page */
/* Apple Music-style fullscreen lyrics
Full-screen scrollable list. Active line scrolls to ~35% from top.
User scroll pauses auto-scroll for 4 s. Word-sync via imperative DOM updates. */
/* Fade first and last slot (each 6/30 = 20% of container) */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 20%,
#000 80%,
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 20%,
#000 80%,
transparent 100%
);
.fsa-lyrics-container {
position: absolute;
inset: 0;
z-index: 3;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
padding: 0 clamp(28px, 5vw, 72px);
/* pointer-events on children only — container itself scrollable */
pointer-events: auto;
}
/* Synced rail — smooth slide, no abrupt jumps */
.fs-lyrics-rail {
.fsa-lyrics-container::-webkit-scrollbar {
display: none;
}
/* Top padding — clears the close button (44 px) + some breathing room */
.fsa-lyrics-top-pad {
height: 80px;
pointer-events: none;
}
/* Bottom padding — lets last line scroll to ~35% from top even on short tracks */
.fsa-lyrics-bottom-pad {
height: 60vh;
pointer-events: none;
}
/* ── Line base styles ── */
.fsa-lyric-line {
/* Fixed font-size avoids layout reflow on active-line switch.
Visual "size" difference is done via transform: scale below. */
font-size: clamp(1.4rem, 3.2vh, 3rem);
font-weight: 600;
line-height: 1.45;
color: rgba(255, 255, 255, 0.28);
margin-bottom: 0.45em;
cursor: pointer;
user-select: none;
word-break: break-word;
overflow-wrap: break-word;
transform-origin: left center;
/* All visual changes are compositor-friendly (color, text-shadow, transform) */
transition:
color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
text-shadow 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: scale(0.9);
opacity: 0.9;
}
.fsa-lyric-line:hover {
color: rgba(255, 255, 255, 0.55);
}
/* Past: subtly smaller and more faded */
.fsa-lyric-line.fsal-past {
color: rgba(255, 255, 255, 0.16);
transform: scale(0.86);
opacity: 0.7;
}
/* Active: full scale, bright, glowing — font-weight snaps (no layout cost on same size) */
.fsa-lyric-line.fsal-active {
font-weight: 700;
color: rgba(255, 255, 255, 0.96);
transform: scale(1);
opacity: 1;
text-shadow:
0 0 48px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 25%),
0 2px 18px rgba(0, 0, 0, 0.55);
}
/* Word-sync — active line dims base so only lit words pop */
.fsa-lyric-line.fsal-active .fsa-lyric-word {
color: rgba(255, 255, 255, 0.28);
text-shadow: none;
}
.fsa-lyric-word {
display: inline;
white-space: pre; /* preserve trailing spaces from lyricsplus tokens */
transition: color 160ms ease, text-shadow 160ms ease;
}
.fsa-lyric-line.fsal-active .fsa-lyric-word.played {
color: rgba(255, 255, 255, 0.72);
}
.fsa-lyric-line.fsal-active .fsa-lyric-word.active {
color: #ffffff;
text-shadow: 0 0 32px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 10%);
}
/* Plain (unsynced) lyrics */
.fsa-plain-lyrics {
pointer-events: none;
}
.fsa-plain-line {
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
font-weight: 500;
line-height: 1.55;
color: rgba(255, 255, 255, 0.72);
margin: 0 0 0.2em;
word-break: break-word;
}
/* ── Gradient fades — top and bottom chrome ──────────────────────────────── */
/* Top fade: covers close button area; pointer-events none so button stays clickable */
.fsa-fade-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 110px;
z-index: 5;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(6, 6, 14, 0.92) 0%,
rgba(6, 6, 14, 0.5) 55%,
transparent 100%
);
}
/* Bottom fade: sits above lyrics (same z-index, later in DOM) but under cluster */
.fsa-fade-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 45vh;
z-index: 3;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(6, 6, 14, 0.55) 40%,
rgba(6, 6, 14, 0.88) 72%,
rgba(6, 6, 14, 0.96) 100%
);
}
/* ── Layout overrides when lyrics mode is active ────────────────────────── */
/* Hide portrait — lyrics use the full width */
.fs-player[data-lyrics] .fs-portrait-wrap {
display: none;
}
/* Scrim is redundant without the portrait — hide it */
.fs-player[data-lyrics] .fs-scrim {
display: none;
}
/* ── Lyrics settings popover (above mic button) ─────────────────────────────── */
/* The panel itself floated above the mic button.
Rendered inside position:relative wrapper (the button wrapper).
z-index: 9 sits above the backdrop (8). */
.fslm-panel {
position: absolute;
bottom: calc(100% + 12px);
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);
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);
animation: fslmIn 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom right;
}
@keyframes fslmIn {
from { opacity: 0; transform: scale(0.88) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Toggle row */
.fslm-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.fslm-label {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
/* Style selector */
.fslm-style-row {
display: flex;
gap: 7px;
transition: opacity 200ms ease;
}
.fslm-disabled .fslm-style-row,
.fslm-style-row.fslm-disabled {
opacity: 0.35;
pointer-events: none;
}
.fslm-style-btn {
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);
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);
}
.fslm-style-active {
/* accent colors set inline via accentColor prop */
}
.fslm-style-name {
display: block;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.fslm-style-desc {
display: block;
font-size: 10px;
margin-top: 3px;
opacity: 0.7;
line-height: 1.3;
}
/* Small arrow pointing down toward the button */
.fslm-arrow {
position: absolute;
bottom: -6px;
right: 10px;
width: 12px;
height: 12px;
background: rgba(18, 18, 28, 0.88);
border-right: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transform: rotate(45deg);
}
/* ── Classic 5-line rail (Rail style) ───────────────────────────────────────── */
.fsr-lyrics-overlay {
position: absolute;
top: 15vh;
left: clamp(28px, 4vw, 64px);
right: 52%;
height: 30vh;
z-index: 3;
overflow: hidden;
pointer-events: none;
contain: layout style;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
}
.fsr-lyrics-rail {
position: absolute;
top: 0; left: 0; right: 0;
will-change: transform;
transition: transform 500ms ease-out;
}
/* Each lyric slot is exactly 6vh matches JS window.innerHeight * 0.06.
Fixed height (not min-height) is intentional: keeps rail math correct
regardless of whether the text wraps or not. 2 wrapped lines of 2vh
font at 1.4 line-height = 5.6vh, comfortably inside the 6vh slot. */
.fs-lyric-line {
.fsr-lyric-line {
height: 6vh;
display: flex;
align-items: center;
@@ -3560,50 +3820,57 @@
font-weight: 400;
cursor: pointer;
pointer-events: auto;
/* font-weight intentionally NOT transitioned — triggers layout reflow on every frame */
transition: color 280ms ease, text-shadow 280ms ease, transform 280ms ease;
transform-origin: left center;
user-select: none;
}
.fs-lyric-line:hover {
color: rgba(255, 255, 255, 0.5);
}
.fsr-lyric-line:hover { color: rgba(255, 255, 255, 0.5); }
.fs-lyric-line.fsl-past {
color: rgba(255, 255, 255, 0.1);
}
.fsr-lyric-line.fsrl-past { color: rgba(255, 255, 255, 0.1); }
/* Active: emphasis via scale + glow; font-weight snaps instantly (no layout-reflow transition) */
.fs-lyric-line.fsl-active {
.fsr-lyric-line.fsrl-active {
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
transform: scaleX(1.015); /* subtle compositor-only emphasis, no layout cost */
transform: scaleX(1.015);
}
/* Fullscreen word-sync (karaoke). Parent .fsl-active base colour stays dim so
only the .active/.played words pop. */
.fs-lyric-line.fsl-active .fs-lyric-word {
.fsr-lyric-line.fsrl-active .fsr-lyric-word {
color: rgba(255, 255, 255, 0.35);
text-shadow: none;
}
.fs-lyric-word {
.fsr-lyric-word {
display: inline;
/* Preserve the trailing space the lyricsplus API embeds in each syllabus
token (`"Gina "`, `"dreams "`, ). Without this the flex formatting
context collapses it and words run together. */
white-space: pre;
transition: color 0.18s ease, text-shadow 0.18s ease;
}
.fs-lyric-line.fsl-active .fs-lyric-word.played {
color: rgba(255, 255, 255, 0.75);
}
.fs-lyric-line.fsl-active .fs-lyric-word.active {
.fsr-lyric-line.fsrl-active .fsr-lyric-word.played { color: rgba(255, 255, 255, 0.75); }
.fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
color: #ffffff;
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
}
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
html.no-compositing .fsr-lyrics-rail { will-change: auto; }
/* Apple-style FS lyrics strip GPU-only effects.
Without compositing, transform: scale() on every line forces a per-line
software repaint on each active-line change, and the 48 px / 32 px blur
text-shadows repaint on every word tick. Replace with opacity + colour,
which are cheap text-only repaints. */
html.no-compositing .fsa-lyric-line {
transform: none;
transition: color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
html.no-compositing .fsa-lyric-line.fsal-past { transform: none; }
html.no-compositing .fsa-lyric-line.fsal-active { transform: none; text-shadow: none; }
html.no-compositing .fsa-lyric-line.fsal-active .fsa-lyric-word.active {
text-shadow: none;
}
/* no-compositing overrides
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
@@ -3629,10 +3896,19 @@ html.no-compositing .fs-portrait {
}
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
pointer-events: none on wrap means the overlay never blocks clicks. */
pointer-events: none on wrap means the overlay never blocks clicks.
Also drop translateZ(0) useless GPU layer hint without a compositor. */
html.no-compositing .fs-portrait-wrap {
-webkit-mask-image: none;
mask-image: none;
transform: none;
}
/* FS art crossfade: opacity transition compositing is GPU-cheap, software-expensive.
Snap instantly between covers; visually a hard cut, but the previous layer is
replaced before the user can really notice. */
html.no-compositing .fs-art {
transition: none;
}
html.no-compositing .fs-portrait-wrap::before {
content: '';
@@ -3648,23 +3924,18 @@ html.no-compositing .fs-portrait-wrap::before {
);
}
/* Lyrics overlay: just remove the fade — no replacement needed */
html.no-compositing .fs-lyrics-overlay {
-webkit-mask-image: none;
mask-image: none;
}
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
/* Lyrics rail: remove will-change */
html.no-compositing .fs-lyrics-rail {
will-change: auto;
}
/* Mesh blobs: stop animations in software mode each frame composites surfaces
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
displays this saturates the CPU and drops the player to ~10 FPS.
Static gradients are preserved; only the slow pan animation is removed. */
/* Mesh blobs: stop animations + flatten to a solid dark fill.
Even static, the 130 × 130 % radial-gradient with color-mix() is recomputed
on every paint, and the `transition: background 400ms` on accent change
re-rasterises the giant surface for nearly half a second per track switch.
Atmosphere is sacrificed; the dynamic accent is preserved on title /
seekbar / controls via their own var(--dynamic-fs-accent) bindings. */
html.no-compositing .fs-mesh-blob {
animation: none;
background: #06060e;
transition: none;
}
/* Seekbar played bar: remove box-shadow its width changes on every playback
@@ -3673,6 +3944,45 @@ html.no-compositing .fs-seekbar-played {
box-shadow: none;
}
/* Track title: drop the 40 px text-shadow glow paints a giant blur on a
clamp(2868 px) font and re-rasterises whenever anything around it
redraws (cluster updates, seekbar tick, dynamic accent change). */
html.no-compositing .fs-track-title {
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.65);
transition: color 200ms ease-in-out;
}
/* Album art wrap: replace the accent-coloured outer glow with a flat
shadow. The 28 px blur was repainting every accent change. */
html.no-compositing .fs-art-wrap {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.07);
transition: none;
}
/* Play button: drop accent glow + hover filter. Keep flat dark shadow only. */
html.no-compositing .fs-btn-play {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
}
html.no-compositing .fs-btn-play:hover {
filter: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.65);
}
/* Rail-style FS lyrics: same problems as Apple style 28 px text-shadow
blur on every active line + every active word, plus per-line transform
scaleX. Strip them; rail still scrolls fine via the parent translateY. */
html.no-compositing .fsr-lyric-line {
transition: color 280ms ease;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active {
text-shadow: none;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
text-shadow: none;
}
/* Chat */
.chat-popup {
position: absolute;
@@ -7725,6 +8035,116 @@ html.no-compositing .fs-seekbar-played {
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;
+61
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;
@@ -940,6 +987,20 @@
z-index: 1;
}
/* Scoped UI scaling target. Sidebar / queue / player / titlebar live in
sibling grid cells of .app-shell and stay at 1:1. The wrapper inherits
.main-content's flex column layout so .content-header (fixed height) and
.content-body (flex: 1, scroll) keep working unchanged.
Zoom is applied via inline style only when uiScale !== 1, so the default
case has no extra layer or layout cost. */
.main-content-zoom {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
.content-header {
display: flex;
align-items: center;
+550 -171
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
/**
* Spring-based scroll animation iOS / Apple Music feel.
*
* Uses a critically-damped spring model driven by rAF:
* velocity += (target position) × stiffness
* velocity *= damping
* position += velocity
*
* Tuning:
* stiffness 0.04 0.10 lower = slower / more fluid
* damping 0.80 0.88 lower = more bounce; higher = overdamped / snappy
* maxVelocity caps initial lurch when target is far away
*
* A single SpringScroller instance per container avoids fighting rAF loops
* when the target changes before the previous animation finishes calling
* scrollTo() mid-flight just updates the target and the running loop picks it up.
*/
export class SpringScroller {
private container : HTMLElement;
private target = 0;
private velocity = 0;
private rafId: number | null = null;
private readonly stiffness : number;
private readonly damping : number;
private readonly maxVelocity: number;
constructor(
container : HTMLElement,
stiffness = 0.065, // gentle pull
damping = 0.84, // smooth settle, no oscillation
maxVelocity = 28, // px/frame cap — prevents jarring lurch on large jumps
) {
this.container = container;
this.target = container.scrollTop;
this.stiffness = stiffness;
this.damping = damping;
this.maxVelocity = maxVelocity;
}
scrollTo(targetY: number) {
this.target = Math.max(0, targetY);
// Software-rendered Linux: replace our 60 fps rAF spring with the
// browser's native smooth scroll. The browser handles easing internally
// (one animation, not 60 JS callbacks per second) and we still get a
// smooth visual instead of a hard snap.
if (document.documentElement.classList.contains('no-compositing')) {
this.stop();
this.container.scrollTo({ top: this.target, behavior: 'smooth' });
return;
}
if (this.rafId === null) this.tick();
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.velocity = 0;
}
/** Teleport without animation (e.g. on track reset). */
jump(y: number) {
this.stop();
this.target = y;
this.container.scrollTop = y;
}
private tick = () => {
const pos = this.container.scrollTop;
const delta = this.target - pos;
let v = (this.velocity + delta * this.stiffness) * this.damping;
// Cap velocity so large distances don't start with a hard jerk.
if (v > this.maxVelocity) v = this.maxVelocity;
if (v < -this.maxVelocity) v = -this.maxVelocity;
this.velocity = v;
this.container.scrollTop += v;
const settled = Math.abs(v) < 0.12 && Math.abs(delta) < 0.5;
if (settled) {
this.container.scrollTop = this.target;
this.rafId = null;
this.velocity = 0;
} else {
this.rafId = requestAnimationFrame(this.tick);
}
};
}
/**
* Convenience: compute the scroll position that places `el` at `fraction`
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style).
*/
export function targetForFraction(
container: HTMLElement,
el : HTMLElement,
fraction = 0.35,
): number {
const cRect = container.getBoundingClientRect();
const eRect = el.getBoundingClientRect();
return container.scrollTop + (eRect.top - cRect.top) - cRect.height * fraction + eRect.height / 2;
}