Compare commits

..

82 Commits

Author SHA1 Message Date
Frank Stellmacher 77598e10e9 chore(release): v1.43.0 (#225)
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:34:05 +02:00
Frank Stellmacher e7bfe276c3 ci(release): route nix lock refresh through PR + auto-merge (#224)
After enabling branch protection rulesets on main, the existing
direct push from the verify-nix job (`git push origin HEAD:main`)
is rejected. Push the refresh commit to a per-release branch
(`chore/nix-lock-refresh-v<VERSION>`) instead, open a PR, and
enable auto-merge so the change lands as soon as repo conditions
allow (0 required reviews + 0 required status checks → instant).

Adds `pull-requests: write` to job permissions so GITHUB_TOKEN can
create + merge the PR.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:16:25 +02:00
Frank Stellmacher f510223d2d feat(settings): compact spacing pass + row hover affordance (#223)
Tightens the Settings panels globally: section margin-bottom 32→20px,
card padding 20→12px, divider margin 12→8px, section header h2
16→15px. Adds a subtle accent-tinted hover background on every
.settings-toggle-row (extended to card edges via negative
horizontal margin). Vertical row padding kept at 2px so the diet
isn't undone by the hover affordance.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:58:19 +02:00
Psychotoxical 2d3be10c8f feat(users): show last-access timestamp per user
Maps Navidrome's lastAccessAt onto NdUser and renders it in the
User Management row as a localized relative time (Intl.RelativeTimeFormat
with the current i18n language). Tooltip carries the absolute
timestamp. Navidrome returns 0001-01-01 for never-accessed users —
detected and shown as the localized "Never" label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:26:58 +02:00
Frank Stellmacher 459c9f688d feat(users): per-user library assignment + themed confirm modal (#222)
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:18:10 +02:00
Psychotoxical 6d3c50264a fix(mini): minimize main on open + cap width on non-tiling WMs
Restore the main-window minimize/unminimize behavior when opening the
mini player on Windows — the original WebView2 stall no longer applies
since the mini window is pre-created at startup. Also cap the mini
window at 400 px width so horizontal drag can't stretch the layout
across a whole monitor; skipped on tiling WMs (Hyprland, Sway, i3)
where the compositor manages sizing itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:13:57 +02:00
Psychotoxical 9f21edee80 feat(mini): queue-style meta block, action toolbar, vertical volume slider
Restructure the MiniPlayer to mirror the main window's queue panel layout:

UI changes:
- Meta block: cover + title/artist/album/year (year added to MiniTrackInfo)
- Action toolbar styled like .queue-round-btn (30px round, accent fill when
  active), in order: volume, shuffle | gapless, crossfade, infinite | queue
- Volume button opens a thin 5px vertical strip slider that drops down (click
  / drag / wheel to adjust). Right-click on the volume button mutes.
- Controls + progress moved to the bottom as a true footer (margin-top: auto),
  so they stay anchored even with the queue expanded
- Queue toggle moved out of the titlebar into the action bar (logically lives
  with the other queue/playback toggles)
- Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout

Bridge changes (miniPlayerBridge.ts):
- MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled,
  infiniteQueueEnabled
- Bridge now subscribes to authStore in addition to playerStore so toolbar
  toggle states propagate cross-window
- New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade,
  mini:set-infinite-queue
- Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha)
  so the mini doesn't need to know about both states to act

Misc:
- Belt-and-suspenders user-select: none on .mini-player-shell * to kill
  Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
2026-04-20 18:51:31 +02:00
Kveld. a7eb0eda72 fix padding (#221) 2026-04-20 17:41:48 +02:00
Kveld. 9687f90b54 fix: artist top songs continue playback (#220)
* fix: continue playback after top songs finish on artist page

* comments to english
2026-04-20 17:41:44 +02:00
Psychotoxical ccf6e6c886 fix(seekbar): commit seek on mouseup + ignore progress ticks while dragging
Ports the WaveformSeek UX changes from PR #219 (original fix by @cucadmuh)
onto test/seekable-streaming:

- Split previewFraction (during drag) / commitSeek (on mouseup) so audio_seek
  fires once per gesture instead of on every mousemove.
- Subscribe-effect skips external progress updates while isDragging — prevents
  cursor flicker from streaming/recovery ticks fighting the dragged position.
- Clear seekTarget in seek() catch branch so a failed seek doesn't permanently
  block progress updates.

Skips the byte-restart fast-path from #219: would defeat RangedHttpSource's
direct seek by restarting the stream after 450ms.

Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:28:20 +02:00
Psychotoxical 5aac4d4ed0 feat(audio): seekable streaming via RangedHttpSource + instant local playback
Reworks the manual streaming-start path so playback is seekable from the
first frame and hot-cache hits start without a multi-hundred-MB pre-read.

Why
- The legacy AudioStreamReader was a non-seekable ring buffer. Forward
  seeks during streaming let Symphonia consume the buffer through to EOF
  (next song), and backward seeks were silently broken.
- fetch_data() loaded `psysonic-local://` files via tokio::fs::read,
  blocking playback for seconds on hot-cache or offline files (e.g. a
  100+ MB hi-res FLAC) and producing ALSA underruns when the new sink
  swap arrived too late.

What changed
- New RangedHttpSource MediaSource: pre-allocates a Vec<u8> in track
  size, background ranged_download_task fills linearly from offset 0
  with HTTP Range reconnects. is_seekable=true; reads block on data,
  seeks update the cursor only. Picked when the server response carries
  Accept-Ranges: bytes + Content-Length.
- New LocalFileSource MediaSource: thin std::fs::File wrapper used for
  every `psysonic-local://` URL — Symphonia probes the first ~64 KB and
  starts playback before the rest is read.
- audio_play wires both paths through a generic PlayInput::SeekableMedia
  variant so the build_streaming_source pipeline stays single-shape.
- current_is_seekable AtomicBool on AudioEngine: audio_seek short-circuits
  with `not seekable` for the legacy fallback so the frontend restart-
  fallback (playerStore seek catch) engages instead of letting a forward
  seek consume the ring buffer.
- Dev-only [stream] / [seek] eprintln logs (cfg(debug_assertions)) so
  source selection, codec resolution (codec name + lossless flag + bit
  depth + rate + channels), download progress and seek targets are
  visible in the terminal during testing.
- content_type_to_hint now covers wav/wave/opus; URL-derived format
  hints strip the query string first and only accept known audio
  extensions (Subsonic stream URLs have no extension — would otherwise
  latch onto query-param fragments).

Compatibility
- Servers without Accept-Ranges fall back to the existing AudioStreamReader
  path; seek is rejected up-front so the existing restart-fallback runs.
- Radio + audio_chain_preload paths untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:27:31 +02:00
Psychotoxical ccff7be499 chore(settings): drop Alpha badge on AudioMuse toggle
Also removes the now-unused `hotCacheAlphaBadge` i18n key from all
8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:53:10 +02:00
Psychotoxical 99fc62e505 fix(themes/jayfin): WCAG AA contrast fixes for nav + primary buttons
Active nav-link: raise background to 22% accent + switch text/icon to
white — lifts from 3.30:1 to >10:1 (was AA-small fail). Left purple bar
preserves the Jellyfin brand cue.

Primary buttons (btn-primary, player-btn-primary, hero-play-btn,
album-card-details-btn, queue-round-btn.active): force font-weight 700.
White on #AA5CC3 measures 4.08:1 — bold glyphs compensate visually so
button labels remain clearly legible without shifting the brand color.

Not touched: accent-as-text on bg-card (4.09:1, needs separate
--accent-text token), hover tint #be70d8, white on danger red
(cross-theme issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:45:14 +02:00
Psychotoxical dc819f3a2c feat(settings): admin-gated User Management tab via Navidrome REST API
Adds a dedicated Settings tab for managing Navidrome users (list, create,
edit, delete). Only visible to admins — gated by `/auth/login` probe on
mount. Uses Navidrome's native /api/user endpoints instead of Subsonic
(getUsers.view etc. return only the caller on Navidrome). All HTTP calls
go through new Rust commands to bypass WebView CORS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:35:14 +02:00
Psychotoxical 69dd374e80 chore(credits): add PRs #214, #215, #216, #217 to Settings → About
kilyabin: #214 (ease-out lyrics scroll + plain-lyrics bottom fade),
#215 (single-line YouLy+ source strings).
kveld9: #216 (opt-in floating player bar), #217 (Linux GPU-vendor
auto-detection for the WebKitGTK DMA-BUF renderer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:15:21 +02:00
Psychotoxical 36caf3a14c refactor(linux): simplify GPU-vendor detection for DMA-BUF toggle
Follow-up on #217. Blind A/B test on NVIDIA + proprietary confirmed the
toggle helps, but the detection path had a few rough edges.

- Drop lspci and glxinfo fallbacks: the /proc/driver/nvidia/version +
  sysfs scan catch every realistic case, and spawning subprocesses in
  main() added startup latency for no win (glxinfo also isn't always
  installed — mesa-utils isn't a hard dep).
- Scan all /sys/class/drm/card* entries via read_dir instead of
  hardcoded card0/card1 — hybrid laptops and systems where only card1
  exists were previously missed.
- Remove 0x1022 from the AMD list: that's AMD's host/chipset vendor ID,
  not GPU. Only 0x1002 (ATI/Radeon) belongs here.
- Unknown vendor → leave WEBKIT_DISABLE_DMABUF_RENDERER unset instead
  of forcing =1. VMs, ARM SBCs and anything exotic keep the WebKitGTK
  default and do not get regressed by a guess.
- Only set the env var for the NVIDIA case. Intel/AMD on Mesa already
  default to DMA-BUF enabled in current WebKitGTK, so the explicit =0
  was redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:08:59 +02:00
Frank Stellmacher d145a7758e Merge pull request #217 from kveld9/feat/linux-gpu-dmabuf-autodetect
feat(linux): Auto-detect GPU vendor and configure DMA-BUF renderer
2026-04-19 22:07:39 +02:00
kveld9 fc0b8fbcab introducing WEBKIT_DISABLE_DMABUF_RENDERER 2026-04-19 15:40:29 -03:00
Psychotoxical 5a51c2c713 feat(floating-bar): liquid-glass look on macOS and Windows
Inspired by kveld9's PR #211 proposal. Adapted to stay within WebView2
and WKWebView GPU budgets — 8px blur instead of 28, theme-token colours
via color-mix instead of hardcoded white, no saturate/brightness/
contrast stack, no transitions, no !important.

Linux keeps the solid floating bar from PR #216 on every compositing
mode. Platform is detected once on mount via a data-platform attribute
on <html>.

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:00:10 +02:00
Psychotoxical fab1dbf863 perf(floating-bar): subscribe themeStore via selector
Both AppShell and PlayerBar read floatingPlayerBar via destructured
useThemeStore() without a selector, so every unrelated theme change
(accent, font, scheduler state, …) would force a re-render. Switch to
s => s.floatingPlayerBar so these components only re-render when the
toggle actually flips.

Follow-up to PR #216.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:44:17 +02:00
Frank Stellmacher 8a0810ffb4 Merge pull request #216 from kveld9/feat/floating-bar-toggle-v2
feat: floating player bar with toggle
2026-04-19 19:43:14 +02:00
Frank Stellmacher 6e34dc8160 Merge pull request #215 from kilyabin/am-lyrics-improvements
fix(lyrics): sidebar lyrics strings with YouLy+ source shows in one line
2026-04-19 19:37:57 +02:00
Psychotoxical 2aa88d26d4 chore(lyrics): rename springScroll.ts to easeScroll.ts
After PR #214 replaced SpringScroller with EaseScroller, the filename
no longer matched the exported class. Rename for clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:35:51 +02:00
kilyabin 22fe0fff74 fix(lyrics): sidebar lyrics strings with YouLy+ source shows in one line 2026-04-19 21:24:58 +04:00
kveld9 12bebdde06 some fixes 2026-04-19 14:23:14 -03:00
Frank Stellmacher 50c886fd6f Merge pull request #214 from kilyabin/am-lyrics-improvements
refactor(fs-lyrics): smoother line scrolling + bottom fade for plain lyrics
2026-04-19 19:20:02 +02:00
kilyabin 65413c954e feat(fs-lyrics): fade bottom edge of plain lyrics scroll viewport
Synced lyrics always fill the screen due to auto-scroll, so the
existing fsa-fade-bottom overlay naturally fades content below
the active line. Plain (unsynced) lyrics start from the top and
may not fill the viewport, leaving no content for that overlay
to fade.

Add mask-image to .fsa-lyrics-container when rendering plain
lyrics (--plain modifier class). The mask operates on the visible
scroll viewport rather than the content, so the bottom fade is
always present regardless of scroll position.
2026-04-19 21:00:46 +04:00
kilyabin 059900e85e refactor(lyrics): replace spring scroller with cubic ease-out animator
The spring model (velocity × damping per rAF frame) produced an
abrupt-looking lurch on each line change — especially noticeable at
the stiffness/damping values used (0.1 / 0.78).

Replace SpringScroller with EaseScroller: a fixed-duration (650 ms)
cubic ease-out animation that starts from the current scroll position
and decelerates smoothly to the target. Restarting mid-flight is clean —
the next call captures the container's current scrollTop as the new
start point, so fast line changes never skip or jerk.

Applied to both the fullscreen Apple-style lyrics and the sidebar
LyricsPane. User-scroll cancellation (stop() + 4 s resume) is
preserved unchanged.
2026-04-19 20:42:36 +04:00
Psychotoxical dea9d9b5a4 fix(settings): lyrics-sources DnD survives mode toggle
The psy-drop listener was attached in a useEffect keyed only on
setLyricsSources, so when the container was unmounted by the
{lyricsMode === 'standard' && …} wrapper (switching to YouLyPlus and
back), the listener stayed bound to the old DOM node and drag-to-reorder
silently did nothing.

Switch the container ref to a callback-ref stored in state, so the
effect re-runs on mount/unmount and always listens on the current DOM
node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:23:34 +02:00
Psychotoxical d191404f2d Revert "perf(fs-player): cut CPU under WebKitGTK software compositing"
This reverts commit 77edfaa867.

Restores the full Apple Music-style lyrics visuals from PR #205 on Linux:
rAF spring scroller, per-line scale transforms, text-shadows, title glow,
album-art/play-button outer glows, mesh-blob radial gradients, and the
FS-art opacity crossfade. PR #205's author noticed the Linux codepath
lost the intended feel — the CPU tradeoff is accepted.

The trigger-ref fix from 25537f27 for the lyrics menu stays in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:07:13 +02:00
kveld9 ffffe268ab introducing floating bar with toggle 2026-04-19 13:01:31 -03:00
Psychotoxical 81ed6db9d1 style(folder-browser): auto-contrast text on selected row
Replace hardcoded #fff on `.folder-col-row.selected` with a relative
OKLCH expression that picks black or white based on the accent's
lightness. Keeps rows readable on bright-accent themes (Muma, pastel
sets) without per-theme overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:44:08 +02:00
Psychotoxical 6878867e64 style(titlebar): theme-independent traffic-lights + song pill
Window controls use fixed macOS-style colored dots (red/yellow/green)
so they stay visible on every theme (broke on Middle-Earth etc. where
text tokens collapsed against the sidebar bg). Song indicator becomes
a dark translucent pill with light text + text-shadow for universal
contrast. Removed the "Psysonic" label — the sidebar logo already
covers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:37:35 +02:00
Psychotoxical f53f724e1c chore(aur): bump pkgver to 1.42.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:55:16 +02:00
Psychotoxical 9bcef81b22 chore(release): v1.42.1
Critical Windows bug-fix release.

Fixed
- Mini player no longer hangs the app on Windows (see 71fbc717): the
  second WebView2 is now pre-built in .setup() so the first open is a
  pure show/hide instead of a creation + minimize race. Windows is
  back on native window decorations for the mini and skips the main-
  window minimize/unminimize dance around show/hide.
- Mini player re-emits mini:ready on window focus so the snapshot from
  main arrives reliably on first open even when pre-create finished
  before main's bridge attached its listener.

Added
- "Preload mini player" toggle in Settings → General for Linux + macOS
  so those platforms can opt into the instant-open behaviour that
  Windows already has as a hang workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:13 +02:00
Psychotoxical 693766134b feat(mini-player): optional preload toggle for Linux + macOS
Adds a "Preload mini player" toggle in Settings → General → App behaviour
(off by default). When enabled, the mini webview is built hidden at app
start so the first open is instant, matching the Windows experience.
Costs one extra WebKit process running in the background (~50–100 MB).

Windows already pre-creates the mini unconditionally as a hang workaround
(commit 71fbc717); the toggle is hidden there and the invoke is skipped,
so that platform is untouched.

- authStore: new `preloadMiniPlayer: boolean` (default false) + setter.
- lib.rs: new `preload_mini_player` command; idempotent, no-op when the
  window already exists. Registered in the invoke handler.
- App.tsx: main window invokes `preload_mini_player` when the toggle is
  on and the platform is not Windows.
- Settings.tsx: toggle row under "Minimize to Tray", gated with
  `!IS_WINDOWS` so Windows users don't see it.
- i18n: `preloadMiniPlayer` + `preloadMiniPlayerDesc` added to all 8
  locales (en, de, es, fr, nb, nl, ru, zh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:03 +02:00
Psychotoxical 71fbc717f6 fix(mini-player): unhang Windows by pre-creating the mini webview
Creating the second WebView2 webview lazily from the open_mini_player
invoke handler reliably stalled Tauri's event loop on Windows — the mini
opened blank white, neither main nor mini could be closed, and the user
had to kill the process via Task Manager. The builder-then-minimize-main
combo racing with WebView2's first paint seems to be the trigger.

Fix: extract a build_mini_player_window helper and call it once in
.setup() on Windows so the webview is created (hidden) in the startup
context, not from an invoke command. open_mini_player is now a pure
show/hide on all platforms. Windows also skips the main.minimize() call
since that interacted with the hang; macOS + Linux keep the existing
behaviour of minimising main when the mini opens.

Other changes bundled in:
- Switch Windows from the custom in-page titlebar back to native window
  decorations (the earlier decorations(false) move on Windows was part
  of the hang surface — safer to keep the OS chrome there, Linux still
  uses the custom bar).
- Re-emit mini:ready on window focus so every open of the pre-created
  mini forces a fresh snapshot from main's bridge, even when the mount-
  time emit raced past the bridge during startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:31:45 +02:00
Psychotoxical d33c7042b6 fix(mini-player): show action buttons on macOS too
The custom titlebar was rendered only on Win/Linux, so macOS users had
no way to toggle the queue, pin-on-top or expand back to the main
window — the native macOS titlebar takes care of dragging + close, but
those three actions live entirely in app code.

Render a slim transparent in-page strip on macOS as well, holding just
the queue / pin / maximize buttons right-aligned. Close is omitted on
macOS since the red traffic light already does that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:06:46 +02:00
Psychotoxical 61c17d2e24 chore(warnings): silence cpal lifetime + unused tray import on Windows
- patches/cpal-0.15.3 wasapi device.rs: annotate MutexGuard return with
  '_ so mismatched_lifetime_syntaxes stops firing.
- lib.rs: gate the MouseButtonState import to non-Windows targets (the
  Windows tray uses DoubleClick and never pattern-matches it).

Cosmetic only, no behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:56:27 +02:00
github-actions[bot] e4abaf8814 chore(nix): refresh lock + npmDepsHash for v1.42.0 2026-04-19 11:54:46 +00:00
Psychotoxical c17fc0f6ac chore(release): v1.42.0
Consolidates everything since v1.40.0 (public) into one coherent
release. v1.41.0 remains as an internal Draft on GitHub that was used
to verify the Cachix substituter pipeline and never went public — this
release folds its intended contents in and adds the mini-player feature
work, the player-bar time toggle (kveld9), the ReplayGain expand badge
and related UX polish on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:40:57 +02:00
Psychotoxical 16491f6321 feat(mini-player): custom titlebar with action icons; cover/meta/controls layout polish
- Drop the native titlebar on Windows + Linux (decorations: false in
  open_mini_player); macOS keeps the system titlebar with traffic lights.
- Add a slim 26 px custom titlebar with drag region, current track
  title, and the queue / pin / open-main / close action icons.
- Remove the bottom toolbar — those four buttons now live in the
  titlebar where they're easier to reach.
- Refresh the player layout: cover shrinks 112 → 84 px, the right
  column gets title / artist / transport in a single 84 px-tall block,
  progress bar spans the full width below.
- Add a miniPlayer i18n namespace (showQueue, hideQueue, pinOnTop,
  pinOff, openMainWindow, close, emptyQueue) localized for all 8
  supported locales — previously the tooltips were hard-coded English.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:28:28 +02:00
Psychotoxical d8244e0139 docs(about): credit kveld9 for player-bar time-toggle (PR #212) 2026-04-19 13:10:16 +02:00
Psychotoxical 60da17f7cc feat(mini-player): user-bindable keyboard shortcut to toggle mini player
Adds an 'open-mini-player' entry to the in-app keybindings (Settings →
Shortcuts), default unbound. Pressing the chord from the main window
opens the mini and minimises main; pressing it from the mini hides the
mini and restores main — both paths invoke open_mini_player which
already handles the toggle.

The mini window has its own keydown listener (same pattern as Space /
arrows for play/skip) that reads the binding from useKeybindingsStore.
Binding changes in main propagate to an open mini through the existing
storage-event sync (now also covers psysonic_keybindings) so the user
doesn't need to restart the mini after rebinding.

Localized in all 8 supported locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:06 +02:00
Psychotoxical 8cc115cade fix(mini-player): restore window position + queue-open state across launches
Two regressions in the mini player's persistence story:

1. Window position was lost on every reopen. set_position() called on a
   hidden window is unreliable on Linux WMs (Mutter, KWin re-centre on
   show). Worse, the WM-induced re-centre fired WindowEvent::Moved with
   the centre coords, which the throttled persister happily wrote to
   disk — so the saved position turned into "centre" within seconds.

   Fix:
   - Initial open uses WebviewWindowBuilder::position() (logical pixels,
     scaled via the primary monitor) so the window is created at the
     right spot.
   - Re-show path re-applies the saved position with set_position AFTER
     show().
   - Both paths call mark_mini_pos_programmatic() before triggering the
     move; persist_mini_pos_throttled ignores Moved events within 1 s of
     a programmatic mark, so WM-induced and self-induced moves no longer
     overwrite the user's position.

2. The queue panel always opened collapsed regardless of the previous
   session. Persist queueOpen to localStorage (psysonic_mini_queue_open)
   and resize the window to the stored expanded height on mount when
   the saved state was 'open'. Brief jump from 180 px to expanded is
   unavoidable since localStorage only lives in the JS layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:54:00 +02:00
Psychotoxical e07ef0ebf1 feat(queue): collapse ReplayGain into a click-to-expand badge
Per @cucadmuh's feedback: the presence of ReplayGain matters more than
the actual numbers. The tech bar now shows a small 'RG' pill on line 1
whenever the track has any RG metadata; hovering reveals the values via
tooltip, clicking persistently expands the second line.

- Source icon stays inline before the format string (was getting
  separated from FLAC by the centred stack layout)
- Pill uses --accent-tinted color-mix so it adapts to every theme
- ChevronDown rotates on expand for a clear affordance
- New persisted state: themeStore.expandReplayGain (default collapsed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:34:52 +02:00
Psychotoxical 6456f13bde fix(player-bar): contain paint to prevent black-flash on WebKitGTK
The player bar occasionally renders fully black for one frame on Linux
(WebKitGTK with software compositing) when an unrelated layer elsewhere
in the page invalidates. `contain: layout paint` makes the bar its own
paint boundary so it can no longer be pulled into a surrounding dirty
rect.

No-op on Wayland-with-GPU and on Chromium-based webviews (Windows,
macOS) — those don't exhibit the flash to begin with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:28:22 +02:00
Psychotoxical a48159d302 feat(queue): split tech bar into two lines when ReplayGain is present
The tech info strip in the queue header was a single ellipsised line, so
ReplayGain values (Track / Album / Peak) got cut off on tracks with a
full codec + bitrate + samplerate prefix. Now the strip wraps into two
lines whenever RG values exist:

- Line 1: codec · bitrate · bit depth/sample rate (unchanged)
- Line 2: 'ReplayGain · T … dB · A … dB · Peak …' — slightly smaller
  and dimmed for hierarchy, ellipsises independently if it still
  overflows.

Tracks without RG metadata stay one line as before. New i18n key
`queue.replayGain` ('ReplayGain') added to all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:24:46 +02:00
Psychotoxical 2871db9a96 feat(mini-player): persistent geometry, queue DnD + context menu, overlay scrollbar, live theme sync
This iteration fills in the pieces that make the mini player usable as a
daily standalone window:

- Window position persists to <app_config_dir>/mini_player_pos.json on
  every WindowEvent::Moved (throttled 250 ms); first launch lands in the
  bottom-right of the main window's monitor.
- Queue keeps a 260 px floor when expanded (~2 visible rows). The
  user-resized expanded height is restored on next toggle via
  localStorage so reopening doesn't snap back to the default 440 px.
- set_mini_player_always_on_top(true) now forces a false-then-true cycle
  so the WM re-evaluates the layer after a hide/show; the frontend also
  re-asserts the pin state on mount and on window focus, so the user no
  longer has to click the pin button twice for it to stick.
- Native scrollbar in the queue is hidden in favour of a JS-driven
  overlay thumb so items use the full width of the queue area.
- PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder',
  the bridge calls reorderQueue on the source-of-truth store in main.
- Localized queue-item context menu (MiniContextMenu) with Play now /
  Remove from queue / Open album / Go to artist / Favorite / Song info.
  Cross-window actions forward to main via new bridge events
  (mini:reorder, mini:remove, mini:navigate, mini:song-info); a
  psy:navigate CustomEvent picked up by AppShell handles routing.
- Theme, font and language changes in main propagate live to the mini
  via a 'storage' event listener that re-hydrates the persisted Zustand
  stores and calls i18n.changeLanguage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:08:05 +02:00
Psychotoxical 42ad24cce1 fix(player-bar): use data-tooltip on time toggle (not native title)
Native `title` attribute renders an unstyled OS tooltip and bypasses the
TooltipPortal — convention in this codebase is `data-tooltip="…"` so the
hover hint matches every other player-bar control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:04:29 +02:00
Kveld. eb747ba1ae introducing timer toggle player bar (#212) 2026-04-19 12:04:01 +02:00
Psychotoxical 6bd2bfc01c feat(changelog): replace auto-modal with sidebar banner + /whats-new page
Removes the giant startup changelog modal. After an update, a compact
neutral-palette pill now sits above Now Playing in the sidebar saying
"Changelog — vX.Y.Z". Clicking opens a proper /whats-new page in the
main content area; X dismisses. Page renders the current version's
CHANGELOG entry with inline Markdown (headings, lists, blockquotes,
hr, bold/italic/code, and real [label](url) links that open via the
Tauri shell plugin).

Visual tokens are hardcoded slate/gray/blue so the banner and page look
identical across every theme (dark, light, skeuomorphic, whatever) —
requested for consistent contrast. Auto-mark-as-seen removed from the
page; the banner only goes away on explicit dismiss, so users can
re-read as often as they want.

Settings → About gains a "Release notes" row with a link that resets
the seen-version and opens the page, so the banner can be retriggered
manually (also helpful for dev builds ahead of the current tag).

The existing "Show changelog on update" toggle now gates the banner
instead of the modal; description strings updated in all 8 locales.

fix(themes): WCAG contrast audit — mocha + winmedplayer + wista

Mocha (Catppuccin dark)
  .track-size used --ctp-overlay0 directly (3.36:1 on bg-app, 2.57
  on bg-card). Swapped to --text-muted → 7.37 / 5.65. Fix also lifts
  macchiato/frappe/latte which shared the failure.

WinMedPlayer (Luna)
  --text-muted #b8d0f8 (3.87:1 on bg-app) → #e8f0ff (5.28)
  --border   #2a5090 (1.31 on bg-app)   → #071027 (3.12, meets 3:1 UI)

Wista (Vista Aero)
  Lyrics pane sits on bg-sidebar #0e1e3e but used --text-primary
  (#0d1d3c) for active lines — 1.01:1, literally invisible. Added
  component overrides: .lyrics-line / .lyrics-status / word-synced
  variants → #aac8f0 (9.60), .lyrics-line.active → #ffffff (16.48).
  Palette: --warning #c8980c → #735a00 (2.37 → 5.91 on bg-app),
  --text-muted #4870a8 → #3f6aa0 (4.23 → 4.65 on bg-card).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:45 +02:00
Psychotoxical b5751c2918 docs(readme): fold Arch/AUR into the Linux install section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:27:24 +02:00
Psychotoxical 8c050ad297 docs(readme): add AppImage to Linux install options
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:25:56 +02:00
cucadmuh e2100aedc4 Update README.md (#210) 2026-04-19 01:21:20 +02:00
Psychotoxical aa69f360eb docs(readme): Cachix badge, NixOS feature/install, drop obsolete macOS xattr
- Add Cachix badge to the top row linking to psysonic.cachix.org
- New NixOS/flakes feature bullet and a dedicated install block with
  nix run quickstart, system-config hint, Cachix substituter setup, and
  credit + PR link for @cucadmuh
- macOS install block: drop the xattr Gatekeeper workaround (obsolete
  since v1.40.0 signed + notarized builds); replace with a note on the
  in-app auto-updater

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:09:36 +02:00
Frank Stellmacher ee932db8a9 Update README.md 2026-04-19 01:00:42 +02:00
Frank Stellmacher 461a759f0f Update README.md 2026-04-19 01:00:05 +02:00
Psychotoxical 193a37cf0c docs(about): credit cucadmuh for ArtistCardLocal i18n + NixOS guide
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:51:54 +02:00
cucadmuh 65a46bdd0f docs(nix): add NixOS flake install guide (#209)
Document NixOS/Home Manager installation from the upstream flake,
including Cachix substituter/key configuration, apply commands, and pinning.
Also add a README Linux note linking to the new NixOS guide.
2026-04-19 00:50:42 +02:00
Psychotoxical 0afcc4ab68 feat(mini-player): expandable queue + UX polish
Adds a queue panel that toggles via a new toolbar button and resizes the
mini window between 340×180 (collapsed) and 340×440 (expanded). Tracks
in the queue are clickable — click jumps to that index via a new
mini:jump event handled in the main-window bridge. The current track
auto-scrolls into view when the queue opens.

Resize uses a new resize_mini_player Rust command (more reliable than
JS setSize, which was silently no-op'ing because the
core:window:allow-set-size capability was missing). That capability is
now granted as well.

The mini player hydrates its initial state from the persisted
playerStore, so real content (cover, title, artist, queue) shows as
soon as React mounts instead of "—" while we wait for the first
mini:sync. Dynamic state (isPlaying, progress) still comes from
events.

Window sizing:
- inner_size bumped to 340×180 so the toolbar row isn't clipped
- mini label added to tauri-plugin-window-state denylist so old saved
  sizes don't come back
- show_main_window now also hides the mini — clicking expand no longer
  leaves both windows on screen

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 17:18:33 +02:00
64 changed files with 7405 additions and 1109 deletions
+24 -15
View File
@@ -83,10 +83,8 @@ jobs:
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
# Re-add before the next public release.
# - platform: 'windows-latest'
# args: '--bundles nsis'
- platform: 'windows-latest'
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
@@ -183,9 +181,6 @@ jobs:
gh release upload "app-v${VERSION}" latest.json --clobber
build-linux:
# TEMPORARILY DISABLED during macOS updater testing — faster turnaround.
# Re-enable by removing `if: false` before the next public release.
if: false
needs: create-release
permissions:
contents: write
@@ -241,15 +236,11 @@ jobs:
#
# The refreshed lock/hash files are committed back to `main` when they change.
verify-nix:
# TEMPORARILY DISABLED during the v1.34.x testing iteration (signing +
# updater stabilisation). Remove `if: false` to re-enable — it auto-commits
# flake.lock / npmDepsHash refreshes to main on every release, which causes
# rebase friction while we're iterating fast on the release pipeline.
if: false
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
@@ -291,9 +282,19 @@ jobs:
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
# The cachix-action daemon writes a post-build-hook into the user
# nix.conf, but the Determinate Nix daemon that runs the builds reads
# the system nix.conf — so the hook never fires and only a couple of
# early prep paths get uploaded. Force an explicit closure push here;
# cachix dedupes against anything already in the cache.
nix path-info --recursive .#psysonic | cachix push psysonic
- name: commit + push refreshed lock and hash (if changed)
- name: open + auto-merge PR with refreshed lock and hash (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
@@ -304,5 +305,13 @@ jobs:
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
BRANCH="chore/nix-lock-refresh-v${VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin HEAD:main
git push origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated after the v${VERSION} release: refreshes \`flake.lock\` and \`nix/upstream-sources.json\` so the Cachix substituter resolves the latest pin."
gh pr merge "$BRANCH" --squash --auto --delete-branch
+154 -37
View File
@@ -5,65 +5,182 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> ** Note for Windows users:** This is one of the last releases with an unsigned Windows installer. We are waiting for our code signing certificate and hope it will arrive within the next few days. The installer does not contain a virus — any warnings from Windows SmartScreen or antivirus software are false positives. If you'd like to help cover the certificate costs, you can do so at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all.
> **🛡 A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
>
> **🎉 macOS users:** Starting with **v1.34.15**, Psysonic can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Update" when the toast appears.
## [1.34.22] - 2026-04-18
> ### ⚠️ PLEASE IGNORE — TEST BUILD ONLY
> **⚠️ 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.
>
> Corrects the minisign public key in the bundle. The earlier releases had a character lost in transmission, which made the embedded pubkey one byte short and therefore invalid — any auto-update attempt failed with "Invalid encoding in minisign data". This rebuild has the correct key. Test builds will be deleted soon.
## [1.34.21] - 2026-04-18
> ### ⚠️ PLEASE IGNORE THIS RELEASE — TEST BUILD ONLY
> **🎉 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.
>
> **This is a throwaway test build used to verify the macOS auto-updater pipeline end-to-end. There are no new features, no bug fixes, no changes of any kind that affect how Psysonic behaves for end users — only the version number, plus a small UI polish in the update notification on macOS.**
>
> **This release will be deleted within a day or two.** Do not download it, do not rely on it, do not link to it. Stay on whatever version you currently have installed; normal releases will resume shortly under the regular versioning scheme.
>
> Thanks for your patience while we stabilise the updater infrastructure.
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
## [1.34.20] - 2026-04-18
> ### ⚠️ PLEASE IGNORE THIS RELEASE — TEST BUILD ONLY
>
> **This is a throwaway test build used to verify the macOS auto-updater pipeline end-to-end. There are no new features, no bug fixes, no changes of any kind that affect how Psysonic behaves — only the version number is different.**
>
> **This release will be deleted within a day or two.** Do not download it, do not rely on it, do not link to it. Stay on whatever version you currently have installed; normal releases will resume shortly under the regular versioning scheme.
>
> Thanks for your patience while we stabilise the updater infrastructure.
## [1.34.16] - 2026-04-18
### Fixed
- **CI — Updater signature upload** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: tauri-action on macOS produces the `.app.tar.gz.sig` minisign signature locally but does not upload it as a release asset for cross-target builds, which caused the `latest.json` manifest generator to fail (no signature to embed). An explicit post-build step now finds the `.sig` under `src-tauri/target/*/release/bundle/macos/` and uploads it with the expected filename (`Psysonic_aarch64.app.tar.gz.sig` / `Psysonic_x64.app.tar.gz.sig`).
## [1.34.15] - 2026-04-18
## [1.43.0] - 2026-04-20
### Added
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Update** in the notification toast downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper warnings, no manual DMG handling. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
- **User Management — admin-gated tab in Settings** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the active server is Navidrome and the logged-in user is an admin, Settings gets a new "Users" tab. Lists every user with username, display name, email, last-access timestamp and assigned libraries. Add / edit / delete via Navidrome's native REST API (`/api/user`) using a Bearer token obtained from `/auth/login` — the Subsonic API doesn't expose this, so non-Navidrome servers don't get the tab.
## [1.34.14] - 2026-04-18
- **User Management — per-user library assignment** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#222](https://github.com/Psychotoxical/psysonic/pull/222))*: Mirrors the Navidrome web client. Non-admin users get a checkbox picker showing every library on the server; the picker is hidden for admins (Navidrome auto-grants them access to all libraries). Inline validation prevents saving a non-admin with zero libraries.
- **User Management — last-access timestamp per user** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Each row shows when the user was last active, formatted as a localised relative time (`vor 5 Min.`, `2h ago`, etc.) using `Intl.RelativeTimeFormat`. Tooltip carries the absolute timestamp. Users who have never logged in show "Never".
- **Seekable streaming + instant local playback — first cut** *(by [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh))*: New `RangedHttpSource` + `LocalFileSource` audio backends. Seek operations on remote tracks now issue HTTP `Range` requests instead of restarting the stream from byte 0, and locally cached files start playing instantly without going through the HTTP path at all. WaveformSeek commits the seek on mouseup (not during drag), and progress ticks during a drag are ignored so the playhead doesn't jitter back and forth. **Note:** the underlying seek/buffer behaviour is not fully sorted yet — expect follow-up changes in the next releases as edge cases (slow proxies, partial-content retries, codec-specific quirks) get ironed out.
- **Mini player — queue-style meta block, action toolbar, vertical volume slider** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The mini's right column gets a richer track-info block matching the queue panel's styling. A dedicated action toolbar (love / queue / context menu) sits below the transport. The horizontal volume slider is replaced by a tall vertical one on the right edge for a more compact footprint.
- **Settings — compact spacing pass + row hover affordance** *(by [@Psychotoxical](https://github.com/Psychotoxical), PR [#223](https://github.com/Psychotoxical/psysonic/pull/223))*: Section margins, card padding and divider spacing all tightened — every Settings tab fits more content per viewport. Each toggle row gains a subtle accent-tinted hover background that bleeds to the card edges so the active row is visually obvious.
- **Floating player bar — toggleable variant** *(by [@kveld9](https://github.com/kveld9), PR [#216](https://github.com/Psychotoxical/psysonic/pull/216))*: Settings → Appearance → "Floating player bar" turns the player bar into a floating, rounded panel that sits above the page content with a margin around all four edges. Off by default. Solid background, works with every theme.
- **Floating player bar — liquid-glass look on macOS and Windows** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: When the floating bar is enabled, macOS and Windows users get a gentler glass-effect background (subtle blur + tint) on top of @kveld9's solid variant. Linux keeps the solid look — WebKitGTK's `backdrop-filter` cost is too high for an always-visible panel. A new `data-platform` attribute on `<html>` is the generic platform-gate that other CSS can hook into.
- **NVIDIA proprietary driver — DMA-BUF auto-disabled on Linux** *(by [@kveld9](https://github.com/kveld9), PR [#217](https://github.com/Psychotoxical/psysonic/pull/217), refactored by [@Psychotoxical](https://github.com/Psychotoxical))*: Detects the NVIDIA proprietary driver at startup and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` for the WebKitGTK process, avoiding rendering glitches that show up specifically on that combo. Confirmed via blind A/B testing — only the proprietary driver is targeted; Nouveau / AMD / Intel are not touched.
- **Lyrics — cubic ease-out scroll animator** *(by [@kilyabin](https://github.com/kilyabin), PRs [#214](https://github.com/Psychotoxical/psysonic/pull/214) / [#215](https://github.com/Psychotoxical/psysonic/pull/215))*: The lyrics auto-scroll animation is replaced by a smoother cubic ease-out curve (renamed internally from `springScroll` to `easeScroll`). Active line transitions are noticeably less jerky on long line-spacing changes.
- **Fullscreen lyrics — fade bottom edge of plain lyrics scroll viewport** *(by [@kilyabin](https://github.com/kilyabin))*: Plain (unsynced) lyrics in the fullscreen player now fade out at the bottom of the scroll viewport via a `mask-image` gradient, matching the existing fade on the synced-lyrics overlay.
### Fixed
- **Mini player — main window minimises on open + width cap on non-tiling WMs** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Opening the mini now reliably minimises the main window (previously hit-or-miss on some WMs), and the mini's width is capped on non-tiling Linux WMs so it doesn't open larger than its intended footprint when the user's WM hands it the full screen.
- **Artist page — Top Songs continues playback past the last track** *(by [@kveld9](https://github.com/kveld9), PR [#220](https://github.com/Psychotoxical/psysonic/pull/220))*: Playing a song from the Artist page's Top Songs row no longer stops after the row's last track — the queue continues into the surrounding context as intended.
- **Padding fixes across several pages** *(by [@kveld9](https://github.com/kveld9), PR [#221](https://github.com/Psychotoxical/psysonic/pull/221))*: Layout polish, mostly aligning content to the page-level container padding instead of the inner card padding.
- **Jayfin theme — WCAG AA contrast fixes for nav + primary buttons** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Hover and active states on the Jayfin theme's sidebar nav items and primary buttons now pass WCAG AA contrast against the underlying background.
- **Lyrics — sidebar lyrics with YouLy+ source render as a single line** *(by [@kilyabin](https://github.com/kilyabin))*: Lines from the YouLyrics+ source were being split across multiple visual lines in the QueuePanel lyrics pane. Now collapse onto one line as intended.
- **Settings → Lyrics Sources — drag-and-drop survives mode toggle** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Reordering lyrics sources via drag-and-drop no longer resets when toggling the synced-vs-plain mode.
- **Folder browser — auto-contrast text on selected row** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Selected rows in the folder browser now compute text colour from the row's background luminance, so light themes don't paint white-on-white text.
- **Titlebar — theme-independent traffic-lights + song pill** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The macOS-style traffic-lights and the now-playing pill in the titlebar use fixed colours instead of theme tokens, so they stay legible on every theme without needing per-theme overrides.
### Reverted
- **Reverted: fs-player WebKitGTK CPU-cut patch** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: An earlier perf patch in the Fullscreen Player that disabled compositing under WebKitGTK turned out to cause animation regressions in real-world use. Reverted; the original code path is back.
### Changed
- **AudioMuse toggle — Alpha badge dropped** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The AudioMuse-AI integration has been stable for several releases; the "Alpha" tag in Settings → Server is removed.
## [1.42.1] - 2026-04-19
> **🚨 Critical bug fix for Windows users.** On 1.42.0, opening the mini player on Windows could stall Tauri's event loop: the mini would appear as a blank white window, neither the main window nor the mini could be closed, and the only way out was killing the process via Task Manager. **Please update immediately if you're on Windows 1.42.0.** macOS and Linux were not affected.
### Fixed
- **Mini player no longer hangs the app on Windows** *([@Psychotoxical](https://github.com/Psychotoxical))*: Creating the second WebView2 webview lazily from the `open_mini_player` invoke handler reliably froze the app on Windows — the mini opened blank, both windows became unresponsive, and the user had to kill the process from Task Manager. The builder + `main.minimize()` combo racing against WebView2's first paint was the trigger. The mini webview is now pre-built hidden in Tauri's `.setup()` on Windows, so the first open is a pure show/hide instead of creation + minimize. `open_mini_player` is simpler on all platforms, the minimize-main dance around show/hide is skipped on Windows, and Windows also goes back to the native window decorations (the earlier `decorations: false` mini titlebar was part of the hang surface).
- **Mini player syncs immediately on first open** *([@Psychotoxical](https://github.com/Psychotoxical))*: With the mini pre-created on Windows, the mount-time `mini:ready` event could race past the main window's bridge listener and leave the mini without a snapshot when the user actually opened it. The mini now also re-emits `mini:ready` on every window focus, so opening the mini always triggers a fresh sync regardless of startup ordering.
### Added
- **Optional “Preload mini player” setting on Linux + macOS** *([@Psychotoxical](https://github.com/Psychotoxical))*: Settings → General → App behaviour. Off by default. When enabled, the mini player window is built hidden at app start so the first open is instant instead of waiting a few seconds for WebKit to boot + React to hydrate + the bridge snapshot to arrive. Costs one extra WebKit process in the background permanently (~50100 MB RAM). Windows always preloads regardless of this toggle — it's how we work around the hang above, not an opt-in feature there.
## [1.42.0] - 2026-04-19
> **🛠️ Note on the 1.41.0 jump:** The 1.41.0 tag exists as an internal Draft release on GitHub — it was used to wire up and verify the Cachix substituter pipeline and never went public. **1.42.0 is the first public release after 1.40.0** and consolidates everything that was prepared for 1.41.0 plus the work landed on top in the days since.
>
> **❄️ Cachix is live for NixOS users.** The `psysonic.cachix.org` substituter is now actually fed by every release. Earlier 1.40.x runs were silently skipping the cache push (see *Fixed* below), so the first user to ask for a given output paid the full compile cost. Starting with 1.42.0, `nix run github:Psychotoxical/psysonic` and the NixOS module both pull the prebuilt closure straight from Cachix — no local Rust + symphonia + libopus build required.
### Added
- **Mini player — feature-complete second cut** *(Issue [#162](https://github.com/Psychotoxical/psysonic/issues/162), by [@Psychotoxical](https://github.com/Psychotoxical))*: The early-alpha mini from the internal 1.41.0 prep gets the rest of the workflow it was missing.
- **Expandable queue panel** with full track list, search-style overlay scrollbar (no width-eating gutter), drag-to-reorder using the existing PsyDnD system, and a localized right-click context menu (Play now / Remove from queue / Open album / Go to artist / Favorite / Song info — all forwarded to the main window via Tauri events so the source-of-truth playerStore stays consistent).
- **Custom in-page titlebar** on Windows + Linux with a drag region, the current track title and the queue / pin / open-main / close action icons. macOS keeps the native traffic-lights titlebar so the system look is preserved. The lower toolbar from the alpha is gone — its four buttons live in the titlebar now.
- **Persistent geometry**: window position, expanded-queue height and queue-open state all survive an app restart. Position is written to `<app_config_dir>/mini_player_pos.json` on every move (throttled), and re-applied after each show — Linux WMs (Mutter/KWin) re-centre hidden windows on show, so without re-applying the position would be lost on the second open.
- **User-bindable keyboard shortcut** in Settings → Shortcuts (`open-mini-player`, default unbound). The same chord toggles between main and mini regardless of which window has focus.
- **Layout polish**: cover shrinks 112 → 84 px, the right column gets title / artist / transport in a single block, progress + toolbar take full width.
- **Live theme / font / language sync**: changes in the main window propagate to an open mini via the shared localStorage `storage` event — no need to close + re-open the mini after rebinding a shortcut or switching themes.
- **Always-on-top reliability fix**: WMs that silently ignore `set_always_on_top(true)` when the flag is "already true" (KWin, certain Mutter releases) get a forced false → true cycle so the constraint is actually re-evaluated. The frontend also re-asserts the pin state on mount and on focus, so the user no longer has to click the pin button twice for it to stick.
- **Player bar — click-to-toggle duration / remaining time** *(contributed by [@kveld9](https://github.com/kveld9), PR [#212](https://github.com/Psychotoxical/psysonic/pull/212))*: Click the time read-out in the player bar to swap between total duration (`3:45`) and remaining time (`-2:34`). Updates live, persisted to `themeStore.showRemainingTime`. A small swap icon (⇄) and hover highlight signal the interaction.
- **Queue — ReplayGain in tech strip, expandable badge** *(Issue [#195](https://github.com/Psychotoxical/psysonic/issues/195), originally by [@cucadmuh](https://github.com/cucadmuh) in PRs [#196](https://github.com/Psychotoxical/psysonic/pull/196) / [#201](https://github.com/Psychotoxical/psysonic/pull/201) — UX iteration by [@Psychotoxical](https://github.com/Psychotoxical) on cucadmuh's feedback)*: Tracks with ReplayGain metadata now show a small `RG ⌄` pill at the end of the codec/bitrate/sample-rate strip. Hover reveals the values via tooltip; click expands a second line ("ReplayGain · T -8.9 dB · A -11.0 dB · Peak 0.998") that is persisted across sessions. Hides itself for tracks without RG metadata.
- **Changelog — sidebar banner + dedicated `/whats-new` page** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The auto-popup modal that nagged the user on first launch after each update is replaced by a discreet sidebar banner. Clicking it opens a full `/whats-new` page that renders the latest CHANGELOG section in app — no separate Markdown viewer, no broken links to GitHub.
- **Favorites — genre column + Top Favorite Artists row** *(Issue [#87](https://github.com/Psychotoxical/psysonic/issues/87), by [@Psychotoxical](https://github.com/Psychotoxical))*: The Favorites tracklist now has a toggleable Genre column (alongside the existing Album column and multi-genre filter). A new horizontally scrolling "Top Favorite Artists" row sits between Radio Stations and Songs, aggregated from starred tracks and sorted by star count. Clicking an artist card narrows the song list to that artist.
- **Compilation filter on All Albums** *(Issue [#65](https://github.com/Psychotoxical/psysonic/issues/65), by [@Psychotoxical](https://github.com/Psychotoxical))*: A tri-state toggle in the Albums page header (All / Only compilations / Hide compilations) that reads the OpenSubsonic `isCompilation` tag exposed by Navidrome 0.61+. Client-side filter, no additional server calls. Translated into all 8 supported locales.
- **Sticky header on Albums, New Releases, Artists** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The header row with search/sort/genre/year controls now pins to the top while scrolling, so filters stay reachable without jumping back up. Works the same on all three browse pages.
- **Device Sync — album artist on both panels** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Album entries in both the library (left) and on-device (right) panels now display `Album · Artist` inline, so sampler discs and self-titled albums are no longer guesswork. Playlists unchanged.
- **NixOS — first-class flake install guide** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PRs [#209](https://github.com/Psychotoxical/psysonic/pull/209) / [#210](https://github.com/Psychotoxical/psysonic/pull/210))*: A new top-level `nixos-install.md` walks through adding Psysonic as a flake input, installing via `environment.systemPackages` / `home.packages`, and wiring up the public `psysonic.cachix.org` substituter so every NixOS user pulls prebuilt binaries. README links to it directly.
- **README — AppImage in the Linux install options + Cachix badge** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Linux install section now lists AppImage alongside `.deb`, `.rpm`, AUR and Nix flakes. A Cachix badge on the README header signals that NixOS users get prebuilt binaries.
### Changed
- **Genre filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The inline tagbox + dropdown (capped at 60 entries, ate header space when expanded) is replaced by a compact button that opens a portal-rendered popover with a search field and the full scrollable list of genres. Selected genres sort to the top. Used on Albums, New Releases, Random Albums and Favorites.
- **Year filter — portal popover** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The From/To number inputs in the Albums header became a single button with a popover mirroring the genre filter pattern. When the filter is active, the button shows the range (e.g. `20202024`) in accent colour.
- **Sort picker — portal dropdown** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The two sort buttons on Albums (`AZ (Album)`, `AZ (Artist)`) collapse into one dropdown button showing the current choice. Generic `SortDropdown` component, reusable for other pages.
- **Device Sync — album/playlist meta inline** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `BrowserRow` renders secondary info inline with a `·` separator in muted colour instead of a separate right-aligned column, matching the on-device panel's format.
- **README — Arch/AUR fold-up** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Arch / AUR install instructions are folded into the Linux install section so the README stops scrolling forever.
### Fixed
- **Player bar — black-flash on WebKitGTK** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Linux users occasionally saw the entire player bar paint fully black for one frame when an unrelated layer elsewhere on the page invalidated. `contain: layout paint` makes the bar its own paint boundary so it can no longer be pulled into a surrounding dirty rect. No-op on platforms that don't exhibit the flash (Wayland-with-GPU, Chromium webviews on Windows / macOS).
- **Player bar — time-toggle tooltip uses the in-app TooltipPortal** *(follow-up to PR [#212](https://github.com/Psychotoxical/psysonic/pull/212), by [@Psychotoxical](https://github.com/Psychotoxical))*: The new time-swap control was rendering the native browser `title=` tooltip (unstyled OS popup, ignored by every other control). Switched to `data-tooltip="…"` so it matches every other player-bar tooltip.
- **Fullscreen player — lyrics menu toggle + readability** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Re-clicking the mic icon now actually closes the lyrics settings panel instead of the outside-click handler closing it and the click re-opening it — the trigger button is excluded from the outside-check. The panel itself is now a solid surface (no backdrop blur, near-opaque background, higher-contrast button text) so settings remain readable over the busy fullscreen background.
- **i18n — ArtistCardLocal album count** *(contributed by [@cucadmuh](https://github.com/cucadmuh))*: Local artist cards were rendering the album count with hardcoded German (`Album` / `Alben`). Switched to the existing plural-aware `artists.albumCount` key which already covers all 8 locales including Russian Slavic plurals.
- **Release CI — Cachix never receiving the psysonic closure** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: `cachix-action` installs its post-build hook via `NIX_USER_CONF_FILES`, but the Determinate Nix daemon that runs the actual builds reads the system nix.conf — so the hook never fired. Only a couple of early prep paths ever reached the cache, never the compiled `psysonic` output. The release workflow now pushes the full closure explicitly after `nix build`; Cachix dedupes against paths already present, so redundancy is cheap.
### Contributors
- [@kveld9](https://github.com/kveld9) — click-to-toggle duration / remaining time in the player bar.
- [@cucadmuh](https://github.com/cucadmuh) — i18n fix for ArtistCardLocal, ReplayGain UX feedback that drove the expandable badge, NixOS install guide, README polish.
---
## [1.40.0] - 2026-04-18
### Added
- **macOS — signed and notarized builds** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: macOS releases are now signed with a Developer ID Application certificate and notarized by Apple. Gatekeeper no longer shows the "app from unidentified developer" dialog; the DMG opens and runs with a single click on both Apple Silicon and Intel Macs. Signing + notarization happens in CI on every release.
- **macOS — in-app auto-update** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The Tauri Updater plugin is now active on macOS. When a new release is available, clicking **Install now** in the notification modal downloads the signed `.app.tar.gz` bundle, verifies its minisign signature against the bundled public key, replaces `/Applications/Psysonic.app` in place, and relaunches the app — all in one click, no Gatekeeper re-approval, no manual DMG handling. The modal shows trust badges ("Notarized by Apple" + "Signature verified"), a 3-second restart countdown after install with a manual "Restart now" option, and hides redundant buttons during each download/install phase. Windows and Linux continue to use the existing "download installer / point to folder" flow until their signing pipelines are wired up.
- **WebKitGTK wheel scroll mode (Linux)** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#207](https://github.com/Psychotoxical/psysonic/pull/207))*: The Linux build now defaults to WebKitGTK's native smooth (kinetic) wheel scrolling and exposes a toggle in Settings → General to fall back to classic linear line-by-line scroll. Existing installs are migrated to smooth scrolling once, after which the toggle is fully user-controlled.
### Changed
- **Device Sync — fixed naming scheme + playlist folders** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: The user-configurable filename template is gone. Every sync now writes files under a single, non-negotiable scheme:
- Album / artist sources: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}`
- Playlist sources: `Playlists/{PlaylistName}/{Index:02d} - {Artist} - {Title}.{ext}` plus a self-contained `.m3u8` that references sibling filenames.
**Why:** different OSes normalised separators and special characters differently, so the same library synced from macOS and then plugged into a Windows machine appeared "different" and re-downloaded every album. The fixed scheme ends that forever.
**Playlist folders instead of the album tree:** playlists used to be scattered across the album structure as `.m3u8` references. For playlists with 40 artists that meant 40 new folders on the stick. Now every playlist is one self-contained folder; the `.m3u8` sits inside it and references siblings, so you can copy the whole folder anywhere.
**Migration for existing sticks:** a "Reorganize existing files…" button on the Device Sync page reads the legacy template from the v1 manifest, computes per-track rename pairs, detects collisions, and executes atomic `fs::rename`s. Empty directories left behind are cleaned up automatically. Playlist tracks synced under the old scheme are left for the next sync to re-download into the new playlist folder, rather than being force-moved.
**Album-Artist fallback:** libraries without an albumArtist tag fall back to the track artist — "Unknown Artist" is only ever a last-resort placeholder.
### Fixed
- **WCAG contrast audit — Middle-Earth theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Raised `--warning`, `--border`, `--text-muted`, `--positive`, and multiple component-level overrides (connection indicators, nav section labels, lyrics status, queue duration, player time, glass-panel muted text) to AA thresholds on all background variants. The warm bronze / aged-parchment palette is preserved — no cool tones introduced.
- **WCAG contrast audit — Nucleo theme** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Darkened `--warning`, `--border`, `--text-muted`, and `--positive` tokens to reach AA on the warm cream palette; added a component-level override for the column resize grip (default `--ctp-surface1` was 1.08:1 on the card background, effectively invisible) using the new `--border` token at 2px width. Brass-and-parchment aesthetic preserved.
### Changed
### Contributors
- **Contributors list updated** *(by [@Psychotoxical](https://github.com/Psychotoxical))*: Settings → About now credits PRs [#205](https://github.com/Psychotoxical/psysonic/pull/205) (Apple Music-style scrolling lyrics by [@kilyabin](https://github.com/kilyabin)), [#206](https://github.com/Psychotoxical/psysonic/pull/206) (Golos Text + Unbounded fonts with Cyrillic support by [@kilyabin](https://github.com/kilyabin)), and [#207](https://github.com/Psychotoxical/psysonic/pull/207) (WebKitGTK wheel scroll mode by [@cucadmuh](https://github.com/cucadmuh)).
- **PR [#205](https://github.com/Psychotoxical/psysonic/pull/205)** — Apple Music-style scrolling lyrics with spring-physics scroll, by [@kilyabin](https://github.com/kilyabin).
- **PR [#206](https://github.com/Psychotoxical/psysonic/pull/206)** — Golos Text + Unbounded fonts with Cyrillic support, by [@kilyabin](https://github.com/kilyabin).
- **PR [#207](https://github.com/Psychotoxical/psysonic/pull/207)** — WebKitGTK wheel scroll mode toggle, by [@cucadmuh](https://github.com/cucadmuh).
All three now credited in Settings → About.
---
+25 -24
View File
@@ -9,7 +9,8 @@
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img alt="AUR" src="https://img.shields.io/aur/version/psysonic?style=flat-square&color=1793d1"></a>
<a href="https://aur.archlinux.org/packages/psysonic-bin"><img alt="AUR (bin)" src="https://img.shields.io/aur/version/psysonic-bin?style=flat-square&color=1793d1&label=AUR%20(bin)"></a>
<a href="https://discord.gg/pq6d2ZYSg"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
<a href="https://psysonic.cachix.org"><img alt="Cachix" src="https://img.shields.io/badge/Cachix-psysonic-5277c3?style=flat-square&logo=nixos&logoColor=white"></a>
<a href="https://discord.gg/AMnDRErm4u"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white"></a>
</p>
</div>
@@ -49,7 +50,8 @@ Designed specifically for users hosting their own music via Navidrome or other S
- 🖥️ **CLI Control**: Control playback, switch servers, manage the queue, and more directly from the command line.
- ⌨️ **Customization**: Configurable keybindings, UI fonts, global zoom slider, system tray, backup & restore, and in-app auto-update.
- 🌍 **8 Languages**: English, German, French, Dutch, Spanish, Chinese, Norwegian, Russian.
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
- 🖥️ **Cross-Platform**: Windows, macOS, and Linux (Arch AUR, .deb, .rpm, NixOS flake).
- ❄️ **NixOS / flakes**: First-class flake package with a public **Cachix** binary cache (`psysonic.cachix.org`) — `nix run github:Psychotoxical/psysonic` or add to your system config. See the [NixOS install guide](./nixos-install.md).
## 🗺️ Roadmap
@@ -74,29 +76,9 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
**Manual Installation:**
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
- **Any distro (portable)**: `.AppImage` from GitHub Releases — `chmod +x` and run, no install required
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 Installation (Arch Linux / AUR)
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
**Arch Linux (AUR):**
| Package | Type | Description |
| :--- | :--- | :--- |
@@ -106,6 +88,25 @@ Psysonic is available in the **AUR** in two versions. Choose the one that best f
> [!TIP]
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
**❄️ NixOS (flakes):**
- `nix run github:Psychotoxical/psysonic` — one-shot launch
- Full guide: [`nixos-install.md`](./nixos-install.md) *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#209](https://github.com/Psychotoxical/psysonic/pull/209))*
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon) — **signed with an Apple Developer ID and notarized by Apple**. Gatekeeper opens it with a single click, no `xattr` workaround required.
> [!NOTE]
> Since **v1.40.0**, macOS builds include an in-app auto-updater: click **Install now** in the update notification and the signed `.app.tar.gz` is fetched, verified against the bundled minisign public key, replaced in place, and the app relaunches — all in one step.
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 🚀 Getting Started
1. Download and install Psysonic.
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-GwwfdTSGsjLvaJiSrEJPj+I027Lp6uPLkDXZJ+pDers="
"npmDepsHash": "sha256-5YAQ2PqxJtD7+adecF++swOHVKNstEkyRQeiBrP2lvA="
}
+134
View File
@@ -0,0 +1,134 @@
# Installing Psysonic on NixOS (flake)
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
## Prerequisites
**Flakes** enabled (e.g. in `configuration.nix`):
```nix
nix.settings.experimental-features = [ "nix-command" "flakes" ];
```
## Binary cache (Cachix)
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
- **Substituter URL:** `https://psysonic.cachix.org`
- **Public key** (trust this only if it matches what you expect from the cache owners):
```text
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
```
### NixOS (`configuration.nix` or a flake module)
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
```nix
{
nix.settings = {
substituters = [
"https://psysonic.cachix.org"
"https://cache.nixos.org/"
];
trusted-public-keys = [
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
];
};
}
```
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
## Install on NixOS (flake configuration)
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
### Example: top-level `flake.nix` + `nixosConfigurations`
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
psysonic.url = "github:Psychotoxical/psysonic";
};
outputs = { self, nixpkgs, ... }@inputs: let
system = "x86_64-linux";
in {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./configuration.nix
{
environment.systemPackages = [
inputs.psysonic.packages.${system}.psysonic
];
}
];
};
};
}
```
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
```nix
environment.systemPackages = with pkgs; [
# …
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
### Pinning a revision or tag
Follow **`main`** (above) to track the moving branch, or pin for reproducibility:
```nix
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.34.13"; # example: release tag
```
Use a tag or commit SHA that exists on GitHub; the release workflow keeps **`flake.lock`** and **`nix/upstream-sources.json`** (`npmDepsHash`) in sync on tagged releases.
### Apply configuration
- **NixOS flake host**
```bash
sudo nixos-rebuild switch --flake .#my-host
```
- **Home Manager** (if used separately)
```bash
home-manager switch --flake .#my-user@my-host
```
## Home Manager
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
```nix
home.packages = [
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
## Desktop entry
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
## Troubleshooting (Linux / WebKit)
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
## More detail in-repo
- **`flake.nix`** — package outputs, `devShell`, supported systems (see comments there for `nix build` / `nix develop`).
- **`nix/psysonic.nix`** — how the app is built from this source tree.
- **`.github/workflows/release.yml`** — `verify-nix` job: refreshes lock/npm hash and pushes store paths to Cachix on release tags.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.34.13",
"version": "1.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.34.13",
"version": "1.42.1",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.22",
"version": "1.43.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.34.12
pkgver=1.42.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+17 -11
View File
@@ -3115,9 +3115,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.3"
version = "5.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
dependencies = [
"dunce",
"is-wsl",
@@ -3653,7 +3653,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.22"
version = "1.43.0"
dependencies = [
"biquad",
"discord-rich-presence",
@@ -5971,9 +5971,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uds_windows"
@@ -6186,11 +6186,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
@@ -6199,7 +6199,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.51.0",
]
[[package]]
@@ -6339,9 +6339,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
@@ -7139,6 +7139,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.22"
version = "1.43.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+2 -1
View File
@@ -3,7 +3,7 @@
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"windows": ["main", "mini"],
"permissions": [
"core:default",
"shell:default",
@@ -36,6 +36,7 @@
"core:window:allow-is-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:window:allow-set-size",
"core:webview:allow-create-webview-window",
"process:allow-restart",
"updater:default"
@@ -342,7 +342,7 @@ impl Device {
/// Ensures that `future_audio_client` contains a `Some` and returns a locked mutex to it.
fn ensure_future_audio_client(
&self,
) -> Result<MutexGuard<Option<IAudioClientWrapper>>, windows::core::Error> {
) -> Result<MutexGuard<'_, Option<IAudioClientWrapper>>, windows::core::Error> {
let mut lock = self.future_audio_client.lock().unwrap();
if lock.is_some() {
return Ok(lock);
+511 -41
View File
@@ -1,6 +1,6 @@
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
#[cfg(unix)]
use libc;
@@ -668,6 +668,167 @@ impl MediaSource for AudioStreamReader {
fn byte_len(&self) -> Option<u64> { None }
}
// ── RangedHttpSource — seekable HTTP-backed MediaSource ──────────────────────
//
// Pre-allocates a Vec<u8> of total track size. A background task fills it
// linearly from offset 0 via streaming HTTP. Read blocks (with timeout) until
// requested bytes are downloaded; Seek only updates the cursor.
//
// Reports is_seekable=true so Symphonia performs time-based seeks via the
// format reader. Backward seeks: instant (data in buffer). Forward seeks
// beyond downloaded_to: Read blocks until the linear download catches up.
//
// Requires server to have responded with both Content-Length and
// `Accept-Ranges: bytes` so reconnects can resume via HTTP Range.
struct RangedHttpSource {
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
buf: Arc<Mutex<Vec<u8>>>,
/// Bytes contiguously downloaded from offset 0.
downloaded_to: Arc<AtomicUsize>,
total_size: u64,
pos: u64,
/// Set when the download task terminates (success or hard error).
done: Arc<AtomicBool>,
gen_arc: Arc<AtomicU64>,
gen: u64,
}
impl Read for RangedHttpSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
if self.pos >= self.total_size {
return Ok(0);
}
let max_read = ((self.total_size - self.pos) as usize).min(buf.len());
if max_read == 0 {
return Ok(0);
}
let target_end = self.pos + max_read as u64;
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
#[cfg(debug_assertions)]
let block_started = Instant::now();
#[cfg(debug_assertions)]
let mut blocked = false;
loop {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
if dl >= target_end {
#[cfg(debug_assertions)]
if blocked {
eprintln!(
"[stream] read unblocked after {:.2}s — pos={} target_end={} dl={}",
block_started.elapsed().as_secs_f64(),
self.pos,
target_end,
dl
);
}
break;
}
#[cfg(debug_assertions)]
if !blocked {
blocked = true;
eprintln!(
"[stream] read blocking — pos={} need={} dl_to={} (waiting for {} more bytes)",
self.pos,
target_end,
dl,
target_end - dl
);
}
// Download finished but our cursor is past downloaded_to (e.g. seek
// beyond a partial download that aborted). Return what we have.
if self.done.load(Ordering::SeqCst) {
if dl > self.pos {
let avail = (dl - self.pos) as usize;
let src = self.buf.lock().unwrap();
let start = self.pos as usize;
buf[..avail].copy_from_slice(&src[start..start + avail]);
drop(src);
self.pos += avail as u64;
return Ok(avail);
}
return Ok(0);
}
if Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"ranged-http: no data within timeout",
));
}
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
}
let src = self.buf.lock().unwrap();
let start = self.pos as usize;
let end = start + max_read;
buf[..max_read].copy_from_slice(&src[start..end]);
drop(src);
self.pos += max_read as u64;
Ok(max_read)
}
}
impl Seek for RangedHttpSource {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let new_pos: i64 = match pos {
SeekFrom::Start(p) => p as i64,
SeekFrom::Current(p) => self.pos as i64 + p,
SeekFrom::End(p) => self.total_size as i64 + p,
};
if new_pos < 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"ranged-http: seek before start",
));
}
self.pos = (new_pos as u64).min(self.total_size);
Ok(self.pos)
}
}
impl MediaSource for RangedHttpSource {
fn is_seekable(&self) -> bool { true }
fn byte_len(&self) -> Option<u64> { Some(self.total_size) }
}
// ── LocalFileSource — seekable file-backed MediaSource ───────────────────────
//
// Wraps `std::fs::File` so the decoder reads on-demand from disk instead of
// pre-loading the whole file into a Vec. Used for `psysonic-local://` URLs
// (offline library + hot playback cache hits) — gives instant track-start
// because Symphonia only needs to read ~64 KB during probe before playback
// can begin, vs the previous behaviour of `tokio::fs::read` which blocked
// until the entire file (often 100+ MB for hi-res FLAC) was in RAM.
struct LocalFileSource {
file: std::fs::File,
len: u64,
}
impl Read for LocalFileSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
impl Seek for LocalFileSource {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
impl MediaSource for LocalFileSource {
fn is_seekable(&self) -> bool { true }
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ── Pause / Reconnect Coordination ───────────────────────────────────────────
pub(crate) struct RadioSharedFlags {
@@ -1000,12 +1161,152 @@ async fn track_download_task(
}
}
/// Linear downloader for `RangedHttpSource`: fills the pre-allocated buffer
/// from offset 0 to total_size. Reconnects via HTTP Range from the current
/// `downloaded` offset on transient errors. On completion (full track) the
/// data is promoted to `stream_completed_cache` for fast replay.
async fn ranged_download_task(
gen: u64,
gen_arc: Arc<AtomicU64>,
http_client: reqwest::Client,
url: String,
initial_response: reqwest::Response,
buf: Arc<Mutex<Vec<u8>>>,
downloaded_to: Arc<AtomicUsize>,
done: Arc<AtomicBool>,
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
) {
let total_size = buf.lock().unwrap().len();
let mut downloaded: usize = 0;
let mut reconnects: u32 = 0;
let mut next_response: Option<reqwest::Response> = Some(initial_response);
#[cfg(debug_assertions)]
let dl_started = Instant::now();
#[cfg(debug_assertions)]
let mut next_progress_mb: usize = 1;
'outer: loop {
let response = if let Some(r) = next_response.take() {
r
} else {
let mut req = http_client.get(&url);
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
match req.send().await {
Ok(r) => r,
Err(err) => {
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
eprintln!(
"[audio] ranged reconnect failed after {} attempts: {}",
reconnects, err
);
break 'outer;
}
reconnects += 1;
tokio::time::sleep(Duration::from_millis(200)).await;
continue 'outer;
}
}
};
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
eprintln!(
"[audio] ranged reconnect returned {}, expected 206",
response.status()
);
break 'outer;
}
if downloaded == 0 && !response.status().is_success() {
eprintln!("[audio] ranged HTTP {}", response.status());
break 'outer;
}
let mut byte_stream = response.bytes_stream();
while let Some(chunk) = byte_stream.next().await {
if gen_arc.load(Ordering::SeqCst) != gen {
done.store(true, Ordering::SeqCst);
return;
}
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
eprintln!(
"[audio] ranged dl error after {} reconnects: {}",
reconnects, e
);
break 'outer;
}
reconnects += 1;
eprintln!(
"[audio] ranged dl error (attempt {}/{}): {} — reconnecting",
reconnects, TRACK_STREAM_MAX_RECONNECTS, e
);
next_response = None;
continue 'outer;
}
};
reconnects = 0;
let writable = total_size.saturating_sub(downloaded);
if writable == 0 {
break;
}
let n = chunk.len().min(writable);
{
let mut b = buf.lock().unwrap();
b[downloaded..downloaded + n].copy_from_slice(&chunk[..n]);
}
downloaded += n;
downloaded_to.store(downloaded, Ordering::SeqCst);
#[cfg(debug_assertions)]
{
let mb = downloaded / (1024 * 1024);
if mb >= next_progress_mb {
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
eprintln!(
"[stream] dl progress: {} MB / {} MB ({}%)",
mb,
total_size / (1024 * 1024),
pct
);
next_progress_mb = mb + 1;
}
}
if downloaded >= total_size {
break;
}
}
// Stream ended cleanly (or hit total_size).
break 'outer;
}
done.store(true, Ordering::SeqCst);
#[cfg(debug_assertions)]
eprintln!(
"[stream] dl done: {} / {} bytes in {:.2}s ({} reconnects)",
downloaded,
total_size,
dl_started.elapsed().as_secs_f64(),
reconnects
);
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
let data = buf.lock().unwrap().clone();
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
#[cfg(debug_assertions)]
eprintln!("[stream] promoted to stream_completed_cache for replay");
}
}
fn content_type_to_hint(ct: &str) -> Option<String> {
let ct = ct.to_ascii_lowercase();
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
else if ct.contains("aac") || ct.contains("aacp") { Some("aac".into()) }
else if ct.contains("ogg") { Some("ogg".into()) }
else if ct.contains("flac") { Some("flac".into()) }
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
else if ct.contains("opus") { Some("opus".into()) }
else { None }
}
@@ -1050,6 +1351,38 @@ impl MediaSource for SizedCursorSource {
// Implements Iterator<Item = i16> + Source — identical interface to
// rodio::Decoder, so the rest of the source chain is unchanged.
/// Dev-build only: format codec parameters into a human-readable line for
/// the terminal so you can verify whether playback is genuinely lossless.
#[cfg(debug_assertions)]
fn log_codec_resolution(
tag: &str,
params: &symphonia::core::codecs::CodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_codec(params.codec)
.map(|d| d.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
|| matches!(
codec_name,
"flac" | "alac" | "wavpack" | "monkeys-audio" | "tta" | "shorten"
);
let kind = if lossless { "LOSSLESS" } else { "lossy" };
eprintln!(
"[stream] {tag}: codec={codec_name} ({kind}) {bits} {rate} {ch} container={}",
container_hint.unwrap_or("?")
);
}
/// Max retries for IO/packet-read errors (fatal — network drop, truncated file).
const DECODE_MAX_RETRIES: usize = 3;
/// Max *consecutive* DecodeErrors before giving up on a file.
@@ -1138,6 +1471,9 @@ impl SizedDecoder {
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
#[cfg(debug_assertions)]
log_codec_resolution("bytes", &track.codec_params, format_hint);
let mut decoder = psysonic_codec_registry()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| {
@@ -1222,6 +1558,8 @@ impl SizedDecoder {
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
#[cfg(debug_assertions)]
log_codec_resolution(source_tag, &track.codec_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
@@ -1687,6 +2025,11 @@ pub struct AudioEngine {
/// Last fully downloaded manual-stream track bytes (same playback identity),
/// used to recover seek/replay without waiting for network again.
pub(crate) stream_completed_cache: Arc<Mutex<Option<PreloadedTrack>>>,
/// True when the currently playing source supports seeking (in-memory bytes
/// or `RangedHttpSource`); false for the legacy non-seekable streaming
/// fallback (`AudioStreamReader`). `audio_seek` rejects with a "not
/// seekable" error when false so the frontend restart-fallback can engage.
pub(crate) current_is_seekable: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
@@ -1945,6 +2288,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)),
current_is_seekable: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
fading_out_sink: Arc::new(Mutex::new(None)),
@@ -2197,13 +2541,36 @@ pub async fn audio_play(
old.stop();
}
// Extract format hint from URL for better symphonia probing.
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
// Extract format hint from URL for better symphonia probing. Strip the
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
// don't latch onto random query-param substrings; only accept short
// alphanumeric tails that look like an actual audio extension.
let format_hint = url
.split('?').next()
.and_then(|path| path.rsplit('.').next())
.filter(|ext| {
(1..=5).contains(&ext.len())
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
&& matches!(
ext.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4"
| "aac" | "wav" | "wave" | "ape" | "wv" | "webm" | "mka"
)
})
.map(|s| s.to_lowercase());
enum PlayInput {
Bytes(Vec<u8>),
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
/// `LocalFileSource` for `psysonic-local://` files. Goes through
/// `build_streaming_source` (no iTunSMPB scan, since we don't have the
/// bytes in memory; chained-track gapless trim still applies via the
/// re-played `Bytes` path on the next start).
SeekableMedia {
reader: Box<dyn MediaSource>,
format_hint: Option<String>,
tag: &'static str,
},
Streaming {
reader: AudioStreamReader,
format_hint: Option<String>,
@@ -2212,8 +2579,11 @@ pub async fn audio_play(
// Data source selection:
// 1) Reused chained bytes (manual skip onto pre-chained track)
// 2) Manual uncached remote start -> stream immediately (no full prefetch)
// 3) Existing byte path (preloaded/offline/full HTTP fetch)
// 2) `psysonic-local://` (offline / hot cache hit) → LocalFileSource (instant)
// 3) Manual uncached remote start:
// a) Server supports Range + Content-Length → seekable RangedHttpSource
// b) Server does not → legacy non-seekable AudioStreamReader fallback
// 4) Preloaded/streamed-cache hit → in-memory bytes via fetch_data
let play_input = if let Some(d) = reuse_chained_bytes {
PlayInput::Bytes(d)
} else {
@@ -2231,7 +2601,27 @@ pub async fn audio_play(
};
let is_local = url.starts_with("psysonic-local://");
if manual && !stream_cache_hit && !preloaded_hit && !is_local {
if is_local && !stream_cache_hit && !preloaded_hit {
let path = url.strip_prefix("psysonic-local://").unwrap();
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let local_hint = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
#[cfg(debug_assertions)]
eprintln!(
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
len / 1024,
local_hint
);
let reader = LocalFileSource { file, len };
PlayInput::SeekableMedia {
reader: Box::new(reader),
format_hint: local_hint,
tag: "local-file",
}
} else if manual && !stream_cache_hit && !preloaded_hit && !is_local {
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
@@ -2251,41 +2641,88 @@ pub async fn audio_play(
.unwrap_or(""),
).or_else(|| format_hint.clone());
let buffer_cap = response
.content_length()
.map(|n| n as usize)
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
let rb = HeapRb::<u8>::new(buffer_cap);
let (prod, cons) = rb.split();
let done = Arc::new(AtomicBool::new(false));
tokio::spawn(track_download_task(
gen,
state.generation.clone(),
state.http_client.clone(),
url.clone(),
response,
prod,
done.clone(),
state.stream_completed_cache.clone(),
));
let supports_range = response.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
// Track streaming has no reconnect producer; keep an empty channel.
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
let reader = AudioStreamReader {
cons,
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now()
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
gen_arc: state.generation.clone(),
gen,
source_tag: "track-stream",
eof_when_empty: Some(done),
pos: 0,
};
PlayInput::Streaming {
reader,
format_hint: stream_hint,
if let (true, Some(total)) = (supports_range, total_size) {
let total_usize = total as usize;
#[cfg(debug_assertions)]
eprintln!(
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
tokio::spawn(ranged_download_task(
gen,
state.generation.clone(),
state.http_client.clone(),
url.clone(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
));
let reader = RangedHttpSource {
buf,
downloaded_to,
total_size: total,
pos: 0,
done,
gen_arc: state.generation.clone(),
gen,
};
PlayInput::SeekableMedia {
reader: Box::new(reader),
format_hint: stream_hint,
tag: "ranged-stream",
}
} else {
#[cfg(debug_assertions)]
eprintln!(
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
supports_range, total_size
);
let buffer_cap = total_size
.map(|n| n as usize)
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
let rb = HeapRb::<u8>::new(buffer_cap);
let (prod, cons) = rb.split();
let done = Arc::new(AtomicBool::new(false));
tokio::spawn(track_download_task(
gen,
state.generation.clone(),
state.http_client.clone(),
url.clone(),
response,
prod,
done.clone(),
state.stream_completed_cache.clone(),
));
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
let reader = AudioStreamReader {
cons,
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now()
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
gen_arc: state.generation.clone(),
gen,
source_tag: "track-stream",
eof_when_empty: Some(done),
pos: 0,
};
PlayInput::Streaming {
reader,
format_hint: stream_hint,
}
}
} else {
let data = fetch_data(&url, &state, gen, &app).await?;
@@ -2337,6 +2774,7 @@ pub async fn audio_play(
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let mut new_is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
@@ -2351,7 +2789,27 @@ pub async fn audio_play(
format_hint.as_deref(),
hi_res_enabled,
),
PlayInput::SeekableMedia { reader, format_hint, tag } => {
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, format_hint.as_deref(), tag)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
)
}
PlayInput::Streaming { reader, format_hint } => {
new_is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref(), "track-stream")
})
@@ -2371,6 +2829,7 @@ pub async fn audio_play(
)
}
}.map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
state.current_is_seekable.store(new_is_seekable, Ordering::SeqCst);
let source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
@@ -2969,6 +3428,17 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
}
// Reject seek up-front for non-seekable streaming sources so the frontend's
// restart-fallback engages instead of rolling the dice on the format reader
// (which can consume the ring buffer to EOF for forward seeks → next song).
if !state.current_is_seekable.load(Ordering::SeqCst) {
#[cfg(debug_assertions)]
eprintln!("[seek] rejected → not-seekable source (legacy stream)");
return Err("source is not seekable".into());
}
#[cfg(debug_assertions)]
eprintln!("[seek] target={:.2}s", seconds);
// Seeking back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
+832 -84
View File
File diff suppressed because it is too large Load Diff
+55 -1
View File
@@ -1,17 +1,71 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq)]
enum GpuVendor {
Nvidia,
Intel,
Amd,
}
#[cfg(target_os = "linux")]
fn detect_gpu_vendor() -> Option<GpuVendor> {
use std::fs;
if fs::metadata("/proc/driver/nvidia/version").is_ok() {
return Some(GpuVendor::Nvidia);
}
// Iterate every `/sys/class/drm/card*` — hybrid laptops expose multiple
// cards, and some systems have no `card0` at all.
let entries = fs::read_dir("/sys/class/drm").ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with("card") || name.contains('-') {
continue;
}
let Ok(vendor_id) = fs::read_to_string(entry.path().join("device/vendor")) else {
continue;
};
match vendor_id.trim() {
"0x10de" => return Some(GpuVendor::Nvidia),
"0x8086" => return Some(GpuVendor::Intel),
"0x1002" => return Some(GpuVendor::Amd),
_ => {}
}
}
None
}
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
//
// Safety: set_var modifies global process state. These calls are safe here
// because we're in main() before the Tauri runtime starts — no other threads
// exist yet. If this code moves to lazy init or a plugin context, it would
// need synchronization or marking as unsafe (Rust 2024+).
#[cfg(target_os = "linux")]
unsafe {
{
if std::env::var("GDK_BACKEND").is_err() {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
// NVIDIA proprietary adds a small but reproducible overhead on the
// DMA-BUF renderer path (blind A/B confirmed on NVIDIA + proprietary).
// Unknown GPUs keep the WebKitGTK default — VMs, ARM SBCs and anything
// exotic should not be regressed by a guess.
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
&& matches!(detect_gpu_vendor(), Some(GpuVendor::Nvidia))
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
let args: Vec<String> = std::env::args().collect();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.22",
"version": "1.43.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+93 -12
View File
@@ -38,6 +38,9 @@ import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser';
import DeviceSync from './pages/DeviceSync';
import NowPlayingPage from './pages/NowPlaying';
import WhatsNew from './pages/WhatsNew';
import MiniPlayer from './components/MiniPlayer';
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
@@ -51,10 +54,9 @@ import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
import { IS_LINUX } from './utils/platform';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
@@ -110,6 +112,11 @@ function AppShell() {
}).catch(() => {});
}, []);
useEffect(() => {
const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown';
document.documentElement.setAttribute('data-platform', platform);
}, []);
useEffect(() => {
const win = getCurrentWindow();
// Check initial state (e.g. app launched maximised / already fullscreen).
@@ -143,6 +150,18 @@ function AppShell() {
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
// Mini player → main: route requests dispatched as `psy:navigate`
// CustomEvents from the bridge land here so React Router can take over.
useEffect(() => {
const onPsyNavigate = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.to) navigate(detail.to);
};
window.addEventListener('psy:navigate', onPsyNavigate);
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
}, [navigate]);
// Sync custom titlebar preference with native decorations on Linux
// On tiling WMs decorations are always off (no native title bar to replace).
@@ -227,14 +246,9 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
// Post-update changelog is now surfaced via a dismissible banner in the
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
// modal takeover on startup.
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
@@ -325,11 +339,25 @@ function AppShell() {
};
}, []);
// Pause CSS animations when the window is minimized / hidden.
// WebView2 on Windows keeps compositing infinite-loop animations (mesh-aura,
// portrait-drift, eq-bounce, …) even when the app is minimized, which shows
// up as steady GPU usage. The CSS rule `html[data-app-hidden="true"]` in
// components.css pauses all running animations while this flag is set.
useEffect(() => {
const update = () => {
document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false';
};
document.addEventListener('visibilitychange', update);
update();
return () => document.removeEventListener('visibilitychange', update);
}, []);
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
className="app-shell"
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
@@ -386,6 +414,7 @@ function AppShell() {
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/whats-new" element={<WhatsNew />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
@@ -420,7 +449,6 @@ function AppShell() {
<DownloadFolderModal />
<TooltipPortal />
<AppUpdater />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
@@ -790,6 +818,9 @@ function TauriEventBridge() {
win.isFullscreen().then(fs => win.setFullscreen(!fs));
break;
}
case 'open-mini-player':
invoke('open_mini_player').catch(() => {});
break;
}
};
window.addEventListener('keydown', onKey);
@@ -930,6 +961,12 @@ export default function App() {
const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
// Mini Player window: detected via Tauri window label. Rendered without
// router / sidebar / full audio listeners — it just listens for state + sends
// control events. Label is read synchronously from a global set in main.tsx
// so the initial render picks the right tree.
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
@@ -938,6 +975,50 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// Main window only: push playback state to mini window + handle control events.
useEffect(() => {
if (isMiniWindow) return;
return initMiniPlayerBridgeOnMain();
}, [isMiniWindow]);
// Main window only: optionally pre-create the mini player webview hidden so
// the first open is instant. Windows already does this unconditionally in
// Rust .setup() as a hang workaround — skip here to avoid double-building.
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
useEffect(() => {
if (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
invoke('preload_mini_player').catch(() => {});
}, [isMiniWindow, preloadMiniPlayer]);
// Mini window only: re-hydrate persisted appearance stores when the main
// window writes new values. Both webviews share localStorage (same origin),
// so the `storage` event fires here whenever main mutates a key — but
// Zustand persist only reads localStorage on initial load, hence the
// explicit rehydrate.
useEffect(() => {
if (!isMiniWindow) return;
const onStorage = (e: StorageEvent) => {
if (!e.key) return;
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
else if (e.key === 'psysonic_language' && e.newValue) {
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [isMiniWindow]);
if (isMiniWindow) {
return (
<DragDropProvider>
<MiniPlayer />
<TooltipPortal />
</DragDropProvider>
);
}
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
// because they live in separate grid cells. Document-level zoom is not used
+109
View File
@@ -0,0 +1,109 @@
import { invoke } from '@tauri-apps/api/core';
export interface NdLibrary {
id: number;
name: string;
}
export interface NdUser {
id: string;
userName: string;
name: string;
email: string;
isAdmin: boolean;
libraryIds: number[];
lastLoginAt?: string | null;
lastAccessAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface NdLoginResult {
token: string;
userId: string;
isAdmin: boolean;
}
export async function ndLogin(
serverUrl: string,
username: string,
password: string,
): Promise<NdLoginResult> {
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
}
function extractLibraryIds(o: Record<string, unknown>): number[] {
const libs = o.libraries;
if (!Array.isArray(libs)) return [];
const ids: number[] = [];
for (const l of libs) {
const id = (l as Record<string, unknown>)?.id;
if (typeof id === 'number') ids.push(id);
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
}
return ids;
}
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(u => {
const o = u as Record<string, unknown>;
return {
id: String(o.id ?? ''),
userName: String(o.userName ?? ''),
name: String(o.name ?? ''),
email: String(o.email ?? ''),
isAdmin: !!o.isAdmin,
libraryIds: extractLibraryIds(o),
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
createdAt: o.createdAt as string | undefined,
updatedAt: o.updatedAt as string | undefined,
};
});
}
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
if (!Array.isArray(raw)) return [];
return raw.map(l => {
const o = l as Record<string, unknown>;
const id = typeof o.id === 'number'
? o.id
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
return { id, name: String(o.name ?? '') };
}).filter(l => l.id > 0);
}
export async function ndSetUserLibraries(
serverUrl: string,
token: string,
id: string,
libraryIds: number[],
): Promise<void> {
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
}
export async function ndCreateUser(
serverUrl: string,
token: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<{ id: string }> {
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
const o = (raw as Record<string, unknown> | null) ?? {};
return { id: String(o.id ?? '') };
}
export async function ndUpdateUser(
serverUrl: string,
token: string,
id: string,
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
): Promise<void> {
await invoke('nd_update_user', { serverUrl, token, id, ...data });
}
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
await invoke('nd_delete_user', { serverUrl, token, id });
}
+2
View File
@@ -71,6 +71,8 @@ export interface SubsonicAlbum {
created?: string;
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
userRating?: number;
/** OpenSubsonic: true when the album is tagged as a compilation. */
isCompilation?: boolean;
}
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
+109 -26
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,7 +155,10 @@ 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;
@@ -169,6 +175,31 @@ export default function AppUpdater() {
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
@@ -179,6 +210,8 @@ export default function AppUpdater() {
setDlError('');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { relaunch } = await import('@tauri-apps/plugin-process');
relaunchFnRef.current = relaunch;
const update = await check();
if (!update) {
setDlError(t('common.updaterErrorMsg'));
@@ -196,9 +229,12 @@ export default function AppUpdater() {
setDlProgress({ bytes: downloaded, total });
} else if (event.event === 'Finished') {
setDlState('done');
// downloadAndInstall replaces the .app in place but does not exit
// the running process. Give the user a 3s countdown (with a manual
// "Restart now" button) before auto-relaunch.
startRestartCountdown(3);
}
});
// downloadAndInstall replaces the .app and relaunches automatically on macOS.
} catch (e) {
setDlError(String(e));
setDlState('error');
@@ -316,12 +352,25 @@ export default function AppUpdater() {
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">
<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: 'Downloads, verifies and installs automatically. The app will restart when done.',
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</span>
</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' && (
@@ -338,8 +387,14 @@ export default function AppUpdater() {
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDone', { defaultValue: 'Installed. Restarting…' })}
{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>
)}
@@ -394,27 +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>
{showInstallBtn && dlState === 'idle' && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
{dlState === 'idle' && (
<>
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
)}
</>
)}
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
{dlState === 'done' && useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
)}
{dlState === 'done' && !useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
)}
{dlState === 'error' && (
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
)}
</div>
</div>
+3 -1
View File
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
interface Props {
@@ -9,6 +10,7 @@ interface Props {
}
export default function ArtistCardLocal({ artist }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const coverId = artist.coverArt || artist.id;
// buildCoverArtUrl generates a new crypto salt on every call — must be
@@ -38,7 +40,7 @@ export default function ArtistCardLocal({ artist }: Props) {
<span className="artist-card-name">{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
{t('artists.albumCount', { count: artist.albumCount })}
</span>
)}
</div>
+74
View File
@@ -0,0 +1,74 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
danger,
onConfirm,
onCancel,
}: ConfirmModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onCancel, onConfirm]);
if (!open) return null;
const confirmStyle = danger
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
: undefined;
return createPortal(
<div
className="modal-overlay"
onClick={onCancel}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{message}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
+39 -57
View File
@@ -13,7 +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';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -47,23 +47,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
const [activeIdx, setActiveIdx] = useState(-1);
const activeIdxRef = useRef(-1);
const containerRef = useRef<HTMLDivElement>(null);
const springRef = useRef<SpringScroller | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollerRef = useRef<EaseScroller | 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;
}
containerRef.current = el;
scrollerRef.current?.stop();
scrollerRef.current = el ? new EaseScroller(el) : null;
}, []);
// Reset everything on track change.
@@ -73,7 +68,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
prevWord.current = { line: -1, word: -1 };
activeIdxRef.current = -1;
setActiveIdx(-1);
springRef.current?.jump(0);
scrollerRef.current?.jump(0);
}, [currentTrack?.id]);
// Subscribe to playback time — only triggers React setState when line changes.
@@ -92,13 +87,13 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]);
// Spring-scroll active line to ~35% from the top of the container.
// Ease-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));
if (!el || !box || !scrollerRef.current) return;
scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
}, [activeIdx]);
// Word-sync: imperative DOM updates, zero React re-renders per tick.
@@ -134,8 +129,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
}, [useWords, wordLines]);
const handleUserScroll = useCallback(() => {
// Stop spring animation so it doesn't fight the user's scroll.
springRef.current?.stop();
scrollerRef.current?.stop();
isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
@@ -149,9 +143,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
if (!currentTrack || loading) return null;
const isPlain = !hasSynced && !!plainLyrics;
return (
<div
className="fsa-lyrics-container"
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
@@ -434,40 +430,20 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 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;
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);
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);
return usePlayerStore.subscribe(state => {
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
if (playedRef.current) playedRef.current.style.width = `${p}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(state.progress);
});
}, []);
const handleSeek = useCallback(
@@ -502,8 +478,9 @@ interface FsLyricsMenuProps {
open: boolean;
onClose: () => void;
accentColor: string | null;
triggerRef?: React.RefObject<HTMLElement | null>;
}
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }: FsLyricsMenuProps) {
const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: FsLyricsMenuProps) {
const { t } = useTranslation();
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
@@ -512,13 +489,16 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
const panelRef = useRef<HTMLDivElement>(null);
// Close on click outside the panel or on Escape.
// setTimeout(0) defers listener registration past the current click cycle
// so the button click that opens the panel doesn't immediately close it.
// Ignore clicks on the trigger button so re-clicking it toggles normally
// instead of outside-handler closing + click re-opening.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onMouse = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (triggerRef?.current?.contains(target)) return;
onClose();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
@@ -527,7 +507,7 @@ const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor }:
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose]);
}, [open, onClose, triggerRef]);
if (!open) return null;
@@ -702,6 +682,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity
const [isIdle, setIsIdle] = useState(false);
@@ -838,8 +819,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} />
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
+160 -103
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Filter, X } from 'lucide-react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getGenres } from '../api/subsonic';
@@ -13,8 +14,10 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
const [open, setOpen] = useState(false);
const [genres, setGenres] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -23,124 +26,178 @@ export default function GenreFilterBar({ selected, onSelectionChange }: GenreFil
);
}, []);
// close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// sync open state with selection
useEffect(() => {
if (selected.length > 0) setOpen(true);
}, [selected]);
// Selected on top, then alphabetical (stable for comfortable scanning).
const sortedGenres = useMemo(() => {
const arr = [...genres];
arr.sort((a, b) => {
const sa = selectedSet.has(a) ? 0 : 1;
const sb = selectedSet.has(b) ? 0 : 1;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
return arr;
}, [genres, selectedSet]);
const filteredOptions = genres.filter(
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
);
const filteredGenres = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sortedGenres;
return sortedGenres.filter(g => g.toLowerCase().includes(q));
}, [sortedGenres, search]);
const add = (genre: string) => {
onSelectionChange([...selected, genre]);
setSearch('');
inputRef.current?.focus();
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 280;
const MAX_H = 360;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left, 8),
window.innerWidth - WIDTH - 8,
);
setPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
const remove = (genre: string) => {
onSelectionChange(selected.filter(s => s !== genre));
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const onResize = () => updatePopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!popRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const toggle = (genre: string) => {
if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre));
else onSelectionChange([...selected, genre]);
};
const clear = () => {
onSelectionChange([]);
setSearch('');
setOpen(false);
setDropdownOpen(false);
};
const openFilter = () => {
setOpen(true);
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
};
if (!open) {
return (
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Filter size={14} />
{t('common.filterGenre')}
</button>
);
}
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
// relatedTarget is the next focused element; if it's outside our container, handle close
const next = e.relatedTarget as Node | null;
if (containerRef.current && next && containerRef.current.contains(next)) return;
setTimeout(() => {
if (selected.length === 0) {
setOpen(false);
setSearch('');
setDropdownOpen(false);
} else {
setDropdownOpen(false);
}
}, 150);
};
const count = selected.length;
return (
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<>
<button
ref={triggerRef}
type="button"
className={`btn btn-surface${count > 0 ? ' btn-sort-active' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<Filter size={14} />
{t('common.filterGenre')}
{count > 0 && <span className="genre-filter-count">{count}</span>}
</button>
<div className="genre-filter-tagbox">
{selected.map(g => (
<span key={g} className="genre-filter-chip">
{g}
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
<X size={11} />
</button>
</span>
))}
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="dialog"
>
<div className="genre-filter-popover__search">
<input
ref={inputRef}
type="text"
placeholder={t('common.filterSearchGenres')}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && filteredGenres.length > 0) {
toggle(filteredGenres[0]);
}
}}
/>
</div>
<input
ref={inputRef}
className="genre-filter-input"
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
value={search}
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
remove(selected[selected.length - 1]);
}
}}
/>
{dropdownOpen && filteredOptions.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
{filteredOptions.slice(0, 60).map(g => (
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
{g}
<div className="genre-filter-popover__list">
{filteredGenres.length === 0 ? (
<div className="genre-filter-popover__empty">
{t('common.filterNoGenres')}
</div>
))}
) : (
filteredGenres.map(g => {
const isSel = selectedSet.has(g);
return (
<div
key={g}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => toggle(g)}
role="option"
aria-selected={isSel}
>
<span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{g}
</span>
</div>
);
})
)}
</div>
)}
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
<div className="genre-filter-dropdown" onWheel={e => e.stopPropagation()}>
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
</div>
)}
</div>
{selected.length > 0 && (
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
<X size={13} />
{t('common.filterClear')}
</button>
{count > 0 && (
<div className="genre-filter-popover__footer">
<button
className="btn btn-ghost"
onClick={clear}
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
>
<X size={13} />
{t('common.filterClear')}
</button>
</div>
)}
</div>,
document.body,
)}
</div>
</>
);
}
+8 -13
View File
@@ -6,7 +6,7 @@ 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';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
interface Props {
currentTrack: Track | null;
@@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) {
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const containerRef = useRef<HTMLDivElement | null>(null);
const springRef = useRef<SpringScroller | null>(null);
const scrollerRef = useRef<EaseScroller | 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;
scrollerRef.current?.stop();
scrollerRef.current = el ? new EaseScroller(el) : null;
}, []);
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
const handleUserScroll = useCallback(() => {
springRef.current?.stop();
scrollerRef.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));
if (sidebarLyricsStyle === 'apple' && scrollerRef.current) {
scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35));
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
@@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) {
lineRefs.current = [];
wordRefs.current = [];
prevActive.current = { line: -1, word: -1 };
springRef.current?.jump(0);
scrollerRef.current?.jump(0);
}, [currentTrack?.id]);
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
import { useTranslation } from 'react-i18next';
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
import { star, unstar } from '../api/subsonic';
import type { MiniTrackInfo } from '../utils/miniPlayerBridge';
interface Props {
x: number;
y: number;
track: MiniTrackInfo;
index: number;
onClose: () => void;
}
/**
* Slim queue-item context menu for the mini player. The mini lives in its
* own webview, so all queue mutations forward to the main window via Tauri
* events; only the favorite call hits Subsonic directly because it has no
* cross-window state to keep in sync (next mini:sync from main reflects the
* new starred flag).
*/
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [starred, setStarred] = useState(!!track.starred);
const [pos, setPos] = useState({ left: x, top: y });
// Clamp the menu inside the mini window's viewport (it pops near the
// cursor and would otherwise overflow at the right/bottom edges of the
// small window).
useEffect(() => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = Math.min(x, Math.max(4, vw - r.width - 4));
const top = Math.min(y, Math.max(4, vh - r.height - 4));
setPos({ left, top });
}, [x, y]);
// Dismiss on outside click + Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
const run = (fn: () => void | Promise<void>) => {
Promise.resolve(fn()).finally(onClose);
};
const toggleStar = async () => {
const next = !starred;
setStarred(next);
try {
if (next) await star(track.id, 'song');
else await unstar(track.id, 'song');
} catch {
setStarred(!next);
}
};
return createPortal(
<div
ref={ref}
className="context-menu mini-context-menu"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div
className="context-menu-item"
style={{ color: 'var(--danger)' }}
onClick={() => run(() => emit('mini:remove', { index }))}
>
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
{track.albumId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{track.artistId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
)}
<div className="context-menu-item" onClick={() => run(toggleStar)}>
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
<div className="context-menu-divider" />
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</div>,
document.body,
);
}
+701
View File
@@ -0,0 +1,701 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, ArrowUpToLine } from 'lucide-react';
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
const COLLAPSED_SIZE = { w: 340, h: 260 };
const EXPANDED_SIZE = { w: 340, h: 500 };
// Minimum window dimensions per state. When the queue is open the floor must
// keep at least two queue rows visible; a stricter min would let the user
// collapse the queue area to nothing while it's still toggled on.
const COLLAPSED_MIN = { w: 320, h: 240 };
const EXPANDED_MIN = { w: 320, h: 340 };
// Persist the expanded-window height so reopening the queue restores the
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
function readStoredExpandedHeight(): number {
try {
const raw = localStorage.getItem(EXPANDED_H_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
}
} catch {}
return EXPANDED_SIZE.h;
}
// Persist whether the queue panel was open so the next launch restores
// the same state. Same scope as the height: localStorage of the mini
// webview (shared across mini sessions, separate from the main store).
const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
function readQueueOpen(): boolean {
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
}
function toMini(t: any): MiniTrackInfo {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
year: t.year,
};
}
/**
* Hydrate from the persisted playerStore so initial paint shows real content
* instead of "—" while we wait for the mini:sync event from the main window.
* The persisted state covers the cold-start window (webview boot + bundle).
*/
function initialSnapshot(): MiniSyncPayload {
try {
const s = usePlayerStore.getState();
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
isPlaying: s.isPlaying,
volume: s.volume ?? 1,
gaplessEnabled: false,
crossfadeEnabled: false,
infiniteQueueEnabled: false,
isMobile: false,
};
} catch {
return {
track: null, queue: [], queueIndex: 0, isPlaying: false,
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
infiniteQueueEnabled: false, isMobile: false,
};
}
}
interface ProgressPayload {
current_time: number;
duration: number;
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function MiniPlayer() {
const { t } = useTranslation();
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(() => {
const initial = initialSnapshot();
return initial.track?.duration ?? 0;
});
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const volumeWrapRef = useRef<HTMLDivElement>(null);
// ── PsyDnD reorder ──
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
// on the queue computes a drop indicator, psy-drop emits mini:reorder back
// to main where the source-of-truth store lives.
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const psyDragFromIdxRef = useRef<number | null>(null);
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
const isReorderDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
dropTargetRef.current = null;
setDropTarget(null);
}
}, [isPsyDragging]);
// ── Context menu state ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
// items so the queue keeps its full width.
const recomputeScroll = useCallback(() => {
const el = queueScrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight + 1) {
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
return;
}
const ratio = clientHeight / scrollHeight;
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
const range = clientHeight - thumbH;
const scrollRange = scrollHeight - clientHeight;
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
setScrollMeta({ thumbH, thumbT, visible: true });
}, []);
// Announce to main window that we're mounted; it replies with a snapshot.
// Also re-announce on window focus: on Windows the mini is pre-created at
// app startup so the mount-time emit can race past main's bridge before
// it has attached its listener. Re-emitting on focus means every actual
// open of the mini (user clicks the player-bar icon) triggers a fresh
// sync regardless of startup ordering.
useEffect(() => {
emit('mini:ready', {}).catch(() => {});
const onFocus = () => { emit('mini:ready', {}).catch(() => {}); };
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
// Restore the expanded window size on initial mount when the queue was
// open at the previous app close. Rust always builds the window at the
// collapsed size; without this we'd render queueOpen=true into a 180 px
// window. Brief jump from collapsed to expanded is unavoidable since
// localStorage only lives in JS.
useEffect(() => {
if (!queueOpen) return;
invoke('resize_mini_player', {
width: EXPANDED_SIZE.w,
height: readStoredExpandedHeight(),
minWidth: EXPANDED_MIN.w,
minHeight: EXPANDED_MIN.h,
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-apply pin state on mount and whenever the window regains focus.
// After a Hide → Show cycle (which is what `open_mini_player` does on
// re-toggle) the WM often drops the always-on-top constraint silently;
// re-asserting it here means the user no longer has to click the pin
// button twice to make it stick.
useEffect(() => {
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
const reapply = () => {
if (alwaysOnTop) {
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
}
};
window.addEventListener('focus', reapply);
return () => window.removeEventListener('focus', reapply);
}, [alwaysOnTop]);
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
// Also honour the user-configured 'open-mini-player' shortcut so the
// same chord that opens the mini from main also closes it from here.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
const tag = tgt?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return;
const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player'];
if (matchInAppBinding(e, openMiniBinding)) {
e.preventDefault();
invoke('open_mini_player').catch(() => {});
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
emit('mini:control', 'toggle').catch(() => {});
} else if (e.key === 'ArrowRight') {
emit('mini:control', 'next').catch(() => {});
} else if (e.key === 'ArrowLeft') {
emit('mini:control', 'prev').catch(() => {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Subscribe to state + progress from the main window / Rust.
useEffect(() => {
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
setState(e.payload);
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
});
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration);
});
const unEnded = listen('audio:ended', () => setCurrentTime(0));
return () => {
unSync.then(fn => fn()).catch(() => {});
unProgress.then(fn => fn()).catch(() => {});
unEnded.then(fn => fn()).catch(() => {});
if (ticker.current) window.clearInterval(ticker.current);
};
}, []);
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
const handleVolumeChange = (v: number) => {
const clamped = Math.max(0, Math.min(1, v));
setVolumeState(clamped);
emit('mini:set-volume', { value: clamped }).catch(() => {});
};
const toggleMute = () => {
handleVolumeChange(volume === 0 ? 1 : 0);
};
// Close the volume popover on outside click / Escape.
useEffect(() => {
if (!volumeOpen) return;
const onDown = (e: MouseEvent) => {
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) {
setVolumeOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setVolumeOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [volumeOpen]);
const toggleOnTop = async () => {
const next = !alwaysOnTop;
setAlwaysOnTop(next);
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch {}
};
const showMain = () => invoke('show_main_window').catch(() => {});
const toggleQueue = async () => {
const next = !queueOpen;
// Capture the current expanded height before collapsing so the next
// open restores it. Read window.innerHeight directly — it matches the
// logical inner size that resize_mini_player set previously.
if (!next) {
const h = Math.round(window.innerHeight);
if (h >= EXPANDED_MIN.h) {
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
}
}
setQueueOpen(next);
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
try {
await invoke('resize_mini_player', {
width: targetW,
height: targetH,
minWidth: min.w,
minHeight: min.h,
});
} catch {}
};
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
// Listen for psy-drop on the queue. Only handles `queue_reorder` payloads
// since the mini player has no external drag sources. `queueOpen` must be
// in deps because the wrap (and thus queueScrollRef.current) only mounts
// when the queue is expanded — without it the ref is null on first run
// and the listener never attaches.
useEffect(() => {
if (!queueOpen) return;
const el = queueScrollRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: any = null;
try { parsed = JSON.parse(detail.data); } catch { return; }
const tgt = dropTargetRef.current;
dropTargetRef.current = null;
setDropTarget(null);
if (parsed.type !== 'queue_reorder') return;
const fromIdx = parsed.index as number;
psyDragFromIdxRef.current = null;
const queueLen = usePlayerStore.getState().queue.length || state.queue.length;
const insertIdx = tgt
? (tgt.before ? tgt.idx : tgt.idx + 1)
: queueLen;
if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return;
// Adjust target index if removing the source first shifts later items.
const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
if (fromIdx === adjusted) return;
emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {});
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [queueOpen, state.queue.length]);
// Auto-scroll the current track into view when the queue expands.
useEffect(() => {
if (!queueOpen) return;
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
el?.scrollIntoView({ block: 'nearest' });
}, [queueOpen, state.queueIndex]);
// Recompute overlay-thumb on open, queue mutations, and window resize.
useEffect(() => {
if (!queueOpen) return;
recomputeScroll();
const onResize = () => recomputeScroll();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [queueOpen, state.queue.length, recomputeScroll]);
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player-shell">
<div
className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
{...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
>
{IS_LINUX ? (
<span className="mini-player__titlebar-title" data-tauri-drag-region>
{track?.title ?? 'Psysonic Mini'}
</span>
) : (
// macOS/Windows already render a native titlebar with the window
// title + close button; we just need a flexible spacer so the
// action buttons sit right.
<span className="mini-player__titlebar-spacer" />
)}
<button
type="button"
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
onClick={toggleOnTop}
data-tauri-drag-region="false"
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button
type="button"
className="mini-player__titlebar-btn"
onClick={showMain}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.openMainWindow')}
aria-label={t('miniPlayer.openMainWindow')}
>
<Maximize2 size={13} />
</button>
{/* macOS + Windows already provide Close via the native titlebar
skip the duplicate so the in-app titlebar stays minimal. */}
{IS_LINUX && (
<button
type="button"
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
onClick={closeMini}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.close')}
aria-label={t('miniPlayer.close')}
>
<X size={13} />
</button>
)}
</div>
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__meta">
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player__meta-text" data-tauri-drag-region="false">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
</div>
{track?.artist && (
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
)}
{track?.album && (
<div className="mini-player__album" title={track.album}>{track.album}</div>
)}
{track?.year && (
<div className="mini-player__year">{track.year}</div>
)}
</div>
</div>
<div className="mini-player__toolbar" data-tauri-drag-region="false">
<div className="mini-player__volume-wrap" ref={volumeWrapRef}>
<button
type="button"
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => setVolumeOpen(v => !v)}
onContextMenu={(e) => { e.preventDefault(); toggleMute(); }}
data-tauri-drag-region="false"
data-tooltip={volume === 0 ? t('player.volume') : `${t('player.volume')} ${Math.round(volume * 100)}%`}
aria-label={t('player.volume')}
>
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
{volumeOpen && (
<div className="mini-player__volume-popover" data-tauri-drag-region="false">
<span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
<div
className="mini-player__volume-bar"
role="slider"
aria-label={t('player.volume')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(volume * 100)}
onMouseDown={(e) => {
const target = e.currentTarget;
const setFromY = (clientY: number) => {
const rect = target.getBoundingClientRect();
const ratio = 1 - (clientY - rect.top) / rect.height;
handleVolumeChange(ratio);
};
setFromY(e.clientY);
const onMove = (me: MouseEvent) => setFromY(me.clientY);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
onWheel={(e) => {
e.preventDefault();
handleVolumeChange(volume + (e.deltaY > 0 ? -0.05 : 0.05));
}}
>
<div
className="mini-player__volume-bar-fill"
style={{ height: `${Math.round(volume * 100)}%` }}
/>
</div>
</div>
)}
</div>
<button
type="button"
className="mini-player__tool"
onClick={() => emit('mini:shuffle').catch(() => {})}
disabled={state.queue.length < 2}
data-tauri-drag-region="false"
data-tooltip={t('queue.shuffle')}
aria-label={t('queue.shuffle')}
>
<Shuffle size={13} />
</button>
<span className="mini-player__toolbar-sep" aria-hidden />
<button
type="button"
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<InfinityIcon size={13} />
</button>
<button
type="button"
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
<button
type="button"
className={`mini-player__tool${state.infiniteQueueEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-infinite-queue', { value: !state.infiniteQueueEnabled }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<ArrowUpToLine size={13} />
</button>
<span className="mini-player__toolbar-sep" aria-hidden />
<button
type="button"
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
onClick={toggleQueue}
data-tauri-drag-region="false"
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
>
<ListMusic size={13} />
</button>
</div>
{queueOpen && (
<div
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
onMouseMove={(e) => {
if (!isReorderDrag || !queueScrollRef.current) return;
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
if (e.clientY >= r.top && e.clientY <= r.bottom) {
const before = e.clientY < r.top + r.height / 2;
const idx = parseInt(items[i].dataset.mqIdx!, 10);
const t = { idx, before };
dropTargetRef.current = t;
setDropTarget(t);
return;
}
}
dropTargetRef.current = null;
setDropTarget(null);
}}
>
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
state.queue.map((t, i) => {
let dragStyle: React.CSSProperties = {};
if (isReorderDrag && psyDragFromIdxRef.current === i) {
dragStyle = { opacity: 0.4 };
} else if (isReorderDrag && dropTarget?.idx === i) {
dragStyle = dropTarget.before
? { boxShadow: 'inset 0 2px 0 var(--accent)' }
: { boxShadow: 'inset 0 -2px 0 var(--accent)' };
}
return (
<button
key={`${t.id}-${i}`}
data-mq-idx={i}
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
onClick={() => jumpTo(i)}
onContextMenu={(e) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
// Don't start drag while a click would also be valid —
// the threshold check below upgrades to a drag once
// the pointer leaves the deadband.
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = i;
startDrag(
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
me.clientX,
me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={dragStyle}
>
<span className="mini-queue__num">{i + 1}</span>
<div className="mini-queue__meta">
<div className="mini-queue__title">{t.title}</div>
<div className="mini-queue__artist">{t.artist}</div>
</div>
</button>
);
})
)}
</div>
{scrollMeta.visible && (
<div
className="mini-queue__thumb"
style={{
height: `${scrollMeta.thumbH}px`,
transform: `translateY(${scrollMeta.thumbT}px)`,
}}
/>
)}
</div>
)}
<div className="mini-player__bottom" data-tauri-drag-region="false">
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
<SkipForward size={16} />
</button>
</div>
<div className="mini-player__progress">
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="mini-player__progress-time">{fmt(duration)}</div>
</div>
</div>
{ctxMenu && (
<MiniContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
track={ctxMenu.track}
index={ctxMenu.index}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
</div>
);
}
+90 -4
View File
@@ -2,11 +2,14 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2, ArrowLeftRight,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
@@ -41,11 +44,28 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
return <span className={className} ref={spanRef} />;
});
// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render.
const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
spanRef.current.textContent = `-${formatTime(remaining)}`;
}
};
updateRemaining();
return usePlayerStore.subscribe(updateRemaining);
}, [duration]);
return <span className={className} ref={spanRef} />;
});
export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
const premuteVolumeRef = useRef(1);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
@@ -81,6 +101,40 @@ export default function PlayerBar() {
setUserRatingOverride: s.setUserRatingOverride,
})));
const { lastfmSessionKey } = useAuthStore();
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
useEffect(() => {
if (!floatingPlayerBar) return;
const updatePosition = () => {
const sidebar = document.querySelector('.sidebar') as HTMLElement;
const queue = document.querySelector('.queue-panel') as HTMLElement;
const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0;
const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0;
setFloatingStyle({
left: leftOffset + 24,
right: rightOffset + 24,
width: 'auto',
});
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
const sidebar = document.querySelector('.sidebar');
const queue = document.querySelector('.queue-panel');
if (sidebar) observer.observe(sidebar);
if (queue) observer.observe(queue);
window.addEventListener('resize', updatePosition);
return () => {
observer.disconnect();
window.removeEventListener('resize', updatePosition);
};
}, [floatingPlayerBar]);
const isRadio = !!currentRadio;
@@ -130,8 +184,13 @@ export default function PlayerBar() {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
};
return (
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
const playerBarContent = (
<footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
aria-label={t('player.regionLabel')}
>
{/* Track Info */}
<div className="player-track-info">
@@ -295,7 +354,18 @@ export default function PlayerBar() {
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
<span
className="player-time player-time-toggle"
onClick={() => {
const newVal = !localShowRemaining;
setLocalShowRemaining(newVal);
useThemeStore.getState().setShowRemainingTime(newVal);
}}
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
>
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTime(duration)}
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
</span>
</>
)}
</div>
@@ -310,6 +380,16 @@ export default function PlayerBar() {
<SlidersVertical size={15} />
</button>
{/* Mini Player */}
<button
className="player-btn player-btn-sm"
onClick={() => invoke('open_mini_player').catch(() => {})}
aria-label="Mini Player"
data-tooltip="Mini Player"
>
<PictureInPicture2 size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
@@ -369,4 +449,10 @@ export default function PlayerBar() {
</footer>
);
if (floatingPlayerBar) {
return createPortal(playerBarContent, document.body);
}
return playerBarContent;
}
+50 -22
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
@@ -8,6 +8,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
@@ -267,6 +268,8 @@ export default function QueuePanel() {
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
@@ -451,28 +454,53 @@ export default function QueuePanel() {
})(),
].filter(Boolean) as string[];
const rgParts = formatQueueReplayGainParts(currentTrack, t);
const techLine = [...baseParts, ...rgParts].join(' · ');
if (!techLine && !playbackSource) return null;
const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · ');
if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine;
return (
<div className="queue-current-tech">
{playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
<span className="queue-current-tech-main">{techLine}</span>
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack">
<div className="queue-current-tech-row">
{playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && (
<button
type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
aria-expanded={showRgLine}
aria-label={t('queue.replayGain')}
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
RG
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
</div>
{showRgLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
{' · '}{rgLine}
</span>
)}
</div>
</div>
);
})()}
+7 -1
View File
@@ -15,6 +15,7 @@ import {
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import WhatsNewBanner from './WhatsNewBanner';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
@@ -294,13 +295,18 @@ export default function Sidebar({
)
))}
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
<div className="sidebar-bottom-spacer" />
{/* What's New banner — only visible while the current release hasn't been seen. */}
<WhatsNewBanner collapsed={isCollapsed} />
{/* Now Playing — fixed, always visible */}
<NavLink
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
style={{ marginTop: 'auto' }}
>
<span className="nav-np-icon-wrap">
<AudioLines size={isCollapsed ? 22 : 18} />
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowDownUp, Check } from 'lucide-react';
export interface SortOption<V extends string> {
value: V;
label: string;
}
interface Props<V extends string> {
value: V;
options: SortOption<V>[];
onChange: (value: V) => void;
ariaLabel?: string;
}
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const current = options.find(o => o.value === value);
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = Math.max(rect.width, 220);
const MAX_H = 300;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left, 8),
window.innerWidth - WIDTH - 8,
);
setPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
}, [open]);
useEffect(() => {
if (!open) return;
const onResize = () => updatePopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!popRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
className="btn btn-surface"
onClick={() => setOpen(v => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<ArrowDownUp size={14} />
{current?.label ?? value}
</button>
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="listbox"
>
<div className="genre-filter-popover__list">
{options.map(opt => {
const isSel = opt.value === value;
return (
<div
key={opt.value}
className={`genre-filter-popover__option${isSel ? ' genre-filter-popover__option--selected' : ''}`}
onClick={() => { onChange(opt.value); setOpen(false); }}
role="option"
aria-selected={isSel}
>
<span className="genre-filter-popover__check">
{isSel && <Check size={12} strokeWidth={3} />}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{opt.label}
</span>
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
}
-2
View File
@@ -10,8 +10,6 @@ export default function TitleBar() {
return (
<div className="titlebar" data-tauri-drag-region>
<span className="titlebar-title" data-tauri-drag-region>Psysonic</span>
<div className="titlebar-track" data-tauri-drag-region>
{currentTrack && (
<>
+21 -6
View File
@@ -832,6 +832,9 @@ export default function WaveformSeek({ trackId }: Props) {
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
if (isDragging.current) return;
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
if (!ANIMATED_STYLES.has(styleRef.current)) {
@@ -890,15 +893,23 @@ export default function WaveformSeek({ trackId }: Props) {
trackIdRef.current = trackId;
const seekRef = useRef(seek);
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
// Seek to a 01 fraction: draw immediately for 1:1 responsiveness, then
// let the store + Rust catch up asynchronously.
const seekToFraction = (fraction: number) => {
// Preview a 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => {
progressRef.current = fraction;
pendingSeekRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current);
}
};
const commitSeek = () => {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
seekRef.current(fraction);
};
@@ -907,10 +918,14 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas || !trackIdRef.current) return;
const rect = canvas.getBoundingClientRect();
seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
previewFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); };
const onUp = () => { isDragging.current = false; };
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
commitSeek();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
@@ -935,7 +950,7 @@ export default function WaveformSeek({ trackId }: Props) {
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
previewFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
interface Props {
collapsed?: boolean;
}
/**
* Sidebar pill shown above Now Playing while the current app version hasn't
* been opened yet. Clicking opens the What's New page; X dismisses.
*
* Uses a fixed neutral palette so it looks identical across every theme.
*/
export default function WhatsNewBanner({ collapsed }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const lastSeen = useAuthStore(s => s.lastSeenChangelogVersion);
const setLastSeen = useAuthStore(s => s.setLastSeenChangelogVersion);
const showOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
if (!showOnUpdate || lastSeen === version) return null;
const dismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setLastSeen(version);
};
const open = () => navigate('/whats-new');
if (collapsed) {
return (
<button
type="button"
className="whats-new-banner whats-new-banner--collapsed"
onClick={open}
data-tooltip={t('whatsNew.bannerCollapsed', { version })}
data-tooltip-pos="bottom"
>
<Sparkles size={16} />
</button>
);
}
return (
<button type="button" className="whats-new-banner" onClick={open}>
<Sparkles size={14} className="whats-new-banner__icon" />
<span className="whats-new-banner__text">
<span className="whats-new-banner__title">{t('whatsNew.bannerTitle')}</span>
<span className="whats-new-banner__version">v{version}</span>
</span>
<span
className="whats-new-banner__dismiss"
role="button"
aria-label={t('whatsNew.dismiss')}
onClick={dismiss}
>
<X size={12} />
</span>
</button>
);
}
+167
View File
@@ -0,0 +1,167 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CalendarRange, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}
const CURRENT_YEAR = new Date().getFullYear();
export default function YearFilterButton({ from, to, onChange }: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLInputElement>(null);
const fromNum = parseInt(from, 10);
const toNum = parseInt(to, 10);
const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const updatePopStyle = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 260;
const MAX_H = 200;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left, 8),
window.innerWidth - WIDTH - 8,
);
setPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
setTimeout(() => fromRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return;
const onResize = () => updatePopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!popRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const clear = () => {
onChange('', '');
};
return (
<>
<button
ref={triggerRef}
type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<CalendarRange size={14} />
{active ? `${fromNum}${toNum}` : t('albums.yearFilterLabel')}
</button>
{open && createPortal(
<div
ref={popRef}
className="genre-filter-popover"
style={popStyle}
role="dialog"
>
<div style={{ padding: '0.75rem 0.75rem 0.5rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t('albums.yearFrom')}
</label>
<input
ref={fromRef}
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder="1970"
value={from}
onChange={e => onChange(e.target.value, to)}
/>
</div>
<span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}></span>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t('albums.yearTo')}
</label>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={String(CURRENT_YEAR)}
value={to}
onChange={e => onChange(from, e.target.value)}
/>
</div>
</div>
</div>
{active && (
<div className="genre-filter-popover__footer">
<button
className="btn btn-ghost"
onClick={clear}
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
>
<X size={13} />
{t('albums.yearFilterClear')}
</button>
</div>
)}
</div>,
document.body,
)}
</>
);
}
+100 -9
View File
@@ -218,6 +218,9 @@ export const deTranslation = {
clearArtistFilter: 'Künstlerfilter zurücksetzen',
noFilterResults: 'Keine Ergebnisse mit den ausgewählten Filtern.',
allArtists: 'Alle Künstler',
topArtists: 'Top-Künstler nach Favoriten',
topArtistsSongCount_one: '{{count}} Song',
topArtistsSongCount_other: '{{count}} Songs',
},
randomLanding: {
title: 'Mix erstellen',
@@ -287,6 +290,12 @@ export const deTranslation = {
yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen',
yearFilterLabel: 'Jahr',
compilationLabel: 'Sampler',
compilationOnly: 'Nur Sampler',
compilationHide: 'Ohne Sampler',
compilationTooltipAll: 'Alle Alben · klicken: nur Sampler',
compilationTooltipOnly: 'Nur Sampler · klicken: Sampler ausblenden',
compilationTooltipHide: 'Sampler ausgeblendet · klicken: alle zeigen',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
@@ -375,6 +384,7 @@ export const deTranslation = {
downloadZip: 'Download (ZIP)',
back: 'Zurück',
cancel: 'Abbrechen',
close: 'Schließen',
save: 'Speichern',
delete: 'Löschen',
use: 'Verwenden',
@@ -407,6 +417,15 @@ export const deTranslation = {
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
updaterAurHint: 'Update über AUR installieren:',
updaterErrorMsg: 'Download fehlgeschlagen',
updaterInstallNow: 'Jetzt installieren',
updaterMacReadyTitle: 'Bereit zur Installation',
updaterMacReady: 'Das Update wird heruntergeladen, verifiziert und direkt übernommen — keine DMG nötig. Die App startet anschließend automatisch neu.',
updaterTrustNotarized: 'Von Apple beglaubigt',
updaterTrustSignature: 'Signatur verifiziert',
updaterMacDoneTitle: 'Update installiert',
updaterRestartingIn: 'Neustart in {{n}}s …',
updaterRestarting: 'Neustart läuft …',
updaterRestartNow: 'Jetzt neu starten',
updaterRetryBtn: 'Erneut versuchen',
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
durationMinutesOnly: '{{minutes}} Min.',
@@ -448,6 +467,41 @@ export const deTranslation = {
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Benutzerverwaltung',
userMgmtDesc: 'Benutzer auf diesem Server verwalten. Erfordert Admin-Rechte.',
userMgmtNoAdmin: 'Du brauchst Admin-Rechte, um Benutzer auf diesem Server zu verwalten.',
userMgmtLoadError: 'Benutzer konnten nicht geladen werden.',
userMgmtEmpty: 'Keine Benutzer gefunden.',
userMgmtYouBadge: 'Du',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Benutzer hinzufügen',
userMgmtAddUserTitle: 'Neuen Benutzer anlegen',
userMgmtEditUserTitle: 'Benutzer bearbeiten',
userMgmtUsername: 'Benutzername',
userMgmtName: 'Anzeigename',
userMgmtEmail: 'E-Mail',
userMgmtPassword: 'Passwort',
userMgmtPasswordEditHint: 'Neues Passwort eingeben, um es zu ändern.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Bibliotheken',
userMgmtLibrariesAdminHint: 'Admin-Benutzer haben automatisch Zugriff auf alle Bibliotheken.',
userMgmtLibrariesEmpty: 'Keine Bibliotheken auf diesem Server vorhanden.',
userMgmtLibrariesValidation: 'Mindestens eine Bibliothek auswählen.',
userMgmtLibrariesUpdateError: 'Benutzer gespeichert, aber Bibliothekszuweisung fehlgeschlagen',
userMgmtNoLibraries: 'Keine Bibliotheken zugewiesen',
userMgmtNeverSeen: 'Nie',
userMgmtSave: 'Speichern',
userMgmtCancel: 'Abbrechen',
userMgmtDelete: 'Löschen',
userMgmtEdit: 'Bearbeiten',
userMgmtConfirmDelete: 'Benutzer „{{username}}" löschen? Das kann nicht rückgängig gemacht werden.',
userMgmtCreateError: 'Benutzer konnte nicht angelegt werden.',
userMgmtUpdateError: 'Benutzer konnte nicht aktualisiert werden.',
userMgmtDeleteError: 'Benutzer konnte nicht gelöscht werden.',
userMgmtCreated: 'Benutzer angelegt.',
userMgmtUpdated: 'Benutzer aktualisiert.',
userMgmtDeleted: 'Benutzer gelöscht.',
userMgmtValidationMissing: 'Benutzername, Anzeigename und Passwort sind erforderlich.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Aktivieren, wenn dieser Server das <pluginLink>AudioMuse-AI-Navidrome-Plugin</pluginLink> nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
@@ -505,7 +559,6 @@ export const deTranslation = {
offlineDirClear: 'Auf Standard zurücksetzen',
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
hotCacheTitle: 'Hot-Playback-Cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Lädt kommende Warteschlangen-Titel vor und behält zuletzt gespielte. Bei aktivierter Option: Speicherplatz und Netzwerk.',
hotCacheDirDefault: 'Standard (App-Daten)',
hotCacheDirChange: 'Ordner ändern',
@@ -534,6 +587,8 @@ export const deTranslation = {
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
preloadMiniPlayer: 'Mini-Player vorladen',
preloadMiniPlayerDesc: 'Baut das Mini-Player-Fenster beim App-Start im Hintergrund auf, damit es beim ersten Öffnen sofort Inhalt zeigt. Kostet etwas mehr RAM.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
useCustomTitlebar: 'Eigene Titelleiste',
@@ -580,11 +635,13 @@ export const deTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
aboutReleaseNotesLabel: 'Release Notes',
aboutReleaseNotesLink: 'Neuigkeiten dieser Version öffnen',
aboutContributorsLabel: 'Mitwirkende',
aboutSpecialThanksLabel: 'Besonderer Dank',
changelog: 'Changelog',
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
showChangelogOnUpdateDesc: 'Zeigt automatisch die Neuerungen an, wenn eine neue Version zum ersten Mal gestartet wird.',
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Filter-Keywords',
randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).',
@@ -604,6 +661,7 @@ export const deTranslation = {
randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.',
tabInput: 'Eingabe',
tabServer: 'Server',
tabUsers: 'Benutzer',
shortcutsReset: 'Auf Standard zurücksetzen',
shortcutListening: 'Taste drücken…',
shortcutUnbound: '—',
@@ -621,6 +679,7 @@ export const deTranslation = {
shortcutOpenFolderBrowser: '{{folderBrowser}} öffnen',
shortcutFullscreenPlayer: 'Vollbild-Player',
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
shortcutOpenMiniPlayer: 'Mini-Player öffnen',
tabSystem: 'System',
tabGeneral: 'Allgemein',
ratingsSectionTitle: 'Bewertungen',
@@ -712,6 +771,8 @@ export const deTranslation = {
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
showBitrate: 'Bitrate anzeigen',
showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen',
floatingPlayerBar: 'Schwebende Player-Leiste',
floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen',
uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom',
},
@@ -720,6 +781,14 @@ export const deTranslation = {
dontShowAgain: 'Nicht mehr anzeigen',
close: 'Verstanden',
},
whatsNew: {
title: 'Was ist neu',
empty: 'Für diese Version liegt noch kein Changelog-Eintrag vor.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Neu in v{{version}}',
dismiss: 'Ausblenden',
close: 'Schließen',
},
help: {
title: 'Hilfe',
s1: 'Erste Schritte',
@@ -875,6 +944,7 @@ export const deTranslation = {
trackPlural: 'Titel',
showRemaining: 'Restzeit anzeigen',
showTotal: 'Gesamtzeit anzeigen',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Peak {{pk}}',
@@ -882,6 +952,15 @@ export const deTranslation = {
sourceHot: 'Wiedergabe aus dem Cache',
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
},
miniPlayer: {
showQueue: 'Warteschlange einblenden',
hideQueue: 'Warteschlange ausblenden',
pinOnTop: 'Im Vordergrund halten',
pinOff: 'Vordergrund deaktivieren',
openMainWindow: 'Hauptfenster öffnen',
close: 'Schließen',
emptyQueue: 'Die Warteschlange ist leer',
},
statistics: {
title: 'Statistiken',
recentlyPlayed: 'Zuletzt gehört',
@@ -956,6 +1035,8 @@ export const deTranslation = {
lyricsSourceLrclib: 'Quelle: LRCLIB',
lyricsSourceNetease: 'Quelle: Netease',
lyricsSourceLyricsplus: 'Quelle: YouLyPlus',
showDuration: 'Dauer anzeigen',
showRemainingTime: 'Verbleibende Zeit anzeigen',
},
songInfo: {
title: 'Song-Infos',
@@ -1135,9 +1216,24 @@ export const deTranslation = {
free: 'frei',
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
chooseFolder: 'Auswählen…',
filenameTemplate: 'Dateinamen-Vorlage',
targetDevice: 'Zielgerät',
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
schemaLabel: 'Benennungsschema',
schemaHint: 'Festes Schema für zuverlässige Synchronisation über verschiedene Betriebssysteme hinweg. Playlists werden als .m3u8-Dateien geschrieben und referenzieren die Album-Tracks — keine Duplikate auf dem Gerät.',
migrateButton: 'Bestehende Dateien re-organisieren…',
migrateTooltip: 'Vorhandene Dateien auf dem Gerät nach dem neuen Schema umbenennen (basierend auf der alten Namensvorlage).',
migrateTitle: 'Bestehende Dateien re-organisieren',
migrateLoading: 'Analysiere vorhandene Dateien…',
migrateNothingToDo: 'Alle vorhandenen Dateien entsprechen bereits dem neuen Schema — nichts zu tun.',
migrateNoTemplate: 'Keine alte Namensvorlage auf dem Gerät gefunden. Migration ist nur möglich, wenn der Stick mit einer Psysonic-Version synchronisiert wurde, die konfigurierbare Vorlagen unterstützte.',
migrateFilesToRename: 'Dateien werden umbenannt',
migrateUnchanged: '{{n}} Dateien liegen bereits am korrekten Pfad',
migrateCollisions: '{{n}} Dateien können nicht automatisch umbenannt werden (mehrere Tracks zeigen auf dasselbe Ziel). Sie bleiben unverändert — beim nächsten Sync werden sie neu heruntergeladen.',
migratePreviewNote: 'Alte Vorlage: {{tpl}}',
migrateExecuting: 'Dateien werden umbenannt…',
migrateSuccess: '{{n}} Dateien erfolgreich umbenannt',
migrateFailed: '{{n}} Umbenennungen fehlgeschlagen',
migrateShowErrors: 'Fehler anzeigen',
migrateStart: 'Umbenennen starten',
onDevice: 'Auf dem Gerät',
addSources: 'Hinzufügen…',
colName: 'Name',
@@ -1158,11 +1254,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.',
+100 -9
View File
@@ -219,6 +219,9 @@ export const enTranslation = {
clearArtistFilter: 'Clear artist filter',
noFilterResults: 'No results with selected filters.',
allArtists: 'All Artists',
topArtists: 'Top Artists by Favorites',
topArtistsSongCount_one: '{{count}} song',
topArtistsSongCount_other: '{{count}} songs',
},
randomLanding: {
title: 'Build a Mix',
@@ -288,6 +291,12 @@ export const enTranslation = {
yearTo: 'To',
yearFilterClear: 'Clear year filter',
yearFilterLabel: 'Year',
compilationLabel: 'Compilations',
compilationOnly: 'Only compilations',
compilationHide: 'Hide compilations',
compilationTooltipAll: 'All albums · click: only compilations',
compilationTooltipOnly: 'Only compilations · click: hide compilations',
compilationTooltipHide: 'Compilations hidden · click: show all',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
@@ -376,6 +385,7 @@ export const enTranslation = {
downloadZip: 'Download (ZIP)',
back: 'Back',
cancel: 'Cancel',
close: 'Close',
save: 'Save',
delete: 'Delete',
use: 'Use',
@@ -409,6 +419,15 @@ export const enTranslation = {
updaterAurHint: 'Install the update via AUR:',
updaterErrorMsg: 'Download failed',
updaterRetryBtn: 'Retry',
updaterInstallNow: 'Install now',
updaterMacReadyTitle: 'Ready to install',
updaterMacReady: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
updaterTrustNotarized: 'Notarized by Apple',
updaterTrustSignature: 'Signature verified',
updaterMacDoneTitle: 'Update installed',
updaterRestartingIn: 'Restarting in {{n}}s…',
updaterRestarting: 'Restarting…',
updaterRestartNow: 'Restart now',
durationHoursMinutes: '{{hours}}h {{minutes}}m',
durationMinutesOnly: '{{minutes}}m',
updaterOpenGitHub: 'Open on GitHub',
@@ -450,6 +469,41 @@ export const enTranslation = {
testBtn: 'Test Connection',
testingBtn: 'Testing…',
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'User Management',
userMgmtDesc: 'Manage users on this server. Requires admin privileges.',
userMgmtNoAdmin: 'You need admin privileges to manage users on this server.',
userMgmtLoadError: 'Failed to load users.',
userMgmtEmpty: 'No users found.',
userMgmtYouBadge: 'You',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Add User',
userMgmtAddUserTitle: 'Add New User',
userMgmtEditUserTitle: 'Edit User',
userMgmtUsername: 'Username',
userMgmtName: 'Display Name',
userMgmtEmail: 'Email',
userMgmtPassword: 'Password',
userMgmtPasswordEditHint: 'Enter a new password to update it.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Libraries',
userMgmtLibrariesAdminHint: 'Admin users automatically have access to all libraries.',
userMgmtLibrariesEmpty: 'No libraries available on this server.',
userMgmtLibrariesValidation: 'Select at least one library.',
userMgmtLibrariesUpdateError: 'User saved, but library assignment failed',
userMgmtNoLibraries: 'No libraries assigned',
userMgmtNeverSeen: 'Never',
userMgmtSave: 'Save',
userMgmtCancel: 'Cancel',
userMgmtDelete: 'Delete',
userMgmtEdit: 'Edit',
userMgmtConfirmDelete: 'Delete user "{{username}}"? This cannot be undone.',
userMgmtCreateError: 'Failed to create user.',
userMgmtUpdateError: 'Failed to update user.',
userMgmtDeleteError: 'Failed to delete user.',
userMgmtCreated: 'User created.',
userMgmtUpdated: 'User updated.',
userMgmtDeleted: 'User deleted.',
userMgmtValidationMissing: 'Username, display name and password are required.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
@@ -507,7 +561,6 @@ export const enTranslation = {
offlineDirClear: 'Reset to Default',
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
hotCacheTitle: 'Hot playback cache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Preloads upcoming queue tracks and keeps previous ones. When enabled, uses disk space and network.',
hotCacheDirDefault: 'Default (App Data)',
hotCacheDirChange: 'Change folder',
@@ -536,6 +589,8 @@ export const enTranslation = {
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
preloadMiniPlayer: 'Preload mini player',
preloadMiniPlayerDesc: 'Build the mini player window in the background at app start so it shows content instantly on first open. Uses a little extra memory.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
useCustomTitlebar: 'Custom title bar',
@@ -582,11 +637,13 @@ export const enTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
aboutReleaseNotesLabel: 'Release notes',
aboutReleaseNotesLink: "Open this version's what's-new",
aboutContributorsLabel: 'Contributors',
aboutSpecialThanksLabel: 'Special Thanks',
changelog: 'Changelog',
showChangelogOnUpdate: "Show 'What's New' on update",
showChangelogOnUpdateDesc: "Automatically show what's new when a new version is launched for the first time.",
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Filter Keywords',
randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).',
@@ -606,6 +663,7 @@ export const enTranslation = {
randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.',
tabInput: 'Input',
tabServer: 'Server',
tabUsers: 'Users',
tabSystem: 'System',
tabGeneral: 'General',
ratingsSectionTitle: 'Ratings',
@@ -646,6 +704,7 @@ export const enTranslation = {
shortcutOpenFolderBrowser: 'Open {{folderBrowser}}',
shortcutFullscreenPlayer: 'Fullscreen player',
shortcutNativeFullscreen: 'Native fullscreen',
shortcutOpenMiniPlayer: 'Open mini player',
playbackTitle: 'Playback',
replayGain: 'Replay Gain',
replayGainDesc: 'Normalize track volume using ReplayGain metadata',
@@ -714,6 +773,8 @@ export const enTranslation = {
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
showBitrate: 'Show Bitrate',
showBitrateSub: 'Display audio bitrate in track listings',
floatingPlayerBar: 'Floating Player Bar',
floatingPlayerBarSub: 'Keep the player bar floating above content',
uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom',
},
@@ -722,6 +783,14 @@ export const enTranslation = {
dontShowAgain: "Don't show again",
close: 'Got it',
},
whatsNew: {
title: "What's New",
empty: 'No changelog entry for this version yet.',
bannerTitle: 'Changelog',
bannerCollapsed: "What's new in v{{version}}",
dismiss: 'Dismiss',
close: 'Close',
},
help: {
title: 'Help',
s1: 'Getting Started',
@@ -877,6 +946,7 @@ export const enTranslation = {
trackPlural: 'tracks',
showRemaining: 'Show remaining time',
showTotal: 'Show total time',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Peak {{pk}}',
@@ -884,6 +954,15 @@ export const enTranslation = {
sourceHot: 'Playing from cache',
sourceStream: 'Playing from network stream',
},
miniPlayer: {
showQueue: 'Show queue',
hideQueue: 'Hide queue',
pinOnTop: 'Pin on top',
pinOff: 'Unpin',
openMainWindow: 'Open main window',
close: 'Close',
emptyQueue: 'Queue is empty',
},
statistics: {
title: 'Statistics',
recentlyPlayed: 'Recently Played',
@@ -958,6 +1037,8 @@ export const enTranslation = {
lyricsSourceLrclib: 'Source: LRCLIB',
lyricsSourceNetease: 'Source: Netease',
lyricsSourceLyricsplus: 'Source: YouLyPlus',
showDuration: 'Show duration',
showRemainingTime: 'Show remaining time',
},
songInfo: {
title: 'Song Info',
@@ -1137,9 +1218,24 @@ export const enTranslation = {
free: 'free',
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
chooseFolder: 'Choose…',
filenameTemplate: 'Filename Template',
targetDevice: 'Target Device',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
schemaLabel: 'Naming scheme',
schemaHint: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
migrateButton: 'Reorganize existing files…',
migrateTooltip: 'Rename existing files on the device into the new scheme (from the old filename template).',
migrateTitle: 'Reorganize existing files',
migrateLoading: 'Analyzing existing files…',
migrateNothingToDo: 'All existing files already match the new scheme — nothing to do.',
migrateNoTemplate: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.',
migrateFilesToRename: 'files will be renamed',
migrateUnchanged: '{{n}} files are already at the correct path',
migrateCollisions: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
migratePreviewNote: 'Old template: {{tpl}}',
migrateExecuting: 'Renaming files…',
migrateSuccess: '{{n}} files renamed successfully',
migrateFailed: '{{n}} renames failed',
migrateShowErrors: 'Show errors',
migrateStart: 'Start renaming',
onDevice: 'Device Manager',
addSources: 'Add…',
colName: 'Name',
@@ -1161,11 +1257,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.',
+73 -2
View File
@@ -219,6 +219,9 @@ export const esTranslation = {
clearArtistFilter: 'Limpiar filtro de artista',
noFilterResults: 'No hay resultados con los filtros seleccionados.',
allArtists: 'Todos los Artistas',
topArtists: 'Artistas favoritos principales',
topArtistsSongCount_one: '{{count}} canción',
topArtistsSongCount_other: '{{count}} canciones',
},
randomLanding: {
title: 'Crear Mezcla',
@@ -288,6 +291,12 @@ export const esTranslation = {
yearTo: 'Hasta',
yearFilterClear: 'Limpiar filtro de año',
yearFilterLabel: 'Año',
compilationLabel: 'Recopilatorios',
compilationOnly: 'Solo recopilatorios',
compilationHide: 'Ocultar recopilatorios',
compilationTooltipAll: 'Todos los álbumes · clic: solo recopilatorios',
compilationTooltipOnly: 'Solo recopilatorios · clic: ocultar recopilatorios',
compilationTooltipHide: 'Recopilatorios ocultos · clic: mostrar todo',
select: 'Selección múltiple',
startSelect: 'Activar selección múltiple',
cancelSelect: 'Cancelar',
@@ -451,6 +460,41 @@ export const esTranslation = {
testBtn: 'Probar Conexión',
testingBtn: 'Probando…',
serverCompatible: 'Compatible con: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Gestión de usuarios',
userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.',
userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.',
userMgmtLoadError: 'No se pudieron cargar los usuarios.',
userMgmtEmpty: 'No se encontraron usuarios.',
userMgmtYouBadge: 'Tú',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Añadir usuario',
userMgmtAddUserTitle: 'Nuevo usuario',
userMgmtEditUserTitle: 'Editar usuario',
userMgmtUsername: 'Nombre de usuario',
userMgmtName: 'Nombre visible',
userMgmtEmail: 'Correo electrónico',
userMgmtPassword: 'Contraseña',
userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Bibliotecas',
userMgmtLibrariesAdminHint: 'Los usuarios admin tienen acceso automáticamente a todas las bibliotecas.',
userMgmtLibrariesEmpty: 'No hay bibliotecas disponibles en este servidor.',
userMgmtLibrariesValidation: 'Selecciona al menos una biblioteca.',
userMgmtLibrariesUpdateError: 'Usuario guardado, pero la asignación de bibliotecas falló',
userMgmtNoLibraries: 'Sin bibliotecas asignadas',
userMgmtNeverSeen: 'Nunca',
userMgmtSave: 'Guardar',
userMgmtCancel: 'Cancelar',
userMgmtDelete: 'Eliminar',
userMgmtEdit: 'Editar',
userMgmtConfirmDelete: '¿Eliminar el usuario «{{username}}»? Esta acción no se puede deshacer.',
userMgmtCreateError: 'No se pudo crear el usuario.',
userMgmtUpdateError: 'No se pudo actualizar el usuario.',
userMgmtDeleteError: 'No se pudo eliminar el usuario.',
userMgmtCreated: 'Usuario creado.',
userMgmtUpdated: 'Usuario actualizado.',
userMgmtDeleted: 'Usuario eliminado.',
userMgmtValidationMissing: 'Se requieren nombre de usuario, nombre visible y contraseña.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Activa si este servidor tiene el plugin <pluginLink>AudioMuse-AI Navidrome</pluginLink> configurado. Habilita Mezcla Instantánea desde pistas y usa artistas similares del servidor en lugar de Last.fm en páginas de artistas.',
@@ -508,7 +552,6 @@ export const esTranslation = {
offlineDirClear: 'Restablecer a Predeterminado',
offlineDirHint: 'Las nuevas descargas usarán esta ubicación. Las descargas existentes permanecen en su ruta original.',
hotCacheTitle: 'Caché de reproducción activa',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Precarga las siguientes pistas en cola y mantiene las anteriores. Cuando está activado, usa espacio en disco y red.',
hotCacheDirDefault: 'Predeterminado (Datos de App)',
hotCacheDirChange: 'Cambiar carpeta',
@@ -537,6 +580,8 @@ export const esTranslation = {
showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.',
minimizeToTray: 'Minimizar a Bandeja',
minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.',
preloadMiniPlayer: 'Precargar mini reproductor',
preloadMiniPlayerDesc: 'Crea la ventana del mini reproductor en segundo plano al iniciar la aplicación para que muestre contenido al instante la primera vez que se abre. Consume un poco más de memoria.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
useCustomTitlebar: 'Barra de título personalizada',
@@ -583,11 +628,13 @@ export const esTranslation = {
aboutVersion: 'Versión',
aboutBuiltWith: 'Construido con Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Desarrollado con el apoyo de Claude Code by Anthropic',
aboutReleaseNotesLabel: 'Notas de la versión',
aboutReleaseNotesLink: 'Abrir las novedades de esta versión',
aboutContributorsLabel: 'Contribuidores',
aboutSpecialThanksLabel: 'Agradecimientos Especiales',
changelog: 'Registro de Cambios',
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
showChangelogOnUpdateDesc: "Muestra automáticamente las novedades cuando se lanza una nueva versión por primera vez.",
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
randomMixTitle: 'Mezcla Aleatoria',
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
@@ -607,6 +654,7 @@ export const esTranslation = {
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
tabInput: 'Entrada',
tabServer: 'Servidor',
tabUsers: 'Usuarios',
tabSystem: 'Sistema',
tabGeneral: 'General',
ratingsSectionTitle: 'Calificaciones',
@@ -647,6 +695,7 @@ export const esTranslation = {
shortcutOpenFolderBrowser: 'Abrir {{folderBrowser}}',
shortcutFullscreenPlayer: 'Reproductor pantalla completa',
shortcutNativeFullscreen: 'Pantalla completa nativa',
shortcutOpenMiniPlayer: 'Abrir mini reproductor',
playbackTitle: 'Reproducción',
replayGain: 'Replay Gain',
replayGainDesc: 'Normalizar volumen de pistas usando metadatos ReplayGain',
@@ -715,6 +764,8 @@ export const esTranslation = {
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
showBitrate: 'Mostrar Bitrate',
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
floatingPlayerBar: 'Barra del Reproductor Flotante',
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
uiScaleTitle: 'Escala de Interfaz',
uiScaleLabel: 'Zoom',
},
@@ -723,6 +774,14 @@ export const esTranslation = {
dontShowAgain: 'No mostrar de nuevo',
close: 'Entendido',
},
whatsNew: {
title: 'Novedades',
empty: 'Aún no hay entrada de changelog para esta versión.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Novedades en v{{version}}',
dismiss: 'Ocultar',
close: 'Cerrar',
},
help: {
title: 'Ayuda',
s1: 'Primeros Pasos',
@@ -878,6 +937,7 @@ export const esTranslation = {
trackPlural: 'pistas',
showRemaining: 'Mostrar tiempo restante',
showTotal: 'Mostrar tiempo total',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Pico {{pk}}',
@@ -885,6 +945,15 @@ export const esTranslation = {
sourceHot: 'Reproducción desde la caché',
sourceStream: 'Reproducción desde la transmisión en red',
},
miniPlayer: {
showQueue: 'Mostrar cola',
hideQueue: 'Ocultar cola',
pinOnTop: 'Mantener encima',
pinOff: 'Desfijar',
openMainWindow: 'Abrir ventana principal',
close: 'Cerrar',
emptyQueue: 'La cola está vacía',
},
statistics: {
title: 'Estadísticas',
recentlyPlayed: 'Reproducidos Recientemente',
@@ -959,6 +1028,8 @@ export const esTranslation = {
lyricsSourceLrclib: 'Fuente: LRCLIB',
lyricsSourceNetease: 'Fuente: Netease',
lyricsSourceLyricsplus: 'Fuente: YouLyPlus',
showDuration: 'Mostrar duración',
showRemainingTime: 'Mostrar tiempo restante',
},
songInfo: {
title: 'Información de Canción',
+73 -2
View File
@@ -218,6 +218,9 @@ export const frTranslation = {
clearArtistFilter: 'Effacer le filtre artiste',
noFilterResults: 'Aucun résultat avec les filtres sélectionnés.',
allArtists: 'Tous les artistes',
topArtists: 'Artistes favoris principaux',
topArtistsSongCount_one: '{{count}} morceau',
topArtistsSongCount_other: '{{count}} morceaux',
},
randomLanding: {
title: 'Créer un mix',
@@ -287,6 +290,12 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
compilationLabel: 'Compilations',
compilationOnly: 'Uniquement compilations',
compilationHide: 'Masquer compilations',
compilationTooltipAll: 'Tous les albums · clic : uniquement compilations',
compilationTooltipOnly: 'Uniquement compilations · clic : masquer compilations',
compilationTooltipHide: 'Compilations masquées · clic : tout afficher',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
@@ -448,6 +457,41 @@ export const frTranslation = {
testBtn: 'Tester la connexion',
testingBtn: 'Test en cours…',
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Gestion des utilisateurs',
userMgmtDesc: 'Gérer les utilisateurs sur ce serveur. Nécessite des privilèges administrateur.',
userMgmtNoAdmin: 'Vous devez être administrateur pour gérer les utilisateurs sur ce serveur.',
userMgmtLoadError: 'Impossible de charger les utilisateurs.',
userMgmtEmpty: 'Aucun utilisateur trouvé.',
userMgmtYouBadge: 'Vous',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Ajouter un utilisateur',
userMgmtAddUserTitle: 'Nouvel utilisateur',
userMgmtEditUserTitle: 'Modifier lutilisateur',
userMgmtUsername: 'Nom dutilisateur',
userMgmtName: 'Nom affiché',
userMgmtEmail: 'E-mail',
userMgmtPassword: 'Mot de passe',
userMgmtPasswordEditHint: 'Saisir un nouveau mot de passe pour le modifier.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Bibliothèques',
userMgmtLibrariesAdminHint: 'Les utilisateurs admin ont automatiquement accès à toutes les bibliothèques.',
userMgmtLibrariesEmpty: 'Aucune bibliothèque disponible sur ce serveur.',
userMgmtLibrariesValidation: 'Sélectionnez au moins une bibliothèque.',
userMgmtLibrariesUpdateError: 'Utilisateur enregistré, mais l\u2019attribution des bibliothèques a échoué',
userMgmtNoLibraries: 'Aucune bibliothèque attribuée',
userMgmtNeverSeen: 'Jamais',
userMgmtSave: 'Enregistrer',
userMgmtCancel: 'Annuler',
userMgmtDelete: 'Supprimer',
userMgmtEdit: 'Modifier',
userMgmtConfirmDelete: 'Supprimer lutilisateur « {{username}} » ? Action irréversible.',
userMgmtCreateError: 'Impossible de créer lutilisateur.',
userMgmtUpdateError: 'Impossible de mettre à jour lutilisateur.',
userMgmtDeleteError: 'Impossible de supprimer lutilisateur.',
userMgmtCreated: 'Utilisateur créé.',
userMgmtUpdated: 'Utilisateur mis à jour.',
userMgmtDeleted: 'Utilisateur supprimé.',
userMgmtValidationMissing: 'Nom dutilisateur, nom affiché et mot de passe sont requis.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Activez si ce serveur utilise le <pluginLink>plugin Navidrome AudioMuse-AI</pluginLink>. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
@@ -505,7 +549,6 @@ export const frTranslation = {
offlineDirClear: 'Réinitialiser',
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
hotCacheTitle: 'Cache de lecture à chaud',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Précharge les titres à venir dans la file et conserve les précédents. Si activé : espace disque et réseau.',
hotCacheDirDefault: 'Par défaut (données d\'application)',
hotCacheDirChange: 'Changer le dossier',
@@ -534,6 +577,8 @@ export const frTranslation = {
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
preloadMiniPlayer: 'Précharger le mini-lecteur',
preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.',
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.',
discordRichPresence: 'Discord Rich Presence',
@@ -578,11 +623,13 @@ export const frTranslation = {
aboutVersion: 'Version',
aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic',
aboutReleaseNotesLabel: 'Notes de version',
aboutReleaseNotesLink: 'Ouvrir les nouveautés de cette version',
aboutContributorsLabel: 'Contributeurs',
aboutSpecialThanksLabel: 'Remerciements spéciaux',
changelog: 'Journal des modifications',
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
showChangelogOnUpdateDesc: 'Affiche automatiquement les nouveautés au premier lancement d\'une nouvelle version.',
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
randomMixTitle: 'Mix aléatoire',
randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés',
randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).',
@@ -602,6 +649,7 @@ export const frTranslation = {
randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".',
tabInput: 'Entrée',
tabServer: 'Serveur',
tabUsers: 'Utilisateurs',
shortcutsReset: 'Réinitialiser',
shortcutListening: 'Appuyez sur une touche…',
shortcutUnbound: '—',
@@ -619,6 +667,7 @@ export const frTranslation = {
shortcutOpenFolderBrowser: 'Ouvrir {{folderBrowser}}',
shortcutFullscreenPlayer: 'Lecteur plein écran',
shortcutNativeFullscreen: 'Plein écran natif',
shortcutOpenMiniPlayer: 'Ouvrir le mini-lecteur',
tabSystem: 'Système',
tabGeneral: 'Général',
ratingsSectionTitle: 'Notes',
@@ -710,6 +759,8 @@ export const frTranslation = {
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
showBitrate: 'Afficher le Débit',
showBitrateSub: 'Afficher le débit audio dans les listes de pistes',
floatingPlayerBar: 'Barre de Lecteur Flottante',
floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu',
uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom',
},
@@ -718,6 +769,14 @@ export const frTranslation = {
dontShowAgain: 'Ne plus afficher',
close: 'Compris',
},
whatsNew: {
title: 'Quoi de neuf',
empty: 'Aucune entrée de changelog pour cette version.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nouveau dans v{{version}}',
dismiss: 'Ignorer',
close: 'Fermer',
},
help: {
title: 'Aide',
s1: 'Démarrage',
@@ -873,6 +932,7 @@ export const frTranslation = {
trackPlural: 'pistes',
showRemaining: 'Afficher le temps restant',
showTotal: 'Afficher la durée totale',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Pic {{pk}}',
@@ -880,6 +940,15 @@ export const frTranslation = {
sourceHot: 'Lecture depuis le cache',
sourceStream: 'Lecture depuis le flux réseau',
},
miniPlayer: {
showQueue: 'Afficher la file',
hideQueue: 'Masquer la file',
pinOnTop: 'Toujours visible',
pinOff: 'Désépingler',
openMainWindow: 'Ouvrir la fenêtre principale',
close: 'Fermer',
emptyQueue: 'La file est vide',
},
statistics: {
title: 'Statistiques',
recentlyPlayed: 'Écoutés récemment',
@@ -954,6 +1023,8 @@ export const frTranslation = {
lyricsSourceLrclib: 'Source : LRCLIB',
lyricsSourceNetease: 'Source : Netease',
lyricsSourceLyricsplus: 'Source : YouLyPlus',
showDuration: 'Afficher la durée',
showRemainingTime: 'Afficher le temps restant',
},
songInfo: {
title: 'Infos du morceau',
+73 -2
View File
@@ -218,6 +218,9 @@ export const nbTranslation = {
clearArtistFilter: 'Tøm artistfilter',
noFilterResults: 'Ingen resultater med valgte filtre.',
allArtists: 'Alle artister',
topArtists: 'Toppartister etter favoritter',
topArtistsSongCount_one: '{{count}} sang',
topArtistsSongCount_other: '{{count}} sanger',
},
randomLanding: {
title: 'Lag en miks',
@@ -287,6 +290,12 @@ export const nbTranslation = {
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
compilationLabel: 'Samleplater',
compilationOnly: 'Kun samleplater',
compilationHide: 'Skjul samleplater',
compilationTooltipAll: 'Alle album · klikk: kun samleplater',
compilationTooltipOnly: 'Kun samleplater · klikk: skjul samleplater',
compilationTooltipHide: 'Samleplater skjult · klikk: vis alle',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
@@ -448,6 +457,41 @@ export const nbTranslation = {
testBtn: 'Test tilkobling',
testingBtn: 'Tester…',
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Brukeradministrasjon',
userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.',
userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.',
userMgmtLoadError: 'Kunne ikke laste brukere.',
userMgmtEmpty: 'Ingen brukere funnet.',
userMgmtYouBadge: 'Deg',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Legg til bruker',
userMgmtAddUserTitle: 'Ny bruker',
userMgmtEditUserTitle: 'Rediger bruker',
userMgmtUsername: 'Brukernavn',
userMgmtName: 'Visningsnavn',
userMgmtEmail: 'E-post',
userMgmtPassword: 'Passord',
userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Biblioteker',
userMgmtLibrariesAdminHint: 'Administratorbrukere har automatisk tilgang til alle biblioteker.',
userMgmtLibrariesEmpty: 'Ingen biblioteker tilgjengelig på denne serveren.',
userMgmtLibrariesValidation: 'Velg minst ett bibliotek.',
userMgmtLibrariesUpdateError: 'Brukeren ble lagret, men bibliotekstilordning mislyktes',
userMgmtNoLibraries: 'Ingen biblioteker tilordnet',
userMgmtNeverSeen: 'Aldri',
userMgmtSave: 'Lagre',
userMgmtCancel: 'Avbryt',
userMgmtDelete: 'Slett',
userMgmtEdit: 'Rediger',
userMgmtConfirmDelete: 'Slett brukeren «{{username}}»? Dette kan ikke angres.',
userMgmtCreateError: 'Kunne ikke opprette bruker.',
userMgmtUpdateError: 'Kunne ikke oppdatere bruker.',
userMgmtDeleteError: 'Kunne ikke slette bruker.',
userMgmtCreated: 'Bruker opprettet.',
userMgmtUpdated: 'Bruker oppdatert.',
userMgmtDeleted: 'Bruker slettet.',
userMgmtValidationMissing: 'Brukernavn, visningsnavn og passord er påkrevd.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
@@ -506,7 +550,6 @@ export const nbTranslation = {
offlineDirClear: 'Tilbakestill til standard',
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
hotCacheTitle: 'Varm avspillingsbuffer',
hotCacheAlphaBadge: 'Alfa',
hotCacheDisclaimer: 'Forhåndshenter neste spor i køen og beholder tidligere. Når aktivert: diskplass og nettverk.',
hotCacheDirDefault: 'Standard (App-data)',
hotCacheDirChange: 'Endre mappe',
@@ -533,6 +576,8 @@ export const nbTranslation = {
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
minimizeToTray: 'Minimer til oppgavelinjen',
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
preloadMiniPlayer: 'Forhåndslast miniavspiller',
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
discordRichPresence: 'Discord Rich Presence',
@@ -577,11 +622,13 @@ export const nbTranslation = {
aboutVersion: 'Versjon',
aboutBuiltWith: 'Bygget med Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Utviklet med støtte fra Claude Code laget av Anthropic',
aboutReleaseNotesLabel: 'Versjonsnotater',
aboutReleaseNotesLink: 'Åpne nyhetene for denne versjonen',
aboutContributorsLabel: 'Bidragsytere',
aboutSpecialThanksLabel: 'Spesiell takk',
changelog: 'Endringslogg',
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
showChangelogOnUpdateDesc: "Vis automatisk hva som er nytt når en ny versjon lanseres for første gang.",
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
randomMixTitle: 'Tilfeldig miks',
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
@@ -601,6 +648,7 @@ export const nbTranslation = {
randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier',
tabServer: 'Tjener',
tabUsers: 'Brukere',
tabSystem: 'System',
tabGeneral: 'Generelt',
ratingsSectionTitle: 'Vurderinger',
@@ -641,6 +689,7 @@ export const nbTranslation = {
shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}',
shortcutFullscreenPlayer: 'Fullskjermsspiller',
shortcutNativeFullscreen: 'Naturlig fullskjerm',
shortcutOpenMiniPlayer: 'Åpne minispiller',
playbackTitle: 'Avspilling',
replayGain: 'Replay Gain',
replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata',
@@ -709,6 +758,8 @@ export const nbTranslation = {
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
showBitrate: 'Vis Bitrate',
showBitrateSub: 'Vis audio-bitrate i sporlister',
floatingPlayerBar: 'Flytende Spillerlinje',
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom',
},
@@ -717,6 +768,14 @@ export const nbTranslation = {
dontShowAgain: "Ikke vis igjen",
close: 'Forstått',
},
whatsNew: {
title: 'Nyheter',
empty: 'Ingen endringslogg for denne versjonen ennå.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nyheter i v{{version}}',
dismiss: 'Skjul',
close: 'Lukk',
},
help: {
title: 'Hjelp',
s1: 'Komme i gang',
@@ -872,6 +931,7 @@ export const nbTranslation = {
trackPlural: 'spor',
showRemaining: 'Vis gjenværende tid',
showTotal: 'Vis total tid',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Topp {{pk}}',
@@ -879,6 +939,15 @@ export const nbTranslation = {
sourceHot: 'Spiller fra cache',
sourceStream: 'Spiller fra nettverksstrøm',
},
miniPlayer: {
showQueue: 'Vis kø',
hideQueue: 'Skjul kø',
pinOnTop: 'Hold øverst',
pinOff: 'Løsne',
openMainWindow: 'Åpne hovedvindu',
close: 'Lukk',
emptyQueue: 'Køen er tom',
},
statistics: {
title: 'Statistikk',
recentlyPlayed: 'Nylig spilt',
@@ -953,6 +1022,8 @@ export const nbTranslation = {
lyricsSourceLrclib: 'Kilde: LRCLIB',
lyricsSourceNetease: 'Kilde: Netease',
lyricsSourceLyricsplus: 'Kilde: YouLyPlus',
showDuration: 'Vis varighet',
showRemainingTime: 'Vis gjenværende tid',
},
songInfo: {
title: 'Sanginfo',
+73 -2
View File
@@ -217,6 +217,9 @@ export const nlTranslation = {
clearArtistFilter: 'Artiestfilter wissen',
noFilterResults: 'Geen resultaten met de geselecteerde filters.',
allArtists: 'Alle artiesten',
topArtists: 'Top-artiesten op favorieten',
topArtistsSongCount_one: '{{count}} nummer',
topArtistsSongCount_other: '{{count}} nummers',
},
randomLanding: {
title: 'Mix samenstellen',
@@ -286,6 +289,12 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
compilationLabel: 'Compilaties',
compilationOnly: 'Alleen compilaties',
compilationHide: 'Compilaties verbergen',
compilationTooltipAll: 'Alle albums · klik: alleen compilaties',
compilationTooltipOnly: 'Alleen compilaties · klik: compilaties verbergen',
compilationTooltipHide: 'Compilaties verborgen · klik: toon alles',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
@@ -447,6 +456,41 @@ export const nlTranslation = {
testBtn: 'Verbinding testen',
testingBtn: 'Testen…',
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Gebruikersbeheer',
userMgmtDesc: 'Beheer gebruikers op deze server. Vereist admin-rechten.',
userMgmtNoAdmin: 'Je hebt admin-rechten nodig om gebruikers op deze server te beheren.',
userMgmtLoadError: 'Kon gebruikers niet laden.',
userMgmtEmpty: 'Geen gebruikers gevonden.',
userMgmtYouBadge: 'Jij',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Gebruiker toevoegen',
userMgmtAddUserTitle: 'Nieuwe gebruiker',
userMgmtEditUserTitle: 'Gebruiker bewerken',
userMgmtUsername: 'Gebruikersnaam',
userMgmtName: 'Weergavenaam',
userMgmtEmail: 'E-mail',
userMgmtPassword: 'Wachtwoord',
userMgmtPasswordEditHint: 'Voer een nieuw wachtwoord in om het bij te werken.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Bibliotheken',
userMgmtLibrariesAdminHint: 'Beheerders hebben automatisch toegang tot alle bibliotheken.',
userMgmtLibrariesEmpty: 'Geen bibliotheken beschikbaar op deze server.',
userMgmtLibrariesValidation: 'Selecteer ten minste één bibliotheek.',
userMgmtLibrariesUpdateError: 'Gebruiker opgeslagen, maar bibliotheektoewijzing mislukt',
userMgmtNoLibraries: 'Geen bibliotheken toegewezen',
userMgmtNeverSeen: 'Nooit',
userMgmtSave: 'Opslaan',
userMgmtCancel: 'Annuleren',
userMgmtDelete: 'Verwijderen',
userMgmtEdit: 'Bewerken',
userMgmtConfirmDelete: 'Gebruiker "{{username}}" verwijderen? Dit kan niet ongedaan worden gemaakt.',
userMgmtCreateError: 'Aanmaken van gebruiker mislukt.',
userMgmtUpdateError: 'Bijwerken van gebruiker mislukt.',
userMgmtDeleteError: 'Verwijderen van gebruiker mislukt.',
userMgmtCreated: 'Gebruiker aangemaakt.',
userMgmtUpdated: 'Gebruiker bijgewerkt.',
userMgmtDeleted: 'Gebruiker verwijderd.',
userMgmtValidationMissing: 'Gebruikersnaam, weergavenaam en wachtwoord zijn vereist.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Zet aan als deze server de <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink> gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpaginas.',
@@ -504,7 +548,6 @@ export const nlTranslation = {
offlineDirClear: 'Terugzetten naar standaard',
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
hotCacheTitle: 'Warme afspeelcache',
hotCacheAlphaBadge: 'Alpha',
hotCacheDisclaimer: 'Laadt aankomende wachtrijtracks voor en bewaart eerdere. Indien ingeschakeld: schijfruimte en netwerk.',
hotCacheDirDefault: 'Standaard (app-gegevens)',
hotCacheDirChange: 'Map wijzigen',
@@ -533,6 +576,8 @@ export const nlTranslation = {
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
preloadMiniPlayer: 'Mini-speler vooraf laden',
preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.',
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.',
discordRichPresence: 'Discord Rich Presence',
@@ -577,11 +622,13 @@ export const nlTranslation = {
aboutVersion: 'Versie',
aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic',
aboutReleaseNotesLabel: 'Release-notities',
aboutReleaseNotesLink: 'Nieuws van deze versie openen',
aboutContributorsLabel: 'Bijdragers',
aboutSpecialThanksLabel: 'Speciale dank',
changelog: 'Wijzigingslog',
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
showChangelogOnUpdateDesc: 'Toont automatisch de nieuwigheden bij de eerste start van een nieuwe versie.',
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
randomMixTitle: 'Willekeurige mix',
randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden',
randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).',
@@ -601,6 +648,7 @@ export const nlTranslation = {
randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.',
tabInput: 'Invoer',
tabServer: 'Server',
tabUsers: 'Gebruikers',
shortcutsReset: 'Standaard herstellen',
shortcutListening: 'Druk op een toets…',
shortcutUnbound: '—',
@@ -618,6 +666,7 @@ export const nlTranslation = {
shortcutOpenFolderBrowser: '{{folderBrowser}} openen',
shortcutFullscreenPlayer: 'Volledigschermspeler',
shortcutNativeFullscreen: 'Systeemvolledig scherm',
shortcutOpenMiniPlayer: 'Mini-speler openen',
tabSystem: 'Systeem',
tabGeneral: 'Algemeen',
ratingsSectionTitle: 'Beoordelingen',
@@ -709,6 +758,8 @@ export const nlTranslation = {
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
showBitrate: 'Toon Bitrate',
showBitrateSub: 'Toon audio bitrate in tracklijsten',
floatingPlayerBar: 'Zwevende Spelerbalk',
floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud',
uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom',
},
@@ -717,6 +768,14 @@ export const nlTranslation = {
dontShowAgain: 'Niet meer weergeven',
close: 'Begrepen',
},
whatsNew: {
title: 'Wat is nieuw',
empty: 'Nog geen changelog-item voor deze versie.',
bannerTitle: 'Changelog',
bannerCollapsed: 'Nieuw in v{{version}}',
dismiss: 'Verbergen',
close: 'Sluiten',
},
help: {
title: 'Help',
s1: 'Aan de slag',
@@ -872,6 +931,7 @@ export const nlTranslation = {
trackPlural: 'nummers',
showRemaining: 'Resterende tijd tonen',
showTotal: 'Totale tijd tonen',
replayGain: 'ReplayGain',
rgTrack: 'T {{db}} dB',
rgAlbum: 'A {{db}} dB',
rgPeak: 'Piek {{pk}}',
@@ -879,6 +939,15 @@ export const nlTranslation = {
sourceHot: 'Afspelen vanuit cache',
sourceStream: 'Afspelen vanuit netwerkstream',
},
miniPlayer: {
showQueue: 'Wachtrij tonen',
hideQueue: 'Wachtrij verbergen',
pinOnTop: 'Altijd boven',
pinOff: 'Losmaken',
openMainWindow: 'Hoofdvenster openen',
close: 'Sluiten',
emptyQueue: 'Wachtrij is leeg',
},
statistics: {
title: 'Statistieken',
recentlyPlayed: 'Recent afgespeeld',
@@ -953,6 +1022,8 @@ export const nlTranslation = {
lyricsSourceLrclib: 'Bron: LRCLIB',
lyricsSourceNetease: 'Bron: Netease',
lyricsSourceLyricsplus: 'Bron: YouLyPlus',
showDuration: 'Toon duur',
showRemainingTime: 'Toon resterende tijd',
},
songInfo: {
title: 'Nummerinfo',
+75 -2
View File
@@ -222,6 +222,11 @@ export const ruTranslation = {
clearArtistFilter: 'Сбросить фильтр исполнителя',
noFilterResults: 'Нет результатов с выбранными фильтрами.',
allArtists: 'Все исполнители',
topArtists: 'Топ исполнителей по избранному',
topArtistsSongCount_one: '{{count}} трек',
topArtistsSongCount_few: '{{count}} трека',
topArtistsSongCount_many: '{{count}} треков',
topArtistsSongCount_other: '{{count}} трека',
},
randomLanding: {
title: 'Собрать микс',
@@ -296,6 +301,12 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год',
compilationLabel: 'Сборники',
compilationOnly: 'Только сборники',
compilationHide: 'Скрыть сборники',
compilationTooltipAll: 'Все альбомы · клик: только сборники',
compilationTooltipOnly: 'Только сборники · клик: скрыть сборники',
compilationTooltipHide: 'Сборники скрыты · клик: показать всё',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
@@ -463,6 +474,41 @@ export const ruTranslation = {
testBtn: 'Проверить',
testingBtn: 'Проверка…',
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: 'Управление пользователями',
userMgmtDesc: 'Управляйте пользователями этого сервера. Требуются права администратора.',
userMgmtNoAdmin: 'Для управления пользователями на этом сервере нужны права администратора.',
userMgmtLoadError: 'Не удалось загрузить пользователей.',
userMgmtEmpty: 'Пользователи не найдены.',
userMgmtYouBadge: 'Вы',
userMgmtAdminBadge: 'Админ',
userMgmtAddUser: 'Добавить пользователя',
userMgmtAddUserTitle: 'Новый пользователь',
userMgmtEditUserTitle: 'Изменить пользователя',
userMgmtUsername: 'Имя пользователя',
userMgmtName: 'Отображаемое имя',
userMgmtEmail: 'E-mail',
userMgmtPassword: 'Пароль',
userMgmtPasswordEditHint: 'Введите новый пароль, чтобы изменить его.',
userMgmtRoleAdmin: 'Админ',
userMgmtLibraries: 'Библиотеки',
userMgmtLibrariesAdminHint: 'Администраторы автоматически имеют доступ ко всем библиотекам.',
userMgmtLibrariesEmpty: 'На этом сервере нет доступных библиотек.',
userMgmtLibrariesValidation: 'Выберите хотя бы одну библиотеку.',
userMgmtLibrariesUpdateError: 'Пользователь сохранён, но не удалось назначить библиотеки',
userMgmtNoLibraries: 'Библиотеки не назначены',
userMgmtNeverSeen: 'Никогда',
userMgmtSave: 'Сохранить',
userMgmtCancel: 'Отмена',
userMgmtDelete: 'Удалить',
userMgmtEdit: 'Изменить',
userMgmtConfirmDelete: 'Удалить пользователя «{{username}}»? Действие нельзя отменить.',
userMgmtCreateError: 'Не удалось создать пользователя.',
userMgmtUpdateError: 'Не удалось обновить пользователя.',
userMgmtDeleteError: 'Не удалось удалить пользователя.',
userMgmtCreated: 'Пользователь создан.',
userMgmtUpdated: 'Пользователь обновлён.',
userMgmtDeleted: 'Пользователь удалён.',
userMgmtValidationMissing: 'Требуются имя пользователя, отображаемое имя и пароль.',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Включите, если на этом сервере настроен <pluginLink>плагин AudioMuse-AI для Navidrome</pluginLink>. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
@@ -522,7 +568,6 @@ export const ruTranslation = {
offlineDirClear: 'Сбросить по умолчанию',
offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.',
hotCacheTitle: 'Горячий кэш воспроизведения',
hotCacheAlphaBadge: 'Альфа',
hotCacheDisclaimer: 'Заранее подгружает следующие треки из очереди и сохраняет предыдущие. При включении использует место на диске и сеть.',
hotCacheDirDefault: 'По умолчанию (данные приложения)',
hotCacheDirChange: 'Сменить папку',
@@ -552,6 +597,8 @@ export const ruTranslation = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
preloadMiniPlayer: 'Предзагрузка мини-плеера',
preloadMiniPlayerDesc: 'Создаёт окно мини-плеера в фоне при запуске приложения, чтобы при первом открытии содержимое отображалось мгновенно. Использует немного больше памяти.',
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
@@ -602,11 +649,13 @@ export const ruTranslation = {
aboutVersion: 'Версия',
aboutBuiltWith: 'Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'При поддержке Claude Code (Anthropic)',
aboutReleaseNotesLabel: 'Примечания к выпуску',
aboutReleaseNotesLink: 'Открыть «Что нового» этой версии',
aboutContributorsLabel: 'Участники',
aboutSpecialThanksLabel: 'Особая благодарность',
changelog: 'Список изменений',
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
showChangelogOnUpdateDesc: 'Автоматически при первом запуске новой версии.',
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
randomMixTitle: 'Случайный микс',
randomMixBlacklistTitle: 'Свои слова-фильтры',
randomMixBlacklistDesc:
@@ -627,6 +676,7 @@ export const ruTranslation = {
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод',
tabServer: 'Сервер',
tabUsers: 'Пользователи',
tabSystem: 'Система',
tabGeneral: 'Общие',
ratingsSectionTitle: 'Рейтинги',
@@ -669,6 +719,7 @@ export const ruTranslation = {
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
shortcutFullscreenPlayer: 'Полноэкранный плеер',
shortcutNativeFullscreen: 'Системный полный экран',
shortcutOpenMiniPlayer: 'Открыть мини-плеер',
playbackTitle: 'Воспроизведение',
replayGain: 'Replay Gain',
replayGainDesc: 'Выравнивание громкости по метаданным ReplayGain',
@@ -737,6 +788,8 @@ export const ruTranslation = {
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать Битрейт',
showBitrateSub: 'Отображать битрейт аудио в списках треков',
floatingPlayerBar: 'Плавающая панель плеера',
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
},
@@ -745,6 +798,14 @@ export const ruTranslation = {
dontShowAgain: 'Больше не показывать',
close: 'Понятно',
},
whatsNew: {
title: 'Что нового',
empty: 'Для этой версии пока нет записи в журнале изменений.',
bannerTitle: 'Журнал изменений',
bannerCollapsed: 'Что нового в v{{version}}',
dismiss: 'Скрыть',
close: 'Закрыть',
},
help: {
title: 'Справка',
s1: 'С чего начать',
@@ -923,6 +984,7 @@ export const ruTranslation = {
trackPlural: 'треков',
showRemaining: 'Осталось',
showTotal: 'Всего',
replayGain: 'ReplayGain',
rgTrack: 'Т {{db}} дБ',
rgAlbum: 'А {{db}} дБ',
rgPeak: 'Пик {{pk}}',
@@ -930,6 +992,15 @@ export const ruTranslation = {
sourceHot: 'Играет из кэша',
sourceStream: 'Играет из сетевого потока',
},
miniPlayer: {
showQueue: 'Показать очередь',
hideQueue: 'Скрыть очередь',
pinOnTop: 'Поверх окон',
pinOff: 'Открепить',
openMainWindow: 'Открыть главное окно',
close: 'Закрыть',
emptyQueue: 'Очередь пуста',
},
statistics: {
title: 'Статистика',
recentlyPlayed: 'Недавно проиграно',
@@ -1012,6 +1083,8 @@ export const ruTranslation = {
lyricsNotFound: 'Текст не найден',
lyricsSourceNetease: 'Источник: Netease',
lyricsSourceLyricsplus: 'Источник: YouLyPlus',
showDuration: 'Показать длительность',
showRemainingTime: 'Показать оставшееся время',
},
songInfo: {
title: 'О треке',
+73 -2
View File
@@ -217,6 +217,9 @@ export const zhTranslation = {
clearArtistFilter: '清除艺术家筛选',
noFilterResults: '所选筛选条件下无结果。',
allArtists: '全部艺术家',
topArtists: '按收藏数排行的艺术家',
topArtistsSongCount_one: '{{count}} 首歌曲',
topArtistsSongCount_other: '{{count}} 首歌曲',
},
randomLanding: {
title: '创建混音',
@@ -286,6 +289,12 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
compilationLabel: '合辑',
compilationOnly: '仅合辑',
compilationHide: '隐藏合辑',
compilationTooltipAll: '所有专辑 · 点击:仅合辑',
compilationTooltipOnly: '仅合辑 · 点击:隐藏合辑',
compilationTooltipHide: '已隐藏合辑 · 点击:显示全部',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
@@ -443,6 +452,41 @@ export const zhTranslation = {
testBtn: '测试连接',
testingBtn: '正在测试…',
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
userMgmtTitle: '用户管理',
userMgmtDesc: '管理此服务器上的用户。需要管理员权限。',
userMgmtNoAdmin: '需要管理员权限才能管理此服务器上的用户。',
userMgmtLoadError: '加载用户失败。',
userMgmtEmpty: '未找到用户。',
userMgmtYouBadge: '您',
userMgmtAdminBadge: '管理员',
userMgmtAddUser: '添加用户',
userMgmtAddUserTitle: '新建用户',
userMgmtEditUserTitle: '编辑用户',
userMgmtUsername: '用户名',
userMgmtName: '显示名称',
userMgmtEmail: '电子邮箱',
userMgmtPassword: '密码',
userMgmtPasswordEditHint: '输入新密码以更新。',
userMgmtRoleAdmin: '管理员',
userMgmtLibraries: '音乐库',
userMgmtLibrariesAdminHint: '管理员用户自动拥有所有音乐库的访问权限。',
userMgmtLibrariesEmpty: '此服务器上没有可用的音乐库。',
userMgmtLibrariesValidation: '请至少选择一个音乐库。',
userMgmtLibrariesUpdateError: '用户已保存,但音乐库分配失败',
userMgmtNoLibraries: '未分配音乐库',
userMgmtNeverSeen: '从未',
userMgmtSave: '保存',
userMgmtCancel: '取消',
userMgmtDelete: '删除',
userMgmtEdit: '编辑',
userMgmtConfirmDelete: '删除用户 "{{username}}"?此操作无法撤销。',
userMgmtCreateError: '创建用户失败。',
userMgmtUpdateError: '更新用户失败。',
userMgmtDeleteError: '删除用户失败。',
userMgmtCreated: '用户已创建。',
userMgmtUpdated: '用户已更新。',
userMgmtDeleted: '用户已删除。',
userMgmtValidationMissing: '用户名、显示名称和密码均为必填项。',
audiomuseTitle: 'AudioMuse-AINavidrome',
audiomuseDesc:
'若此服务器已配置 <pluginLink>AudioMuse-AI Navidrome 插件</pluginLink>请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
@@ -500,7 +544,6 @@ export const zhTranslation = {
offlineDirClear: '重置为默认',
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
hotCacheTitle: '热播放缓存',
hotCacheAlphaBadge: '预览',
hotCacheDisclaimer: '预加载队列中即将播放的曲目并保留近期已播放的。开启后占用磁盘与网络。',
hotCacheDirDefault: '默认(应用数据)',
hotCacheDirChange: '更改目录',
@@ -529,6 +572,8 @@ export const zhTranslation = {
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
preloadMiniPlayer: '预加载迷你播放器',
preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。',
linuxWebkitSmoothScroll: '滚轮平滑(Linux',
linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。',
discordRichPresence: 'Discord Rich Presence',
@@ -573,11 +618,13 @@ export const zhTranslation = {
aboutVersion: '版本',
aboutBuiltWith: '使用 Tauri · React · TypeScript · Rust/rodio 构建',
aboutAiCredit: '在 Anthropic 的 Claude Code 支持下开发',
aboutReleaseNotesLabel: '版本说明',
aboutReleaseNotesLink: '打开此版本的新功能',
aboutContributorsLabel: '贡献者',
aboutSpecialThanksLabel: '特别感谢',
changelog: '更新日志',
showChangelogOnUpdate: '更新时显示"新功能"',
showChangelogOnUpdateDesc: '新版本首次启动时自动显示更新内容。',
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
randomMixTitle: '随机混音',
randomMixBlacklistTitle: '自定义过滤关键词',
randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。',
@@ -597,6 +644,7 @@ export const zhTranslation = {
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
tabInput: '输入',
tabServer: '服务器',
tabUsers: '用户',
tabSystem: '系统',
tabGeneral: '通用',
ratingsSectionTitle: '评分',
@@ -637,6 +685,7 @@ export const zhTranslation = {
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
shortcutFullscreenPlayer: '全屏播放器',
shortcutNativeFullscreen: '原生全屏',
shortcutOpenMiniPlayer: '打开迷你播放器',
playbackTitle: '播放',
replayGain: '回放增益',
replayGainDesc: '使用 ReplayGain 元数据标准化曲目音量',
@@ -705,6 +754,8 @@ export const zhTranslation = {
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率',
showBitrateSub: '在曲目列表中显示音频比特率',
floatingPlayerBar: '浮动播放栏',
floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
},
@@ -713,6 +764,14 @@ export const zhTranslation = {
dontShowAgain: '不再显示',
close: '知道了',
},
whatsNew: {
title: '新功能',
empty: '此版本暂无更新日志。',
bannerTitle: '更新日志',
bannerCollapsed: 'v{{version}} 新功能',
dismiss: '关闭',
close: '关闭',
},
help: {
title: '帮助',
s1: '入门指南',
@@ -868,6 +927,7 @@ export const zhTranslation = {
trackPlural: '首曲目',
showRemaining: '显示剩余时间',
showTotal: '显示总时间',
replayGain: 'ReplayGain',
rgTrack: '曲目 {{db}} dB',
rgAlbum: '专辑 {{db}} dB',
rgPeak: '峰值 {{pk}}',
@@ -875,6 +935,15 @@ export const zhTranslation = {
sourceHot: '正在从缓存播放',
sourceStream: '正在从网络流播放',
},
miniPlayer: {
showQueue: '显示队列',
hideQueue: '隐藏队列',
pinOnTop: '置顶',
pinOff: '取消置顶',
openMainWindow: '打开主窗口',
close: '关闭',
emptyQueue: '队列为空',
},
statistics: {
title: '统计',
recentlyPlayed: '最近播放',
@@ -949,6 +1018,8 @@ export const zhTranslation = {
lyricsSourceLrclib: '来源:LRCLIB',
lyricsSourceNetease: '来源:网易云',
lyricsSourceLyricsplus: '来源:YouLyPlus',
showDuration: '显示时长',
showRemainingTime: '显示剩余时间',
},
songInfo: {
title: '歌曲信息',
+9
View File
@@ -1,11 +1,20 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { getCurrentWindow } from '@tauri-apps/api/window';
import App from './App';
import './i18n';
import './styles/theme.css';
import './styles/layout.css';
import './styles/components.css';
// Expose the Tauri window label synchronously so App() can pick its root
// component (main app vs mini player) on first render without flicker.
try {
(window as any).__PSY_WINDOW_LABEL__ = getCurrentWindow().label;
} catch {
(window as any).__PSY_WINDOW_LABEL__ = 'main';
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+51 -53
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import YearFilterButton from '../components/YearFilterButton';
import SortDropdown from '../components/SortDropdown';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
@@ -11,12 +13,12 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3 } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide';
const PAGE_SIZE = 30;
const CURRENT_YEAR = new Date().getFullYear();
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
@@ -44,6 +46,7 @@ export default function Albums() {
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const [compFilter, setCompFilter] = useState<CompFilter>('all');
const observerTarget = useRef<HTMLDivElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
@@ -68,9 +71,19 @@ export default function Albums() {
setSelectedIds(new Set());
};
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const visibleAlbums = useMemo(() => {
if (compFilter === 'all') return albums;
if (compFilter === 'only') return albums.filter(a => a.isCompilation);
return albums.filter(a => !a.isCompilation);
}, [albums, compFilter]);
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const cycleCompFilter = () => {
setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all');
};
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
@@ -179,8 +192,6 @@ export default function Albums() {
return () => observer.disconnect();
}, [loadMore]);
const clearYear = () => { setYearFrom(''); setYearTo(''); };
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
@@ -188,7 +199,7 @@ export default function Albums() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
@@ -208,54 +219,41 @@ export default function Albums() {
</>
) : (
<>
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
onClick={() => setSort(o.value)}
style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
{o.label}
</button>
))}
{!yearActive && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={setSort}
/>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 14, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
/>
<span style={{ fontSize: 14, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
>
<X size={14} />
</button>
)}
</div>
<YearFilterButton
from={yearFrom}
to={yearTo}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<Disc3 size={14} />
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</button>
</>
)}
@@ -279,7 +277,7 @@ export default function Albums() {
) : (
<>
<div className="album-grid-wrap">
{albums.map(a => (
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
+29 -2
View File
@@ -344,6 +344,33 @@ export default function ArtistDetail() {
}
};
const playTopSongWithContinuation = async (startIndex: number) => {
if (!artist || albums.length === 0) return;
setPlayAllLoading(true);
try {
// Get all artist tracks ordered by album and track number
const allTracks = await fetchAllTracks();
// Top songs from clicked index onward
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
// Track IDs for deduplication
const topSongIds = new Set(topSongs.map(s => s.id));
// Filter remaining tracks to exclude top songs (prevent duplicates)
const remainingTracks = allTracks.filter(t => !topSongIds.has(t.id));
// Build queue: remaining top songs + rest of artist catalog
const queue = [...topTracksFromIndex, ...remainingTracks];
if (queue.length > 0) {
playTrack(queue[0], queue);
}
} finally {
setPlayAllLoading(false);
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
@@ -597,14 +624,14 @@ export default function ArtistDetail() {
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, topSongs.map(songToTrack));
playTopSongWithContinuation(idx);
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTopSongWithContinuation(idx); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
+68 -66
View File
@@ -172,77 +172,79 @@ export default function Artists() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
<div className="page-sticky-header">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '2rem' }}>
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && (
+376 -152
View File
@@ -17,6 +17,7 @@ import {
SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
} from '../api/subsonic';
import { showToast } from '../utils/toast';
import { IS_WINDOWS } from '../utils/platform';
type SourceTab = 'playlists' | 'albums' | 'artists';
@@ -24,6 +25,40 @@ type SourceTab = 'playlists' | 'albums' | 'artists';
function uuid(): string { return crypto.randomUUID(); }
// Same sanitize rules the Rust side uses (`sanitize_path_component`): strip
// Windows-illegal chars and control chars, trim leading/trailing dots + spaces.
// Kept in JS only for the migration flow — computes the *old* path under a
// user-supplied template so we can diff against the current files on disk.
function sanitizeComponent(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
}
interface OldTemplateTrack {
artist: string;
album: string;
title: string;
trackNumber?: number;
discNumber?: number;
year?: number;
suffix: string;
}
/** Renders a track's path under a legacy (user-configurable) template. Used only
* for the migration preview the live sync flow goes through Rust's fixed
* `build_track_path`. */
function applyLegacyTemplate(template: string, track: OldTemplateTrack): string {
const relative = template
.replace(/\{artist\}/g, sanitizeComponent(track.artist))
.replace(/\{album\}/g, sanitizeComponent(track.album))
.replace(/\{title\}/g, sanitizeComponent(track.title))
.replace(/\{track_number\}/g, track.trackNumber != null ? String(track.trackNumber).padStart(2, '0') : '')
.replace(/\{disc_number\}/g, track.discNumber != null ? String(track.discNumber) : '')
.replace(/\{year\}/g, track.year != null ? String(track.year) : '');
const withExt = `${relative}.${track.suffix}`;
return IS_WINDOWS ? withExt.replace(/\//g, '\\') : withExt;
}
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
@@ -33,16 +68,31 @@ async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicS
return all;
}
function trackToSyncInfo(track: SubsonicSong, url: string) {
/** Tracks that came from `calculate_sync_payload` may carry embedded playlist
* context so the follow-up `sync_batch_to_device` call knows to place them
* under `Playlists/{Name}/` instead of the album tree. */
type SyncTrackMaybePlaylist = SubsonicSong & { _playlistName?: string; _playlistIndex?: number };
function trackToSyncInfo(
track: SyncTrackMaybePlaylist,
url: string,
playlistCtx?: { name: string; index: number },
) {
// Fall back to track artist when the file has no albumArtist tag — not every
// library is tagged with it. Treat empty strings as missing (some Subsonic
// servers return "" rather than omitting the field).
const albumArtist = (track.albumArtist?.trim() || track.artist?.trim() || '');
return {
id: track.id, url,
suffix: track.suffix ?? 'mp3',
artist: track.artist ?? '',
albumArtist,
album: track.album ?? '',
title: track.title ?? '',
trackNumber: track.track,
discNumber: track.discNumber,
year: track.year,
duration: track.duration,
playlistName: playlistCtx?.name ?? track._playlistName,
playlistIndex: playlistCtx?.index ?? track._playlistIndex,
};
}
@@ -71,14 +121,13 @@ export default function DeviceSync() {
const { t } = useTranslation();
const targetDir = useDeviceSyncStore(s => s.targetDir);
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
const sources = useDeviceSyncStore(s => s.sources);
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion);
const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths);
const scanning = useDeviceSyncStore(s => s.scanning);
const {
setTargetDir, setFilenameTemplate, addSource, removeSource,
setTargetDir, addSource, removeSource,
clearSources, toggleChecked, setCheckedIds, markForDeletion,
unmarkDeletion, removeSources, setDeviceFilePaths, setScanning,
} = useDeviceSyncStore.getState();
@@ -103,9 +152,6 @@ export default function DeviceSync() {
// Map source IDs → computed device paths (for status derivation)
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
// Template stored in the manifest — may differ from local filenameTemplate when reading a
// manifest written on another OS. Used only for status checks, not for new syncs.
const [deviceManifestTemplate, setDeviceManifestTemplate] = useState<string | null>(null);
// ─── Removable drive detection ──────────────────────────────────────────
const [drives, setDrives] = useState<RemovableDrive[]>([]);
@@ -115,6 +161,15 @@ export default function DeviceSync() {
const [preSyncLoading, setPreSyncLoading] = useState(false);
const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
// ─── Migration (rename existing files into the fixed scheme) ────────────
type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
const [migrationPairs, setMigrationPairs] = useState<{ old: string; new: string }[]>([]);
const [migrationCollisions, setMigrationCollisions] = useState<{ old: string; new: string }[]>([]);
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
const [migrationResult, setMigrationResult] = useState<{ ok: number; failed: number; errors: string[] } | null>(null);
const refreshDrives = useCallback(async () => {
setDrivesLoading(true);
try {
@@ -170,13 +225,12 @@ export default function DeviceSync() {
useEffect(() => {
if (!targetDir || !driveDetected || manifestImportedRef.current) return;
manifestImportedRef.current = true;
invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
'read_device_manifest', { destDir: targetDir }
).then(manifest => {
if (manifest?.sources?.length) {
useDeviceSyncStore.getState().clearSources();
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
}
}).catch(() => {});
@@ -186,7 +240,6 @@ export default function DeviceSync() {
useEffect(() => {
if (!driveDetected) {
setDeviceFilePaths([]);
setDeviceManifestTemplate(null);
manifestImportedRef.current = false;
}
}, [driveDetected]);
@@ -197,9 +250,7 @@ export default function DeviceSync() {
setSourcePathsMap(new Map());
return;
}
// Use the template from the manifest (written by the original sync machine) so that
// status checks work correctly even when the local template differs (e.g. Windows→Linux).
const templateForStatus = deviceManifestTemplate ?? filenameTemplate;
// Path schema is fixed in the Rust backend now — no template parameter.
let cancelled = false;
(async () => {
const map = new Map<string, string[]>();
@@ -208,9 +259,11 @@ export default function DeviceSync() {
try {
const tracks = await fetchTracksForSource(source);
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map(t => trackToSyncInfo(t, '')),
tracks: tracks.map((tr, idx) => trackToSyncInfo(
tr, '',
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
)),
destDir: targetDir,
template: templateForStatus,
});
map.set(source.id, paths);
} catch {
@@ -220,7 +273,7 @@ export default function DeviceSync() {
if (!cancelled) setSourcePathsMap(map);
})();
return () => { cancelled = true; };
}, [targetDir, filenameTemplate, deviceManifestTemplate, sources]);
}, [targetDir, sources]);
// Derive sync status per source
const sourceStatuses = useMemo(() => {
@@ -300,8 +353,24 @@ export default function DeviceSync() {
5000, 'info'
);
// Write manifest so another machine can read the synced sources from the stick
const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState();
if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {});
const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState();
if (dir) {
invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {});
// For every playlist source, write an Extended-M3U next to the
// playlist-folder tracks. Context carries the playlist name +
// per-track index so the filenames match the files we just synced.
const playlistSources = srcs.filter(s => s.type === 'playlist');
playlistSources.forEach(async playlist => {
try {
const tracks = await fetchTracksForSource(playlist);
await invoke('write_playlist_m3u8', {
destDir: dir,
playlistName: playlist.name,
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
});
} catch { /* m3u8 failure is non-fatal — skip silently */ }
});
}
}
// Re-scan the device after sync completes (cancelled or not)
scanDevice();
@@ -380,6 +449,119 @@ export default function DeviceSync() {
const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]);
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
// ─── Migration handlers ─────────────────────────────────────────────────
const startMigrationPreview = async () => {
if (!targetDir || sources.length === 0) return;
setMigrationPhase('loading');
setMigrationResult(null);
try {
// Look up the old template from the v1 manifest on disk.
const manifest = await invoke<{ version: number; filenameTemplate?: string } | null>(
'read_device_manifest', { destDir: targetDir }
);
const oldTemplate = manifest?.filenameTemplate?.trim() || '';
if (!oldTemplate) {
// v2 manifest or missing — nothing to migrate from.
setMigrationPhase('nothing');
return;
}
setMigrationOldTemplate(oldTemplate);
// Migration only renames tracks that came from album/artist sources —
// under the old template all tracks lived in a flat album tree. Playlist
// sources get their own `Playlists/{name}/…` folder under the new scheme,
// so the files they need are a subset (or copies) of the album tracks and
// are cleaner to just re-download on the next sync.
const albumSourceTracks: SubsonicSong[] = [];
const seenIds = new Set<string>();
for (const source of sources.filter(s => s.type !== 'playlist')) {
try {
const tracks = await fetchTracksForSource(source);
for (const tr of tracks) {
if (seenIds.has(tr.id)) continue;
seenIds.add(tr.id);
albumSourceTracks.push(tr);
}
} catch { /* skip unreachable source */ }
}
// New paths via Rust (fixed album-tree schema).
const newAbsPaths = await invoke<string[]>('compute_sync_paths', {
tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')),
destDir: targetDir,
});
const sepChar = IS_WINDOWS ? '\\' : '/';
const prefix = targetDir.endsWith(sepChar) ? targetDir : targetDir + sepChar;
const newRelPaths = newAbsPaths.map(p => p.startsWith(prefix) ? p.slice(prefix.length) : p);
// Old paths via the legacy template (JS).
const oldRelPaths = albumSourceTracks.map(tr => applyLegacyTemplate(oldTemplate, {
artist: tr.artist ?? '',
album: tr.album ?? '',
title: tr.title ?? '',
trackNumber: tr.track,
discNumber: tr.discNumber,
year: tr.year,
suffix: tr.suffix ?? 'mp3',
}));
const pairs: { old: string; new: string }[] = [];
const collisions: { old: string; new: string }[] = [];
const newPathCounts = new Map<string, number>();
let unchanged = 0;
for (let i = 0; i < albumSourceTracks.length; i++) {
const o = oldRelPaths[i];
const n = newRelPaths[i];
if (o === n) { unchanged += 1; continue; }
newPathCounts.set(n, (newPathCounts.get(n) ?? 0) + 1);
pairs.push({ old: o, new: n });
}
// Two separate old files mapping onto the same new path → collision.
const colliding = new Set([...newPathCounts.entries()].filter(([, c]) => c > 1).map(([p]) => p));
const cleanPairs = pairs.filter(p => !colliding.has(p.new));
for (const p of pairs.filter(p => colliding.has(p.new))) collisions.push(p);
setMigrationPairs(cleanPairs);
setMigrationCollisions(collisions);
setMigrationUnchanged(unchanged);
setMigrationPhase(cleanPairs.length === 0 && collisions.length === 0 ? 'nothing' : 'preview');
} catch (e) {
setMigrationResult({ ok: 0, failed: 0, errors: [String(e)] });
setMigrationPhase('done');
}
};
const executeMigration = async () => {
if (!targetDir || migrationPairs.length === 0) { setMigrationPhase('closed'); return; }
setMigrationPhase('executing');
try {
const results = await invoke<{ oldPath: string; newPath: string; ok: boolean; error: string | null }[]>(
'rename_device_files',
{ targetDir, pairs: migrationPairs.map(p => [p.old, p.new]) }
);
const ok = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
const errors = results.filter(r => !r.ok).map(r => `${r.oldPath}: ${r.error ?? 'unknown'}`);
setMigrationResult({ ok, failed, errors });
// Bump manifest to v2 (no template field) + rescan the device.
invoke('write_device_manifest', { destDir: targetDir, sources }).catch(() => {});
scanDevice();
setMigrationPhase('done');
} catch (e) {
setMigrationResult({ ok: 0, failed: migrationPairs.length, errors: [String(e)] });
setMigrationPhase('done');
}
};
const closeMigration = () => {
setMigrationPhase('closed');
setMigrationPairs([]);
setMigrationCollisions([]);
setMigrationResult(null);
setMigrationOldTemplate('');
};
const handleChooseFolder = async () => {
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (sel) {
@@ -388,13 +570,12 @@ export default function DeviceSync() {
// If the device has a psysonic-sync.json, always import it — replacing any
// sources from a previous device so switching sticks works correctly.
try {
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>(
const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>(
'read_device_manifest', { destDir: dir }
);
if (manifest?.sources?.length) {
useDeviceSyncStore.getState().clearSources();
manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s));
setDeviceManifestTemplate(manifest.filenameTemplate ?? null);
showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info');
}
} catch { /* no manifest, that's fine */ }
@@ -422,7 +603,6 @@ export default function DeviceSync() {
deletionIds: pendingDeletion,
auth: { baseUrl, ...params },
targetDir,
template: filenameTemplate,
});
setSyncDelta(payload);
@@ -442,16 +622,20 @@ export default function DeviceSync() {
if (deletionSources.length > 0) {
try {
const allPaths: string[] = [];
const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s)));
const deletionTracks = trackArrays.flat();
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: deletionTracks.map(t => trackToSyncInfo(t, '')),
destDir: targetDir,
template: filenameTemplate,
});
allPaths.push(...paths);
// Compute paths per source so playlist sources delete from their own
// folder (Playlists/{Name}/…) rather than from the album tree.
for (const source of deletionSources) {
const tracks = await fetchTracksForSource(source);
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map((tr, idx) => trackToSyncInfo(
tr, '',
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
)),
destDir: targetDir,
});
allPaths.push(...paths);
}
await invoke<number>('delete_device_files', { paths: allPaths });
removeSources(deletionSources.map(s => s.id));
// Update manifest so it stays in sync after deletions
@@ -468,6 +652,21 @@ export default function DeviceSync() {
const allTracks = syncDelta.tracks;
if (allTracks.length === 0) {
// No new downloads needed, but the user may still have added a
// playlist source — (re)write its .m3u8 against the existing files.
if (targetDir) {
const playlistSources = sources.filter(s => s.type === 'playlist');
playlistSources.forEach(async playlist => {
try {
const tracks = await fetchTracksForSource(playlist);
await invoke('write_playlist_m3u8', {
destDir: targetDir,
playlistName: playlist.name,
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
});
} catch { /* non-fatal */ }
});
}
scanDevice();
return;
}
@@ -480,7 +679,6 @@ export default function DeviceSync() {
invoke('sync_batch_to_device', {
tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))),
destDir: targetDir,
template: filenameTemplate,
jobId,
expectedBytes: syncDelta.addBytes,
}).catch((err: string) => {
@@ -524,63 +722,6 @@ export default function DeviceSync() {
(!driveDetected && !!targetDir) ||
(pendingCount === 0 && deletionCount === 0);
// ─── Template presets & token insertion ────────────────────────────────
const TEMPLATE_PRESETS = useMemo(() => [
{ key: 'standard', value: '{artist}/{album}/{track_number} - {title}', label: t('deviceSync.templatePresetStandard') },
{ key: 'multidisc', value: '{artist}/{album}/{disc_number}-{track_number} - {title}', label: t('deviceSync.templatePresetMultiDisc') },
{ key: 'altfolder', value: '{artist} - {album}/{track_number} - {title}', label: t('deviceSync.templatePresetAltFolder') },
], [t]);
const TEMPLATE_TOKENS = ['{artist}', '{album}', '{title}', '{track_number}', '{disc_number}', '{year}', '/', '-'];
const activePreset = TEMPLATE_PRESETS.find(p => p.value === filenameTemplate)?.key ?? null;
const templateInputRef = useRef<HTMLInputElement>(null);
const cursorPosRef = useRef<number>(filenameTemplate.length);
const insertToken = useCallback((token: string) => {
const input = templateInputRef.current;
const pos = cursorPosRef.current;
const next = filenameTemplate.slice(0, pos) + token + filenameTemplate.slice(pos);
setFilenameTemplate(next);
requestAnimationFrame(() => {
if (!input) return;
input.focus();
const newPos = pos + token.length;
input.setSelectionRange(newPos, newPos);
cursorPosRef.current = newPos;
});
}, [filenameTemplate, setFilenameTemplate]);
const trackCursor = useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length);
}, [filenameTemplate.length]);
// ─── Template preview (dummy track) ─────────────────────────────────────
const PREVIEW_TRACK = {
artist: 'Artist Name',
album: 'Album Title',
title: 'Track Title',
track_number: '01',
disc_number: '1',
year: '2024',
} as const;
const templatePreviewText = useMemo(() => {
try {
const result = filenameTemplate
.replace(/\{artist\}/g, PREVIEW_TRACK.artist)
.replace(/\{album\}/g, PREVIEW_TRACK.album)
.replace(/\{title\}/g, PREVIEW_TRACK.title)
.replace(/\{track_number\}/g, PREVIEW_TRACK.track_number)
.replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number)
.replace(/\{year\}/g, PREVIEW_TRACK.year);
return `${result}.mp3`;
} catch {
return '';
}
}, [filenameTemplate]);
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
@@ -598,63 +739,30 @@ export default function DeviceSync() {
</div>
<div className="device-sync-config-row">
{/* ── Left: Template ── */}
<div className="device-sync-template-section">
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
<div className="device-sync-template-presets">
{TEMPLATE_PRESETS.map(p => (
<button
key={p.key}
className={`device-sync-template-preset-btn${activePreset === p.key ? ' active' : ''}`}
onClick={() => setFilenameTemplate(p.value)}
>
{p.label}
</button>
))}
</div>
<div className="device-sync-template-input-wrap">
<div className="device-sync-template-input-row">
<input
ref={templateInputRef}
className="input device-sync-template-input"
value={filenameTemplate}
onChange={e => { setFilenameTemplate(e.target.value); trackCursor(e); }}
onSelect={trackCursor}
onKeyUp={trackCursor}
onClick={trackCursor}
spellCheck={false}
/>
{filenameTemplate && (
<button
className="device-sync-template-clear"
onClick={() => { setFilenameTemplate(''); cursorPosRef.current = 0; templateInputRef.current?.focus(); }}
data-tooltip={t('common.clear')}
data-tooltip-pos="bottom"
>
<X size={13} />
</button>
)}
</div>
<div className="device-sync-template-tokens">
{TEMPLATE_TOKENS.map(tok => (
<button
key={tok}
className="device-sync-template-token"
onClick={() => insertToken(tok)}
data-tooltip={tok === '/' ? t('deviceSync.tokenSlashHint') : undefined}
data-tooltip-pos="bottom"
>
{tok}
</button>
))}
</div>
{templatePreviewText && (
<span className="device-sync-template-preview">
{t('deviceSync.templatePreview')}: {templatePreviewText}
</span>
)}
</div>
{/* ── Left: Fixed schema info ── */}
<div className="device-sync-schema-section">
<span className="device-sync-label-inline">{t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })}</span>
<code className="device-sync-schema-code">
{'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'}
</code>
<span className="device-sync-schema-hint">
{t('deviceSync.schemaHint', {
defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
})}
</span>
{targetDir && sources.length > 0 && (
<button
className="btn btn-ghost device-sync-migrate-btn"
onClick={startMigrationPreview}
data-tooltip={t('deviceSync.migrateTooltip', {
defaultValue: 'Rename existing files on the device into the new scheme (from the old filename template).',
})}
data-tooltip-pos="bottom"
>
{t('deviceSync.migrateButton', { defaultValue: 'Reorganize existing files…' })}
</button>
)}
</div>
{/* ── Right: Drive config ── */}
@@ -764,7 +872,7 @@ export default function DeviceSync() {
{activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.artist}
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} />
))}
{activeTab === 'artists' && filteredArtists.map(ar => (
<React.Fragment key={ar.id}>
@@ -788,7 +896,7 @@ export default function DeviceSync() {
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
indent
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} />
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
))
}
</React.Fragment>
@@ -882,7 +990,10 @@ export default function DeviceSync() {
onChange={() => toggleChecked(s.id)}
disabled={status === 'deletion'}
/>
<span className="device-sync-row-name">{s.name}</span>
<span className="device-sync-row-name">
{s.name}
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
</span>
<span className="device-sync-source-type">{s.type}</span>
<span className={`device-sync-status-icon ${status}`}>
{status === 'synced' && <CheckCircle2 size={13} />}
@@ -1039,6 +1150,117 @@ export default function DeviceSync() {
</div>
</div>
)}
{/* ── Migration modal (rename existing files into the fixed scheme) ── */}
{migrationPhase !== 'closed' && (
<div className="modal-overlay" onClick={migrationPhase === 'executing' ? undefined : closeMigration}>
<div className="modal-content device-sync-migrate-modal" onClick={e => e.stopPropagation()}>
<h2 className="modal-title">{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}</h2>
<div className="device-sync-migrate-body">
{migrationPhase === 'loading' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })}</span>
</div>
)}
{migrationPhase === 'nothing' && (
<div className="device-sync-migrate-nothing">
{migrationOldTemplate ? (
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
) : (
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
)}
</div>
)}
{migrationPhase === 'preview' && (
<>
<div className="device-sync-migrate-summary">
<div>
<strong>{migrationPairs.length}</strong>{' '}
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
</div>
{migrationUnchanged > 0 && (
<div className="muted">
{t('deviceSync.migrateUnchanged', {
defaultValue: '{{n}} files are already at the correct path',
n: migrationUnchanged,
})}
</div>
)}
{migrationCollisions.length > 0 && (
<div className="device-sync-migrate-warning">
<AlertCircle size={14} />
{t('deviceSync.migrateCollisions', {
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
n: migrationCollisions.length,
})}
</div>
)}
</div>
<div className="device-sync-migrate-preview-note">
{t('deviceSync.migratePreviewNote', {
defaultValue: 'Old template: {{tpl}}',
tpl: migrationOldTemplate,
})}
</div>
</>
)}
{migrationPhase === 'executing' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
</div>
)}
{migrationPhase === 'done' && migrationResult && (
<div className="device-sync-migrate-result">
<div className="device-sync-migrate-result-line">
<CheckCircle2 size={14} className="positive" />
{t('deviceSync.migrateSuccess', {
defaultValue: '{{n}} files renamed successfully',
n: migrationResult.ok,
})}
</div>
{migrationResult.failed > 0 && (
<div className="device-sync-migrate-result-line">
<AlertCircle size={14} className="danger" />
{t('deviceSync.migrateFailed', {
defaultValue: '{{n}} renames failed',
n: migrationResult.failed,
})}
</div>
)}
{migrationResult.errors.length > 0 && (
<details className="device-sync-migrate-errors">
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
<ul>
{migrationResult.errors.slice(0, 50).map((err, i) => (
<li key={i}>{err}</li>
))}
{migrationResult.errors.length > 50 && (
<li> {migrationResult.errors.length - 50} more</li>
)}
</ul>
</details>
)}
</div>
)}
</div>
<div className="device-sync-migrate-footer">
{migrationPhase === 'preview' && (
<>
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
</button>
</>
)}
{(migrationPhase === 'done' || migrationPhase === 'nothing') && (
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -1053,8 +1275,10 @@ function BrowserRow({ name, meta, selected, onToggle, indent }: {
<span className="device-sync-row-check">
{selected ? <CheckCircle2 size={14} /> : <span className="device-sync-row-circle" />}
</span>
<span className="device-sync-row-name">{name}</span>
{meta && <span className="device-sync-row-meta">{meta}</span>}
<span className="device-sync-row-name">
{name}
{meta && <span className="device-sync-row-artist"> · {meta}</span>}
</span>
</button>
);
}
+154 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import StarRating from '../components/StarRating';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
@@ -25,6 +25,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
@@ -205,6 +206,30 @@ export default function Favorites() {
loadAll();
}, [musicLibraryFilterVersion]);
// ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo(() => {
const counts = new Map<string, { id: string; name: string; count: number; coverArtId: string }>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = s.artistId || s.artist;
if (!key) continue;
const existing = counts.get(key);
if (existing) {
existing.count += 1;
} else {
counts.set(key, {
id: key,
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 12);
}, [songs, starredOverrides]);
// ── Filter & sort logic ──────────────────────────────────────────────────
const filteredSongs = useMemo(() => {
return songs.filter(s => {
@@ -309,6 +334,15 @@ export default function Favorites() {
/>
)}
{topFavoriteArtists.length >= 2 && (
<TopFavoriteArtistsRow
title={t('favorites.topArtists')}
artists={topFavoriteArtists}
selectedKey={selectedArtist}
onToggle={key => setSelectedArtist(prev => prev === key ? null : key)}
/>
)}
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<section className="album-row-section">
{/* ── Section Header with Stats & Filters ───────────────────────── */}
@@ -656,6 +690,11 @@ export default function Favorites() {
)}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
@@ -709,6 +748,120 @@ export default function Favorites() {
);
}
// ── Top Favorite Artists Row ──────────────────────────────────────────────────
interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
}
interface TopFavoriteArtistsRowProps {
title: string;
artists: TopFavoriteArtist[];
selectedKey: string | null;
onToggle: (key: string) => void;
}
function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [artists]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<TopFavoriteArtistCard
key={a.id}
artist={a}
isSelected={selectedKey === a.id}
onClick={() => onToggle(a.id)}
songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })}
/>
))}
</div>
</div>
</section>
);
}
interface TopFavoriteArtistCardProps {
artist: TopFavoriteArtist;
isSelected: boolean;
onClick: () => void;
songCountLabel: string;
}
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
return (
<div
className={`artist-card${isSelected ? ' artist-card-selected' : ''}`}
onClick={onClick}
style={isSelected ? { outline: '2px solid var(--accent)', outlineOffset: '-2px', borderRadius: 12 } : undefined}
>
<div className="artist-card-avatar">
{coverId ? (
<CachedImage
src={coverSrc}
cacheKey={coverCacheKey}
alt={artist.name}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name">{artist.name}</span>
<span className="artist-card-meta">{songCountLabel}</span>
</div>
</div>
);
}
// ── Radio Station Row ─────────────────────────────────────────────────────────
interface RadioStationRowProps {
+1 -1
View File
@@ -137,7 +137,7 @@ export default function NewReleases() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
+561 -26
View File
@@ -5,7 +5,8 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
Users, UserPlus, Shield
} from 'lucide-react';
import i18n from '../i18n';
import { exportBackup, importBackup } from '../utils/backup';
@@ -23,7 +24,7 @@ import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX, IS_MACOS } from '../utils/platform';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
@@ -33,8 +34,14 @@ import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import {
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
ndListLibraries, ndSetUserLibraries,
type NdUser, type NdLibrary,
} from '../api/navidromeAdmin';
import { switchActiveServer } from '../utils/switchActiveServer';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import ConfirmModal from '../components/ConfirmModal';
import { Trans, useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
import StarRating from '../components/StarRating';
@@ -121,6 +128,8 @@ const CONTRIBUTORS = [
'ReplayGain values in Queue tech strip (PR #196)',
'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German',
'NixOS / flake install guide with Cachix setup (PR #209)',
],
},
{
@@ -138,6 +147,8 @@ const CONTRIBUTORS = [
'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)',
'Fullscreen & sidebar lyrics: duration-based ease-out scroll animator replacing spring physics; bottom fade for plain lyrics (PR #214)',
'Sidebar lyrics: YouLy+ source strings render in a single line (PR #215)',
],
},
{
@@ -157,6 +168,9 @@ const CONTRIBUTORS = [
'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)',
'Opt-in floating player bar with themed background, accent-colored border, rounded album art, and centered volume section (PR #216)',
'Linux GPU-vendor auto-detection to configure the WebKitGTK DMA-BUF renderer (disabled on NVIDIA proprietary) (PR #217)',
],
},
{
@@ -177,7 +191,7 @@ const SPECIAL_THANKS = [
},
] as const;
type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
type Tab = 'general' | 'server' | 'users' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
@@ -237,6 +251,466 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
);
}
interface UserFormState {
userName: string;
name: string;
email: string;
password: string;
isAdmin: boolean;
libraryIds: number[];
}
function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState {
const defaultIds = allLibraries.map(l => l.id);
return {
userName: u?.userName ?? '',
name: u?.name ?? '',
email: u?.email ?? '',
password: '',
isAdmin: !!u?.isAdmin,
libraryIds: u ? [...u.libraryIds] : defaultIds,
};
}
function UserForm({
initial,
libraries,
onSave,
onCancel,
busy,
}: {
initial: NdUser | null;
libraries: NdLibrary[];
onSave: (form: UserFormState) => void;
onCancel: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries));
const [showPass, setShowPass] = useState(false);
const isEdit = !!initial;
const set = <K extends keyof UserFormState>(k: K, v: UserFormState[K]) =>
setForm(f => ({ ...f, [k]: v }));
const toggleLib = (id: number) =>
setForm(f => ({
...f,
libraryIds: f.libraryIds.includes(id)
? f.libraryIds.filter(x => x !== id)
: [...f.libraryIds, id],
}));
const canSave =
form.userName.trim().length > 0 &&
form.name.trim().length > 0 &&
form.password.length > 0 &&
(form.isAdmin || form.libraryIds.length > 0);
return (
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
{isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')}
</h3>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.userMgmtUsername')}</label>
<input
className="input"
type="text"
value={form.userName}
onChange={e => set('userName', e.target.value)}
disabled={isEdit}
autoComplete="off"
/>
</div>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.userMgmtName')}</label>
<input
className="input"
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
autoComplete="off"
/>
</div>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtEmail')}</label>
<input
className="input"
type="email"
value={form.email}
onChange={e => set('email', e.target.value)}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<div style={{ position: 'relative' }}>
<input
className="input"
type={showPass ? 'text' : 'password'}
value={form.password}
onChange={e => set('password', e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
style={{ paddingRight: '2.5rem' }}
/>
<button
type="button"
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
onClick={() => setShowPass(v => !v)}
>
{showPass ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{isEdit && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>
{t('settings.userMgmtPasswordEditHint')}
</div>
)}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginBottom: '1rem' }}>
<input
type="checkbox"
checked={form.isAdmin}
onChange={e => set('isAdmin', e.target.checked)}
/>
<Shield size={14} />
{t('settings.userMgmtRoleAdmin')}
</label>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: 13, marginBottom: 6, display: 'block' }}>
{t('settings.userMgmtLibraries')}
</label>
{form.isAdmin ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.userMgmtLibrariesAdminHint')}
</div>
) : libraries.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.userMgmtLibrariesEmpty')}
</div>
) : (
<>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: 180,
overflowY: 'auto',
padding: '6px 8px',
border: `1px solid ${form.libraryIds.length === 0 ? 'var(--danger)' : 'var(--border)'}`,
borderRadius: 6,
}}
>
{libraries.map(lib => (
<label
key={lib.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', padding: '2px 0' }}
>
<input
type="checkbox"
checked={form.libraryIds.includes(lib.id)}
onChange={() => toggleLib(lib.id)}
/>
{lib.name}
</label>
))}
</div>
{form.libraryIds.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 4 }}>
{t('settings.userMgmtLibrariesValidation')}
</div>
)}
</>
)}
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
{t('settings.userMgmtCancel')}
</button>
<button
className="btn btn-primary"
onClick={() => onSave(form)}
disabled={busy || !canSave}
>
{t('settings.userMgmtSave')}
</button>
</div>
</div>
);
}
function formatLastSeen(iso: string | null | undefined, locale: string, neverLabel: string): string {
if (!iso) return neverLabel;
const t = new Date(iso).getTime();
// Navidrome returns "0001-01-01T00:00:00Z" for never-accessed users → guard against bogus epochs.
if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel;
const diffSec = (t - Date.now()) / 1000;
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
return rtf.format(Math.round(diffSec / 31536000), 'year');
}
function UserManagementSection({
serverUrl,
token,
currentUsername,
}: {
serverUrl: string;
token: string;
currentUsername: string;
}) {
const { t, i18n } = useTranslation();
const [users, setUsers] = useState<NdUser[]>([]);
const [libraries, setLibraries] = useState<NdLibrary[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const [list, libs] = await Promise.all([
ndListUsers(serverUrl, token),
ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]),
]);
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name)));
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : t('settings.userMgmtLoadError');
setLoadError(msg);
} finally {
setLoading(false);
}
}, [serverUrl, token, t]);
useEffect(() => { void load(); }, [load]);
const handleSave = async (form: UserFormState) => {
const userName = form.userName.trim();
const name = form.name.trim();
const email = form.email.trim();
if (!userName || !name || !form.password) {
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
if (!token) return;
setBusy(true);
try {
let targetId: string;
if (editing === 'new') {
const created = await ndCreateUser(serverUrl, token, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
targetId = created.id;
showToast(t('settings.userMgmtCreated'), 3000, 'info');
} else if (editing) {
await ndUpdateUser(serverUrl, token, editing.id, {
userName, name, email, password: form.password, isAdmin: form.isAdmin,
});
targetId = editing.id;
showToast(t('settings.userMgmtUpdated'), 3000, 'info');
} else {
return;
}
if (!form.isAdmin && form.libraryIds.length > 0) {
try {
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : String(e);
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
}
}
setEditing(null);
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
const fallback = editing === 'new'
? t('settings.userMgmtCreateError')
: t('settings.userMgmtUpdateError');
showToast(msg ?? fallback, 5000, 'error');
} finally {
setBusy(false);
}
};
const performDelete = async (u: NdUser) => {
if (!token) return;
setConfirmingDelete(null);
setBusy(true);
try {
await ndDeleteUser(serverUrl, token, u.id);
showToast(t('settings.userMgmtDeleted'), 3000, 'info');
await load();
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : t('settings.userMgmtDeleteError'));
showToast(msg, 5000, 'error');
} finally {
setBusy(false);
}
};
return (
<section className="settings-section">
<div className="settings-section-header">
<Users size={18} />
<h2>{t('settings.userMgmtTitle')}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.userMgmtDesc')}
</div>
{loading && (
<div className="settings-card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}></span>
</div>
)}
{!loading && loadError && (
<div className="settings-card" style={{ color: 'var(--danger)', fontSize: 13 }}>
{loadError}
</div>
)}
{!loading && !loadError && (
<>
{editing ? (
<UserForm
initial={editing === 'new' ? null : editing}
libraries={libraries}
onSave={handleSave}
onCancel={() => setEditing(null)}
busy={busy}
/>
) : (
<button
className="btn btn-surface"
style={{ marginBottom: '0.75rem' }}
onClick={() => setEditing('new')}
disabled={busy}
>
<UserPlus size={16} /> {t('settings.userMgmtAddUser')}
</button>
)}
{users.length === 0 ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{t('settings.userMgmtEmpty')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{users.map(u => {
const isSelf = u.userName === currentUsername;
const libNames = u.isAdmin
? null
: u.libraryIds.length === 0
? t('settings.userMgmtNoLibraries')
: libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', ');
const lastSeen = formatLastSeen(u.lastAccessAt, i18n.language, t('settings.userMgmtNeverSeen'));
const lastSeenAbsolute = u.lastAccessAt
? new Date(u.lastAccessAt).toLocaleString(i18n.language)
: '';
return (
<div
key={u.id}
className="settings-card user-row"
role="button"
tabIndex={0}
onClick={() => { if (!busy) setEditing(u); }}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !busy) {
e.preventDefault();
setEditing(u);
}
}}
style={{
padding: '6px 10px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: busy ? 'default' : 'pointer',
}}
>
<User size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13, flexShrink: 0 }}>{u.userName}</span>
{u.name && u.name !== u.userName && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', flexShrink: 0 }}>· {u.name}</span>
)}
{isSelf && (
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
{t('settings.userMgmtYouBadge')}
</span>
)}
{u.isAdmin && (
<span
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)', flexShrink: 0 }}
data-tooltip={t('settings.userMgmtRoleAdmin')}
>
<Shield size={10} />
{t('settings.userMgmtAdminBadge')}
</span>
)}
{libNames && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{libNames}
</span>
)}
<span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: libNames ? 0 : 'auto' }}
data-tooltip={lastSeenAbsolute || undefined}
>
{lastSeen}
</span>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); setConfirmingDelete(u); }}
disabled={busy || isSelf}
data-tooltip={t('settings.userMgmtDelete')}
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
)}
</>
)}
<ConfirmModal
open={!!confirmingDelete}
title={t('settings.userMgmtDelete')}
message={confirmingDelete
? t('settings.userMgmtConfirmDelete', { username: confirmingDelete.userName })
: ''}
confirmLabel={t('settings.userMgmtDelete')}
cancelLabel={t('settings.userMgmtCancel')}
danger
onConfirm={() => { if (confirmingDelete) void performDelete(confirmingDelete); }}
onCancel={() => setConfirmingDelete(null)}
/>
</section>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -416,6 +890,28 @@ export default function Settings() {
const [contributorsOpen, setContributorsOpen] = useState(false);
const [fontPickerOpen, setFontPickerOpen] = useState(false);
const [discordOptionsOpen, setDiscordOptionsOpen] = useState(false);
const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null);
const [ndAuthChecked, setNdAuthChecked] = useState(false);
useEffect(() => {
const server = auth.getActiveServer();
setNdAuthChecked(false);
if (!server) { setNdAdminAuth(null); setNdAuthChecked(true); return; }
const serverUrl = (server.url.startsWith('http') ? server.url : `http://${server.url}`).replace(/\/$/, '');
let cancelled = false;
ndLogin(serverUrl, server.username, server.password)
.then(res => {
if (cancelled) return;
setNdAdminAuth(res.isAdmin ? { token: res.token, serverUrl, username: server.username } : null);
})
.catch(() => { if (!cancelled) setNdAdminAuth(null); })
.finally(() => { if (!cancelled) setNdAuthChecked(true); });
return () => { cancelled = true; };
}, [auth.activeServerId]);
useEffect(() => {
if (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('general');
}, [activeTab, ndAdminAuth, ndAuthChecked]);
useEffect(() => {
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
@@ -664,6 +1160,7 @@ export default function Settings() {
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: <Users size={15} /> }] : []),
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
@@ -1124,6 +1621,25 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
{!IS_WINDOWS && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadMiniPlayerDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preloadMiniPlayer')}>
<input
type="checkbox"
checked={auth.preloadMiniPlayer}
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
{IS_LINUX && !isTilingWm && (
<>
<div className="settings-section-divider" />
@@ -1752,6 +2268,17 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
@@ -2008,6 +2535,7 @@ export default function Settings() {
['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })],
['fullscreen-player', t('settings.shortcutFullscreenPlayer')],
['native-fullscreen', t('settings.shortcutNativeFullscreen')],
['open-mini-player', t('settings.shortcutOpenMiniPlayer')],
] as [KeyAction, string][]).map(([action, label]) => {
const bound = kb.bindings[action];
const isListening = listeningFor === action;
@@ -2257,20 +2785,6 @@ export default function Settings() {
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: 4,
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.hotCacheAlphaBadge')}
</span>
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
@@ -2403,6 +2917,14 @@ export default function Settings() {
)}
{/* ── System ───────────────────────────────────────────────────────────── */}
{activeTab === 'users' && ndAdminAuth && (
<UserManagementSection
serverUrl={ndAdminAuth.serverUrl}
token={ndAdminAuth.token}
currentUsername={ndAdminAuth.username}
/>
)}
{activeTab === 'system' && (
<>
<BackupSection />
@@ -2443,6 +2965,18 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutReleaseNotesLabel')}</span>
<button
onClick={() => {
useAuthStore.getState().setLastSeenChangelogVersion('');
navigate('/whats-new');
}}
style={{ color: 'var(--accent)', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
>
{t('settings.aboutReleaseNotesLink')}
</button>
</div>
<div>
<button
style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}
@@ -2649,7 +3183,9 @@ function LyricsSourcesCustomizer() {
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
const { isDragging: isPsyDragging } = useDragDrop();
const containerRef = useRef<HTMLDivElement>(null);
// useState (not useRef) so the listener-effect re-runs when the container
// gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper.
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
const dropTargetRef = useRef<LyricsDropTarget>(null);
const sourcesRef = useRef(lyricsSources);
@@ -2660,8 +3196,7 @@ function LyricsSourcesCustomizer() {
}, [isPsyDragging]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (!containerEl) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
@@ -2682,13 +3217,13 @@ function LyricsSourcesCustomizer() {
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
setLyricsSources(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [setLyricsSources]);
containerEl.addEventListener('psy-drop', onPsyDrop);
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
}, [containerEl, setLyricsSources]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerRef.current) return;
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
if (!isPsyDragging || !containerEl) return;
const rows = containerEl.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
let target: LyricsDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
@@ -2782,7 +3317,7 @@ function LyricsSourcesCustomizer() {
</div>
{lyricsMode === 'standard' && (
<div className="settings-card" style={{ padding: '4px 0' }} ref={containerRef} onMouseMove={handleMouseMove}>
<div className="settings-card" style={{ padding: '4px 0' }} ref={setContainerEl} onMouseMove={handleMouseMove}>
{lyricsSources.map((src, i) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
+62
View File
@@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import { Sparkles, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { renderChangelogBody } from '../utils/changelogMarkdown';
export default function WhatsNew() {
const { t } = useTranslation();
const navigate = useNavigate();
const close = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
const entry = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
if (!block) return null;
const lines = block.split('\n');
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
const body = lines.slice(1).join('\n').trim();
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
}, []);
return (
<div className="whats-new">
<header className="whats-new__header">
<div className="whats-new__title-row">
<Sparkles size={20} className="whats-new__icon" />
<div>
<h1 className="whats-new__title">{t('whatsNew.title')}</h1>
<div className="whats-new__subtitle">
v{entry?.version ?? version}
{entry?.date && <span className="whats-new__date"> · {entry.date}</span>}
</div>
</div>
<button
type="button"
className="whats-new__close"
onClick={close}
aria-label={t('whatsNew.close')}
data-tooltip={t('whatsNew.close')}
data-tooltip-pos="bottom"
>
<X size={18} />
</button>
</div>
</header>
<div className="whats-new__body">
{entry ? (
renderChangelogBody(entry.body)
) : (
<p className="whats-new__empty">{t('whatsNew.empty')}</p>
)}
</div>
</div>
);
}
+6
View File
@@ -66,6 +66,9 @@ interface AuthState {
discordTemplateState: string;
discordTemplateLargeText: string;
useCustomTitlebar: boolean;
/** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly
* on first open. Ignored on Windows that platform always pre-creates as a hang workaround. */
preloadMiniPlayer: boolean;
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
linuxWebkitKineticScroll: boolean;
nowPlayingEnabled: boolean;
@@ -212,6 +215,7 @@ interface AuthState {
setDiscordTemplateState: (v: string) => void;
setDiscordTemplateLargeText: (v: string) => void;
setUseCustomTitlebar: (v: boolean) => void;
setPreloadMiniPlayer: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
@@ -318,6 +322,7 @@ export const useAuthStore = create<AuthState>()(
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
preloadMiniPlayer: false,
linuxWebkitKineticScroll: true,
nowPlayingEnabled: false,
lyricsServerFirst: true,
@@ -448,6 +453,7 @@ export const useAuthStore = create<AuthState>()(
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
+2 -5
View File
@@ -5,11 +5,12 @@ export interface DeviceSyncSource {
type: 'album' | 'playlist' | 'artist';
id: string;
name: string;
/** Album artist — only set when type === 'album'. Shown as a subtitle in the device list. */
artist?: string;
}
interface DeviceSyncState {
targetDir: string | null;
filenameTemplate: string;
sources: DeviceSyncSource[]; // persistent device content list
checkedIds: string[]; // currently checked for bulk actions (not persisted)
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
@@ -17,7 +18,6 @@ interface DeviceSyncState {
scanning: boolean; // true while scanning the device
setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void;
addSource: (source: DeviceSyncSource) => void;
removeSource: (id: string) => void;
clearSources: () => void;
@@ -35,7 +35,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
persist(
(set) => ({
targetDir: null,
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
pendingDeletion: [],
@@ -43,7 +42,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
scanning: false,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
addSource: (source) =>
set((s) => ({
@@ -97,7 +95,6 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
name: 'psysonic_device_sync',
partialize: (s) => ({
targetDir: s.targetDir,
filenameTemplate: s.filenameTemplate,
sources: s.sources,
}),
}
+3 -1
View File
@@ -12,7 +12,8 @@ export type KeyAction =
| 'toggle-queue'
| 'open-folder-browser'
| 'fullscreen-player'
| 'native-fullscreen';
| 'native-fullscreen'
| 'open-mini-player';
/** Physical keys only — ignore for binding capture */
export const MODIFIER_KEY_CODES = [
@@ -35,6 +36,7 @@ export const DEFAULT_BINDINGS: Bindings = {
'open-folder-browser': null,
'fullscreen-player': null,
'native-fullscreen': 'F11',
'open-mini-player': null,
};
interface KeybindingsState {
+3
View File
@@ -1427,6 +1427,9 @@ export const usePlayerStore = create<PlayerState>()(
seekDebounce = null;
seekTarget = time;
invoke('audio_seek', { seconds: time }).catch((err: unknown) => {
// Release the progress-tick guard so the UI doesn't freeze
// waiting for a target the engine will never reach.
seekTarget = null;
const msg = String(err ?? '');
if (!msg.includes('not seekable')) {
console.error(err);
+12
View File
@@ -22,6 +22,12 @@ interface ThemeState {
setEnablePlaylistCoverPhoto: (v: boolean) => void;
showBitrate: boolean;
setShowBitrate: (v: boolean) => void;
showRemainingTime: boolean;
setShowRemainingTime: (v: boolean) => void;
expandReplayGain: boolean;
setExpandReplayGain: (v: boolean) => void;
floatingPlayerBar: boolean;
setFloatingPlayerBar: (v: boolean) => void;
}
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
@@ -59,6 +65,12 @@ export const useThemeStore = create<ThemeState>()(
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
showBitrate: true,
setShowBitrate: (v) => set({ showBitrate: v }),
showRemainingTime: false,
setShowRemainingTime: (v) => set({ showRemainingTime: v }),
expandReplayGain: false,
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
floatingPlayerBar: false,
setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }),
}),
{
name: 'psysonic_theme',
+938 -163
View File
File diff suppressed because it is too large Load Diff
+299 -24
View File
@@ -33,6 +33,13 @@
background: var(--bg-app);
}
/* When player bar is floating, content extends to bottom */
.app-shell.floating-player {
grid-template-rows: minmax(0, 1fr);
grid-template-areas:
"sidebar main queue";
}
/* ─── Custom title bar (Linux only — decorations: false) ─── */
:root {
--titlebar-height: 32px;
@@ -46,6 +53,14 @@
"player player player";
}
/* When player bar is floating with titlebar, content extends to bottom */
.app-shell[data-titlebar].floating-player {
grid-template-rows: var(--titlebar-height) minmax(0, 1fr);
grid-template-areas:
"titlebar titlebar titlebar"
"sidebar main queue";
}
/* Resize grips — replace the native GTK grips lost when decorations: false */
.app-shell[data-titlebar]::before,
.app-shell[data-titlebar]::after {
@@ -67,7 +82,7 @@
grid-area: titlebar;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border-subtle);
padding: 0 6px 0 12px;
@@ -75,15 +90,6 @@
user-select: none;
}
.titlebar-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
pointer-events: none;
flex: 0 0 auto;
}
.titlebar-track {
position: absolute;
left: 50%;
@@ -93,23 +99,35 @@
gap: 6px;
max-width: 40%;
pointer-events: none;
padding: 3px 10px;
background: rgba(0, 0, 0, 0.45);
border-radius: 999px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.titlebar-track:empty {
display: none;
}
.titlebar-track-state {
font-size: 9px;
color: var(--accent);
flex-shrink: 0;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.6);
}
.titlebar-track-text {
font-size: 12px;
color: var(--text-secondary);
font-size: 11px;
color: #ececec;
max-width: 100%;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
}
.titlebar-controls {
display: flex;
gap: 2px;
align-items: center;
gap: 8px;
padding: 0 8px;
flex: 0 0 auto;
}
@@ -117,24 +135,43 @@
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 24px;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
border-radius: 50%;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
transition: filter var(--transition-fast);
}
.titlebar-btn svg {
width: 9px;
height: 9px;
stroke-width: 2.5;
}
.titlebar-btn-close {
background: #ff5f57;
color: #4d0000;
}
.titlebar-btn-minimize {
background: #febc2e;
color: #7a4a00;
}
.titlebar-btn-maximize {
background: #28c840;
color: #004d00;
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
filter: brightness(1.08);
}
.titlebar-btn-close:hover {
background: var(--danger);
color: #fff;
.titlebar-btn:active {
filter: brightness(0.92);
}
/* Resizer handles must start below the titlebar */
@@ -769,6 +806,53 @@
gap: var(--space-2);
font-size: 12px;
}
/* macOS Tauri Updater — idle state info block */
.update-modal-mac-info {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.update-modal-mac-info-main {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.update-modal-mac-info-sub {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
}
.update-modal-trust-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.update-modal-trust-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--positive, var(--accent));
background: var(--bg-glass);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 3px 8px;
}
.update-modal-trust-badge svg {
flex-shrink: 0;
}
/* macOS Tauri Updater — done state (after install, before/during restart) */
.update-modal-done-icon {
align-self: center;
color: var(--positive, var(--accent));
margin-bottom: var(--space-1);
}
.update-modal-done-countdown {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* AUR hint */
.update-modal-aur {
display: flex;
@@ -984,6 +1068,40 @@
contain: paint;
}
/* When floating player is active, add extra bottom padding so content doesn't
get hidden behind the fixed floating bar (player height + bottom offset + minimal spacing).
!important needed to override inline padding:0 on App.tsx content-body */
.app-shell.floating-player .content-body {
padding-bottom: calc(var(--player-height) + 12px) !important;
}
/* Every page re-uses .content-body as its outer wrapper, but App.tsx already
renders one around <Routes /> as the scroll container. The inner one must
not establish its own scroll ancestor otherwise `position: sticky`
children anchor to a container that never scrolls, and the header just
scrolls along with the content. Padding stays: the outer App.tsx
content-body has `padding: 0` inline, the inner one is what gives each
page its breathing room. */
.content-body .content-body {
overflow: visible;
}
/* Sticky page header: keeps page title + filter/search bar visible while the
body scrolls. Negative horizontal margins (+ matching padding) make the
background flush with the scroll container edges so the sticky block looks
like a real bar, not a floating island. `top: 0` clamps below the content-
body's own padding-top; the residual 24px gap above the sticky block while
scrolling is intentional gives the bar visual breathing room. */
.page-sticky-header {
position: sticky;
top: 0;
z-index: 5;
background: var(--bg-app);
margin: 0 calc(-1 * var(--space-6)) var(--space-5);
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
/* ─── Offline Banner ─── */
.offline-banner {
display: flex;
@@ -1071,6 +1189,81 @@
height: var(--player-height);
position: relative;
z-index: 100;
/* WebKitGTK (software compositing) occasionally paints the bar fully
black for one frame when an unrelated layer in the page invalidates.
`contain` isolates layout/paint so the bar's region cannot
participate in the surrounding dirty rect. */
contain: layout paint;
}
/* Floating player bar styles - positioning handled by ResizeObserver */
.player-bar.floating {
position: fixed;
bottom: 12px;
/* left/right handled dynamically by JS */
z-index: 1000;
border-radius: 50px;
width: auto;
min-width: 100px;
max-width: none;
grid-area: unset;
padding: 0 24px 0 8px;
gap: 16px;
background: var(--bg-player);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Internal element adjustments for floating mode */
.player-bar.floating .player-track-info {
flex: 0 0 auto;
min-width: 240px;
max-width: 320px;
}
.player-bar.floating .player-album-art-wrap {
border-radius: 50%;
}
.player-bar.floating .player-album-art,
.player-bar.floating .player-album-art-placeholder {
border-radius: 0;
}
.player-bar.floating .player-buttons {
flex-shrink: 0;
gap: 16px;
}
.player-bar.floating .player-waveform-section {
flex: 1;
min-width: 200px;
}
.player-bar.floating .player-volume-section {
flex: 0 0 auto;
min-width: 120px;
display: flex;
align-items: center;
gap: 8px;
}
/* Liquid-glass floating bar macOS + Windows only.
Inspired by PR #211 (kveld9), adapted to theme tokens and reduced
blur so it stays within WebView2 / WKWebView GPU budgets. Linux
always keeps the solid look from PR #216, regardless of compositor. */
html[data-platform="macos"] .player-bar.floating,
html[data-platform="windows"] .player-bar.floating {
background: color-mix(in srgb, var(--bg-player) 72%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.45),
inset 0 1px 0 color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.player-track-info {
@@ -1279,6 +1472,25 @@
text-align: left;
}
/* Clickable time toggle with swap indicator */
.player-time-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.15s, color 0.15s;
}
.player-time-toggle:hover {
background-color: var(--surface-hover, rgba(128, 128, 128, 0.2));
color: var(--text-primary);
}
.player-time-toggle:active {
background-color: var(--surface-active, rgba(128, 128, 128, 0.3));
}
/* Volume section */
.player-volume-section {
display: flex;
@@ -1296,6 +1508,9 @@
.player-volume-slider {
width: 100%;
display: block;
margin: 0;
vertical-align: middle;
}
.player-volume-pct {
@@ -1627,6 +1842,66 @@
text-overflow: ellipsis;
}
/* Two-line layout: base info + RG badge on top, optional ReplayGain
values underneath when the badge is expanded. The stack wraps both
rows so the source icon stays vertically centered next to the whole
text block. */
.queue-current-tech-stack {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.queue-current-tech-row {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
/* Pill-shaped toggle that signals "this track has ReplayGain metadata"
without committing screen space to the numbers values appear in the
tooltip and on the second line when expanded. */
.queue-current-tech-rg-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 5px 1px 6px;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
border-radius: 999px;
font: inherit;
font-size: 8px;
letter-spacing: 0.06em;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, transform 0.12s;
}
.queue-current-tech-rg-badge:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.queue-current-tech-rg-badge svg {
transition: transform 0.18s ease;
}
.queue-current-tech-rg-badge--open svg {
transform: rotate(180deg);
}
.queue-current-tech-rg {
min-width: 0;
max-width: 100%;
font-size: 8px;
opacity: 0.72;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-divider {
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
+37 -11
View File
@@ -4035,7 +4035,7 @@ select.input.input:focus {
to {
opacity: 1;
transform: translateY(0);
transform: none;
}
}
@@ -4584,9 +4584,10 @@ input[type="range"]:hover::-webkit-slider-thumb {
--accent-glow: rgba(69, 255, 0, 0.45);
--text-primary: #ffffff;
--text-secondary: #dce8ff;
--text-muted: #b8d0f8;
/* lighter so readable on #3a62a5 */
--border: #2a5090;
--text-muted: #e8f0ff;
/* lifted from #b8d0f8 — clears AA on Luna-blue bg-app and dark sidebar */
--border: #071027;
/* darkened from #2a5090 to meet 3:1 UI threshold on all main surfaces */
--border-subtle: #4a72b8;
--positive: #30dd00;
--warning: #ffdd44;
@@ -10780,11 +10781,13 @@ input[type="range"]:hover::-webkit-slider-thumb {
--accent-glow: rgba(21, 101, 200, 0.40);
--text-primary: #0d1d3c;
--text-secondary: #2a4878;
--text-muted: #4870a8;
--text-muted: #3f6aa0;
/* slight lift from #4870a8 to clear AA on bg-card (4.23 → 4.65) */
--border: rgba(100, 160, 220, 0.45);
--border-subtle: rgba(140, 190, 240, 0.30);
--positive: #1a8020;
--warning: #c8980c;
--warning: #735a00;
/* deeper Vista gold — #c8980c was 2.37:1 on bg-app, now 5.91:1 */
--danger: #c02020;
--radius-sm: 3px;
--radius-md: 6px;
@@ -11122,6 +11125,22 @@ input[type="range"]:hover::-webkit-slider-thumb {
color: #6090b8;
}
/* Lyrics pane sits inside the queue panel (bg-sidebar = #0e1e3e deep navy).
Default --text-primary / --text-muted are near-navy and invisible there
explicit light tokens get the pane back to AA+. */
[data-theme='wista'] .lyrics-line,
[data-theme='wista'] .lyrics-status,
[data-theme='wista'] .lyrics-word-synced .lyrics-line {
color: #aac8f0;
}
[data-theme='wista'] .lyrics-line.active,
[data-theme='wista'] .lyrics-word-synced .lyrics-line.active {
color: #ffffff;
}
[data-theme='wista'] .lyrics-line.completed {
color: #aac8f0;
}
/* ─────────────────────────────────────────── */
[data-theme='aqua-quartz'] {
@@ -11656,15 +11675,17 @@ input[type="range"]:hover::-webkit-slider-thumb {
border-right: 1px solid #1e1e1e;
}
/* Aktiver Nav-Link: Lila-Akzent mit linkem Balken */
/* Aktiver Nav-Link: gefüllte Lila-Fläche mit weißer Schrift (WCAG AA) + linker Balken
Vorher: color #AA5CC3 auf 12 % accent über bg-sidebar ~3.30:1 (Fail AA small text).
Jetzt: weißer Text auf 22 % accent >10:1, Brand-Identity bleibt durch den Balken. */
[data-theme='jayfin'] .nav-link.active {
background: rgba(170, 92, 195, 0.12);
color: #AA5CC3;
background: rgba(170, 92, 195, 0.22);
color: #ffffff;
border-left: 3px solid #AA5CC3;
}
[data-theme='jayfin'] .nav-link.active svg {
color: #AA5CC3;
color: #ffffff;
}
/* Player Bar: tiefes Schwarz mit Jellyfin-Brand-Gradient als Oberkante */
@@ -11688,13 +11709,16 @@ input[type="range"]:hover::-webkit-slider-thumb {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* Btn-primary: Jellyfin Lila */
/* Btn-primary: Jellyfin Lila
font-weight 700 nötig, weil weiß auf #AA5CC3 nur 4.08:1 erreicht
bold verdichtet die Glyph-Fläche und hebt die Lesbarkeit über die AA-Schwelle. */
[data-theme='jayfin'] .btn-primary,
[data-theme='jayfin'] .player-btn-primary,
[data-theme='jayfin'] .hero-play-btn {
background: #AA5CC3;
color: #ffffff;
border: none;
font-weight: 700;
box-shadow: 0 2px 12px rgba(170, 92, 195, 0.35);
}
@@ -11710,6 +11734,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
[data-theme='jayfin'] .album-card-details-btn {
background: #AA5CC3;
color: #ffffff;
font-weight: 700;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
@@ -11721,6 +11746,7 @@ input[type="range"]:hover::-webkit-slider-thumb {
[data-theme='jayfin'] .queue-round-btn.active {
background: #AA5CC3;
color: #ffffff;
font-weight: 700;
box-shadow: 0 0 10px rgba(170, 92, 195, 0.4);
}
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { open } from '@tauri-apps/plugin-shell';
/**
* Render inline markdown segments: **bold**, *italic*, `code`, [text](url).
* External links open in the user's default browser via the Tauri shell plugin.
*/
export function renderInlineMarkdown(text: string, keyPrefix = 'i'): React.ReactNode[] {
// Tokenize — order matters: links first (no recursion), then emphasis/code.
const tokens: React.ReactNode[] = [];
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let i = 0;
const pushInline = (segment: string) => {
const parts = segment.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
tokens.push(<strong key={`${keyPrefix}-${i++}`}>{part.slice(2, -2)}</strong>);
} else if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
tokens.push(<em key={`${keyPrefix}-${i++}`}>{part.slice(1, -1)}</em>);
} else if (part.startsWith('`') && part.endsWith('`')) {
tokens.push(<code key={`${keyPrefix}-${i++}`} className="whats-new-code">{part.slice(1, -1)}</code>);
} else {
tokens.push(part);
}
}
};
while ((match = linkRe.exec(text)) !== null) {
if (match.index > lastIndex) pushInline(text.slice(lastIndex, match.index));
const [full, label, url] = match;
tokens.push(
<a
key={`${keyPrefix}-link-${i++}`}
href={url}
onClick={(e) => { e.preventDefault(); open(url).catch(() => {}); }}
className="whats-new-link"
>
{label}
</a>
);
lastIndex = match.index + full.length;
}
if (lastIndex < text.length) pushInline(text.slice(lastIndex));
return tokens;
}
/**
* Render a subset of GitHub-flavored Markdown used by our CHANGELOG: headings
* (### / ####), bullets (- / *), blockquotes, horizontal rules, and inline
* formatting (bold/italic/code/links).
*/
export function renderChangelogBody(body: string): React.ReactNode[] {
const lines = body.split('\n');
const out: React.ReactNode[] = [];
let bulletBuffer: React.ReactNode[] = [];
let quoteBuffer: string[] = [];
const flushBullets = () => {
if (bulletBuffer.length === 0) return;
out.push(<ul key={`ul-${out.length}`} className="whats-new-list">{bulletBuffer}</ul>);
bulletBuffer = [];
};
const flushQuote = () => {
if (quoteBuffer.length === 0) return;
out.push(
<blockquote key={`q-${out.length}`} className="whats-new-quote">
{renderInlineMarkdown(quoteBuffer.join(' '), `q-${out.length}`)}
</blockquote>
);
quoteBuffer = [];
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === '') { flushBullets(); flushQuote(); continue; }
if (trimmed === '---') {
flushBullets(); flushQuote();
out.push(<hr key={`hr-${out.length}`} className="whats-new-hr" />);
continue;
}
if (line.startsWith('### ')) {
flushBullets(); flushQuote();
out.push(<h3 key={`h3-${out.length}`} className="whats-new-h3">{renderInlineMarkdown(line.slice(4), `h3-${i}`)}</h3>);
continue;
}
if (line.startsWith('#### ')) {
flushBullets(); flushQuote();
out.push(<h4 key={`h4-${out.length}`} className="whats-new-h4">{renderInlineMarkdown(line.slice(5), `h4-${i}`)}</h4>);
continue;
}
if (line.startsWith('> ')) {
flushBullets();
quoteBuffer.push(line.slice(2));
continue;
}
if (line.startsWith('- ') || line.startsWith('* ')) {
flushQuote();
bulletBuffer.push(
<li key={`li-${i}`}>{renderInlineMarkdown(line.slice(2), `li-${i}`)}</li>
);
continue;
}
// Paragraph / plain line
flushBullets(); flushQuote();
out.push(<p key={`p-${i}`} className="whats-new-p">{renderInlineMarkdown(line, `p-${i}`)}</p>);
}
flushBullets(); flushQuote();
return out;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Duration-based ease-out scroll animator.
*
* Animates scrollTop from the current position to the target over a fixed
* duration using a cubic ease-out curve. Calling scrollTo() mid-flight
* restarts cleanly from wherever the container currently sits, so fast
* line changes never look jerky or skip.
*/
export class EaseScroller {
private container : HTMLElement;
private startY = 0;
private targetY = 0;
private startTime = 0;
private rafId: number | null = null;
private readonly duration: number;
constructor(container: HTMLElement, duration = 650) {
this.container = container;
this.targetY = container.scrollTop;
this.duration = duration;
}
scrollTo(y: number) {
this.startY = this.container.scrollTop;
this.targetY = Math.max(0, y);
this.startTime = performance.now();
if (this.rafId === null) this.rafId = requestAnimationFrame(this.tick);
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
jump(y: number) {
this.stop();
this.container.scrollTop = y;
this.targetY = y;
}
private tick = (now: number) => {
const t = Math.min((now - this.startTime) / this.duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
this.container.scrollTop = this.startY + (this.targetY - this.startY) * ease;
if (t < 1) {
this.rafId = requestAnimationFrame(this.tick);
} else {
this.container.scrollTop = this.targetY;
this.rafId = null;
}
};
}
/**
* Compute the scroll position that places `el` at `fraction` from the top
* of `container` (0 = top edge, 0.35 = Apple Music-style, 0.5 = centre).
*/
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;
}
+249
View File
@@ -0,0 +1,249 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
export const MINI_WINDOW_LABEL = 'mini';
export interface MiniTrackInfo {
id: string;
title: string;
artist: string;
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
year?: number;
}
export interface MiniSyncPayload {
track: MiniTrackInfo | null;
queue: MiniTrackInfo[];
queueIndex: number;
isPlaying: boolean;
volume: number;
gaplessEnabled: boolean;
crossfadeEnabled: boolean;
infiniteQueueEnabled: boolean;
isMobile: false;
}
export type MiniControlAction =
| 'toggle'
| 'next'
| 'prev'
| 'show-main';
function toMini(t: any): MiniTrackInfo {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
year: t.year,
};
}
function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState();
const a = useAuthStore.getState();
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
isPlaying: s.isPlaying,
volume: s.volume,
gaplessEnabled: !!a.gaplessEnabled,
crossfadeEnabled: !!a.crossfadeEnabled,
infiniteQueueEnabled: !!a.infiniteQueueEnabled,
isMobile: false,
};
}
/**
* Bridge initialised on the main window. Pushes track/state changes to the
* mini window whenever they matter, and handles control events coming back
* from the mini window.
*
* Returns a cleanup function.
*/
export function initMiniPlayerBridgeOnMain(): () => void {
// Only run on the main window
if (getCurrentWindow().label !== 'main') return () => {};
// Push state to the mini window on every relevant store change.
let last = '';
const push = () => {
const payload = snapshot();
const queueIds = payload.queue.map(q => q.id).join(',');
const key = [
payload.track?.id ?? '',
payload.isPlaying,
payload.track?.starred ?? '',
payload.queueIndex,
payload.volume,
payload.gaplessEnabled,
payload.crossfadeEnabled,
payload.infiniteQueueEnabled,
queueIds,
].join('|');
if (key === last) return;
last = key;
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
};
const unsub = usePlayerStore.subscribe((state, prev) => {
if (state.currentTrack?.id !== prev.currentTrack?.id
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex
|| state.queue !== prev.queue
|| state.volume !== prev.volume) {
push();
}
});
// Toolbar toggles (gapless / crossfade / infinite queue) live in authStore;
// subscribe so changes from the main window propagate to the mini.
const unsubAuth = useAuthStore.subscribe((state, prev) => {
if (state.gaplessEnabled !== prev.gaplessEnabled
|| state.crossfadeEnabled !== prev.crossfadeEnabled
|| state.infiniteQueueEnabled !== prev.infiniteQueueEnabled) {
push();
}
});
// Push an initial snapshot whenever a new mini window announces itself.
const readyUnlisten = listen('mini:ready', () => {
last = '';
push();
});
// Receive control actions from the mini window.
const controlUnlisten = listen<MiniControlAction>('mini:control', (e) => {
const action = e.payload;
const store = usePlayerStore.getState();
switch (action) {
case 'toggle': store.togglePlay(); break;
case 'next': store.next(true); break;
case 'prev': store.previous(); break;
case 'show-main': {
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
break;
}
}
});
// Jump to a specific queue index.
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1;
if (idx < 0 || idx >= store.queue.length) return;
const track = store.queue[idx];
if (track) store.playTrack(track, store.queue, true);
});
// PsyDnD reorder forwarded from the mini queue.
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
const store = usePlayerStore.getState();
const { from, to } = e.payload ?? { from: -1, to: -1 };
if (from < 0 || from >= store.queue.length) return;
if (to < 0 || to > store.queue.length) return;
if (from === to) return;
store.reorderQueue(from, to);
});
// Remove a track at index (context menu → "Remove from queue").
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1;
if (idx < 0 || idx >= store.queue.length) return;
store.removeTrack(idx);
});
// Navigate the main app to a route. Used by mini context menu actions
// like "Open Album" / "Go to Artist" — those need the full main UI.
const navigateUnlisten = listen<{ to: string }>('mini:navigate', (e) => {
const to = e.payload?.to;
if (!to) return;
// Surface the main window first so the navigation is visible.
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
// React Router lives in main; route via a custom event the AppShell
// picks up (defined in App.tsx).
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
});
// Volume changes from the mini's vertical slider.
const volumeUnlisten = listen<{ value: number }>('mini:set-volume', (e) => {
const v = e.payload?.value;
if (typeof v !== 'number') return;
usePlayerStore.getState().setVolume(Math.max(0, Math.min(1, v)));
});
// Toolbar actions from the mini.
const shuffleUnlisten = listen('mini:shuffle', () => {
usePlayerStore.getState().shuffleQueue();
});
// Gapless ↔ Crossfade are mutually exclusive (see CLAUDE.md). Bridge handles
// the exclusion so the mini doesn't need to know about both states to act.
const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => {
const v = !!e.payload?.value;
const a = useAuthStore.getState();
if (v) a.setCrossfadeEnabled(false);
a.setGaplessEnabled(v);
});
const crossfadeUnlisten = listen<{ value: boolean }>('mini:set-crossfade', (e) => {
const v = !!e.payload?.value;
const a = useAuthStore.getState();
if (v) a.setGaplessEnabled(false);
a.setCrossfadeEnabled(v);
});
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
const v = !!e.payload?.value;
useAuthStore.getState().setInfiniteQueueEnabled(v);
});
// Open the SongInfo modal in main for a given track id.
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
const id = e.payload?.id;
if (!id) return;
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
usePlayerStore.getState().openSongInfo(id);
});
return () => {
unsub();
unsubAuth();
readyUnlisten.then(fn => fn()).catch(() => {});
controlUnlisten.then(fn => fn()).catch(() => {});
jumpUnlisten.then(fn => fn()).catch(() => {});
reorderUnlisten.then(fn => fn()).catch(() => {});
removeUnlisten.then(fn => fn()).catch(() => {});
navigateUnlisten.then(fn => fn()).catch(() => {});
volumeUnlisten.then(fn => fn()).catch(() => {});
shuffleUnlisten.then(fn => fn()).catch(() => {});
gaplessUnlisten.then(fn => fn()).catch(() => {});
crossfadeUnlisten.then(fn => fn()).catch(() => {});
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
songInfoUnlisten.then(fn => fn()).catch(() => {});
};
}
-105
View File
@@ -1,105 +0,0 @@
/**
* 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;
}