Compare commits

..

34 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
38 changed files with 2693 additions and 473 deletions
+13 -2
View File
@@ -240,6 +240,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
@@ -291,7 +292,9 @@ jobs:
# 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]"
@@ -302,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
+52
View File
@@ -13,6 +13,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
> **📦 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.43.0] - 2026-04-20
### Added
- **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.
- **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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.41.0",
"version": "1.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.41.0",
"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.42.1",
"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.42.0
pkgver=1.42.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3653,7 +3653,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.42.1"
version = "1.43.0"
dependencies = [
"biquad",
"discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.42.1"
version = "1.43.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+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();
+215 -8
View File
@@ -242,6 +242,192 @@ async fn delete_radio_cover(
Ok(())
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
struct NdLoginResult {
token: String,
#[serde(rename = "userId")]
user_id: String,
#[serde(rename = "isAdmin")]
is_admin: bool,
}
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
async fn navidrome_login(
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command]
async fn nd_list_users(
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let resp = reqwest::Client::new()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())
}
/// POST `/api/user` — create a user.
#[tauri::command]
async fn nd_create_user(
server_url: String,
token: String,
user_name: String,
name: String,
email: String,
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let body = serde_json::json!({
"userName": user_name,
"name": name,
"email": email,
"password": password,
"isAdmin": is_admin,
});
let resp = reqwest::Client::new()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("HTTP {}: {}", status, text));
}
serde_json::from_str(&text).map_err(|e| e.to_string())
}
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
#[tauri::command]
async fn nd_update_user(
server_url: String,
token: String,
id: String,
user_name: String,
name: String,
email: String,
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
"name": name,
"email": email,
"isAdmin": is_admin,
});
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let resp = reqwest::Client::new()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("HTTP {}: {}", status, text));
}
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
}
/// DELETE `/api/user/{id}`.
#[tauri::command]
async fn nd_delete_user(
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let resp = reqwest::Client::new()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {}", status, text));
}
Ok(())
}
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command]
async fn nd_list_libraries(
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let resp = reqwest::Client::new()
.get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())
}
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
async fn nd_set_user_libraries(
server_url: String,
token: String,
id: String,
library_ids: Vec<i64>,
) -> Result<(), String> {
let body = serde_json::json!({ "libraryIds": library_ids });
let resp = reqwest::Client::new()
.put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {}", status, text));
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
@@ -2695,7 +2881,7 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPositi
let m_size = monitor.size();
let win_w = (340.0 * scale).round() as i32;
let win_h = (180.0 * scale).round() as i32;
let win_h = (260.0 * scale).round() as i32;
let margin_x = (24.0 * scale).round() as i32;
let margin_y = (56.0 * scale).round() as i32;
@@ -2719,6 +2905,16 @@ fn build_mini_player_window(
{ true }
};
// Tiling WMs manage window sizes themselves — enforcing a max width
// there fights the compositor. Everywhere else we cap the width so
// a horizontal drag can't stretch the layout across a whole monitor.
let cap_width = {
#[cfg(target_os = "linux")]
{ !is_tiling_wm() }
#[cfg(not(target_os = "linux"))]
{ true }
};
// Resolve target position BEFORE building so the WM places the window
// correctly from creation. Calling `set_position` after `build()` is
// unreliable on several Linux WMs which re-centre hidden windows.
@@ -2743,14 +2939,22 @@ fn build_mini_player_window(
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 180.0)
.min_inner_size(320.0, 180.0)
.inner_size(340.0, 260.0)
.min_inner_size(320.0, 240.0)
.resizable(true)
.decorations(use_decorations)
.always_on_top(use_always_on_top)
.skip_taskbar(false)
.visible(visible);
// Cap width so horizontal drag can't stretch the layout across a whole
// monitor. Height is intentionally left effectively unlimited so users
// can grow the queue list as tall as they want. Skipped on tiling WMs
// since those manage window sizing themselves.
if cap_width {
builder = builder.max_inner_size(400.0, 4096.0);
}
if let Some(pos) = target_physical {
builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale);
}
@@ -2781,9 +2985,7 @@ fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// was pre-created at startup (Windows), this is a pure show/hide. On
/// other platforms the window is created lazily on first call.
/// Opening the mini player minimizes the main window; hiding the mini
/// player restores the main window. Both steps are skipped on Windows
/// because creating + immediately minimizing main stalls WebView2's paint
/// pipeline and locks up the Tauri event loop.
/// player restores the main window.
#[tauri::command]
fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let win = match app.get_webview_window("mini") {
@@ -2794,7 +2996,6 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let visible = win.is_visible().unwrap_or(false);
if visible {
win.hide().map_err(|e| e.to_string())?;
#[cfg(not(target_os = "windows"))]
if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize();
let _ = main.show();
@@ -2813,7 +3014,6 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(p) = target {
let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y));
}
#[cfg(not(target_os = "windows"))]
if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize();
}
@@ -3180,6 +3380,13 @@ pub fn run() {
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
navidrome_login,
nd_list_users,
nd_create_user,
nd_update_user,
nd_delete_user,
nd_list_libraries,
nd_set_user_libraries,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
+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.42.1",
"version": "1.43.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+8 -2
View File
@@ -56,7 +56,7 @@ import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
import { IS_LINUX, IS_WINDOWS } 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';
@@ -112,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).
@@ -145,6 +150,7 @@ 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.
@@ -351,7 +357,7 @@ function AppShell() {
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}
+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 });
}
+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,
);
}
+27 -51
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(
+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.
+203 -49
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
import { 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';
@@ -12,13 +12,13 @@ import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
const COLLAPSED_SIZE = { w: 340, h: 180 };
const EXPANDED_SIZE = { w: 340, h: 440 };
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: 180 };
const EXPANDED_MIN = { w: 320, h: 260 };
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.
@@ -53,6 +53,7 @@ function toMini(t: any): MiniTrackInfo {
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
year: t.year,
};
}
@@ -69,10 +70,18 @@ function initialSnapshot(): MiniSyncPayload {
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, isMobile: false };
return {
track: null, queue: [], queueIndex: 0, isPlaying: false,
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
infiniteQueueEnabled: false, isMobile: false,
};
}
}
@@ -99,8 +108,11 @@ export default function MiniPlayer() {
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
@@ -223,6 +235,7 @@ export default function MiniPlayer() {
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);
@@ -239,6 +252,35 @@ export default function MiniPlayer() {
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);
@@ -348,16 +390,6 @@ export default function MiniPlayer() {
// action buttons sit right.
<span className="mini-player__titlebar-spacer" />
)}
<button
type="button"
className={`mini-player__titlebar-btn${queueOpen ? ' mini-player__titlebar-btn--active' : ''}`}
onClick={toggleQueue}
data-tauri-drag-region="false"
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
>
<ListMusic size={13} />
</button>
<button
type="button"
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
@@ -395,47 +427,147 @@ export default function MiniPlayer() {
</div>
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player__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__body" data-tauri-drag-region="false">
<div className="mini-player__titles">
<div className="mini-player__meta-text" data-tauri-drag-region="false">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
</div>
<div className="mini-player__artist" title={track?.artist}>
{track?.artist ?? ''}
</div>
</div>
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
<SkipForward size={16} />
</button>
{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__progress" data-tauri-drag-region="false">
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
<div 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>
<div className="mini-player__progress-time">{fmt(duration)}</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 && (
@@ -532,6 +664,28 @@ export default function MiniPlayer() {
</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}
+47 -2
View File
@@ -101,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;
@@ -150,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">
@@ -410,4 +449,10 @@ export default function PlayerBar() {
</footer>
);
if (floatingPlayerBar) {
return createPortal(playerBarContent, document.body);
}
return playerBarContent;
}
-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;
+38 -1
View File
@@ -467,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.',
@@ -524,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',
@@ -627,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: '—',
@@ -736,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',
},
+38 -1
View File
@@ -469,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.',
@@ -526,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',
@@ -629,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',
@@ -738,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',
},
+38 -1
View File
@@ -460,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.',
@@ -517,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',
@@ -620,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',
@@ -729,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',
},
+38 -1
View File
@@ -457,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.',
@@ -514,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',
@@ -615,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: '—',
@@ -724,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',
},
+38 -1
View File
@@ -457,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.',
@@ -515,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',
@@ -614,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',
@@ -723,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',
},
+38 -1
View File
@@ -456,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.',
@@ -513,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',
@@ -614,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: '—',
@@ -723,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',
},
+38 -1
View File
@@ -474,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.',
@@ -533,7 +568,6 @@ export const ruTranslation = {
offlineDirClear: 'Сбросить по умолчанию',
offlineDirHint: 'Новые загрузки пойдут в выбранную папку. Старые останутся на прежнем месте.',
hotCacheTitle: 'Горячий кэш воспроизведения',
hotCacheAlphaBadge: 'Альфа',
hotCacheDisclaimer: 'Заранее подгружает следующие треки из очереди и сохраняет предыдущие. При включении использует место на диске и сеть.',
hotCacheDirDefault: 'По умолчанию (данные приложения)',
hotCacheDirChange: 'Сменить папку',
@@ -642,6 +676,7 @@ export const ruTranslation = {
randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».',
tabInput: 'Ввод',
tabServer: 'Сервер',
tabUsers: 'Пользователи',
tabSystem: 'Система',
tabGeneral: 'Общие',
ratingsSectionTitle: 'Рейтинги',
@@ -753,6 +788,8 @@ export const ruTranslation = {
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать Битрейт',
showBitrateSub: 'Отображать битрейт аудио в списках треков',
floatingPlayerBar: 'Плавающая панель плеера',
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
},
+38 -1
View File
@@ -452,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。',
@@ -509,7 +544,6 @@ export const zhTranslation = {
offlineDirClear: '重置为默认',
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
hotCacheTitle: '热播放缓存',
hotCacheAlphaBadge: '预览',
hotCacheDisclaimer: '预加载队列中即将播放的曲目并保留近期已播放的。开启后占用磁盘与网络。',
hotCacheDirDefault: '默认(应用数据)',
hotCacheDirChange: '更改目录',
@@ -610,6 +644,7 @@ export const zhTranslation = {
randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。',
tabInput: '输入',
tabServer: '服务器',
tabUsers: '用户',
tabSystem: '系统',
tabGeneral: '通用',
ratingsSectionTitle: '评分',
@@ -719,6 +754,8 @@ export const zhTranslation = {
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率',
showBitrateSub: '在曲目列表中显示音频比特率',
floatingPlayerBar: '浮动播放栏',
floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
},
+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>
+525 -25
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';
@@ -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';
@@ -140,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)',
],
},
{
@@ -160,6 +169,8 @@ const CONTRIBUTORS = [
'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)',
],
},
{
@@ -180,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();
@@ -240,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`;
@@ -419,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; }
@@ -667,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} /> },
@@ -1774,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>
@@ -2280,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}
@@ -2426,6 +2917,14 @@ export default function Settings() {
)}
{/* ── System ───────────────────────────────────────────────────────────── */}
{activeTab === 'users' && ndAdminAuth && (
<UserManagementSection
serverUrl={ndAdminAuth.serverUrl}
token={ndAdminAuth.token}
currentUsername={ndAdminAuth.username}
/>
)}
{activeTab === 'system' && (
<>
<BackupSection />
@@ -2684,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);
@@ -2695,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;
@@ -2717,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();
@@ -2817,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;
+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);
+4
View File
@@ -26,6 +26,8 @@ interface ThemeState {
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 {
@@ -67,6 +69,8 @@ export const useThemeStore = create<ThemeState>()(
setShowRemainingTime: (v) => set({ showRemainingTime: v }),
expandReplayGain: false,
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
floatingPlayerBar: false,
setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }),
}),
{
name: 'psysonic_theme',
+182 -118
View File
@@ -2430,7 +2430,7 @@
}
.settings-section {
margin-bottom: var(--space-8);
margin-bottom: var(--space-5);
}
.settings-section-header {
@@ -2438,11 +2438,11 @@
align-items: center;
gap: var(--space-2);
color: var(--accent);
margin-bottom: var(--space-3);
margin-bottom: var(--space-2);
}
.settings-section-header h2 {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
@@ -2451,7 +2451,20 @@
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
padding: var(--space-3);
}
.settings-card.user-row {
transition: background 120ms ease, border-color 120ms ease;
}
.settings-card.user-row:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
border-color: color-mix(in srgb, var(--accent) 45%, var(--border-subtle));
}
.settings-card.user-row:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.settings-hint {
@@ -2645,6 +2658,7 @@
.lyrics-pane {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
/* scroll-behavior intentionally left to auto — JS drives smooth scroll imperatively */
padding: 16px 16px 8px;
scrollbar-width: thin;
@@ -2680,6 +2694,9 @@
border-radius: 6px;
line-height: 1.55;
cursor: default;
word-break: break-word;
overflow-wrap: break-word;
min-width: 0;
transform-origin: center center;
/* All transitions are compositor-friendly */
transition:
@@ -2726,7 +2743,7 @@
display: inline;
transition: color 160ms ease, text-shadow 160ms ease;
color: inherit;
white-space: pre;
white-space: pre-wrap;
}
.lyrics-word.played {
color: var(--text-secondary);
@@ -2820,12 +2837,19 @@
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 2px var(--space-3);
margin: 0 calc(var(--space-3) * -1);
border-radius: var(--radius-sm);
transition: background 120ms ease;
}
.settings-toggle-row:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.settings-section-divider {
border: none;
border-top: 1px solid var(--border);
margin: var(--space-3) 0;
margin: var(--space-2) 0;
}
/* ─ Sidebar Customizer ─ */
@@ -3529,6 +3553,7 @@
overflow-x: hidden;
scrollbar-width: none;
padding: 0 clamp(28px, 5vw, 72px);
overscroll-behavior-x: none;
/* pointer-events on children only — container itself scrollable */
pointer-events: auto;
}
@@ -3562,6 +3587,8 @@
user-select: none;
word-break: break-word;
overflow-wrap: break-word;
min-width: 0;
max-width: 100%;
transform-origin: left center;
/* All visual changes are compositor-friendly (color, text-shadow, transform) */
transition:
@@ -3603,7 +3630,7 @@
.fsa-lyric-word {
display: inline;
white-space: pre; /* preserve trailing spaces from lyricsplus tokens */
white-space: pre-wrap; /* preserves trailing spaces from lyricsplus tokens, allows wrapping */
transition: color 160ms ease, text-shadow 160ms ease;
}
@@ -3621,6 +3648,12 @@
pointer-events: none;
}
/* Fade the bottom of the scroll viewport when showing plain lyrics */
.fsa-lyrics-container--plain {
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
.fsa-plain-line {
font-size: clamp(1.1rem, 2.5vh, 2.2rem);
font-weight: 500;
@@ -3854,22 +3887,6 @@
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
html.no-compositing .fsr-lyrics-rail { will-change: auto; }
/* Apple-style FS lyrics strip GPU-only effects.
Without compositing, transform: scale() on every line forces a per-line
software repaint on each active-line change, and the 48 px / 32 px blur
text-shadows repaint on every word tick. Replace with opacity + colour,
which are cheap text-only repaints. */
html.no-compositing .fsa-lyric-line {
transform: none;
transition: color 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 380ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
html.no-compositing .fsa-lyric-line.fsal-past { transform: none; }
html.no-compositing .fsa-lyric-line.fsal-active { transform: none; text-shadow: none; }
html.no-compositing .fsa-lyric-line.fsal-active .fsa-lyric-word.active {
text-shadow: none;
}
/* no-compositing overrides
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with
@@ -3894,19 +3911,10 @@ html.no-compositing .fs-portrait {
}
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
pointer-events: none on wrap means the overlay never blocks clicks.
Also drop translateZ(0) useless GPU layer hint without a compositor. */
pointer-events: none on wrap means the overlay never blocks clicks. */
html.no-compositing .fs-portrait-wrap {
-webkit-mask-image: none;
mask-image: none;
transform: none;
}
/* FS art crossfade: opacity transition compositing is GPU-cheap, software-expensive.
Snap instantly between covers; visually a hard cut, but the previous layer is
replaced before the user can really notice. */
html.no-compositing .fs-art {
transition: none;
}
html.no-compositing .fs-portrait-wrap::before {
content: '';
@@ -3924,16 +3932,12 @@ html.no-compositing .fs-portrait-wrap::before {
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
/* Mesh blobs: stop animations + flatten to a solid dark fill.
Even static, the 130 × 130 % radial-gradient with color-mix() is recomputed
on every paint, and the `transition: background 400ms` on accent change
re-rasterises the giant surface for nearly half a second per track switch.
Atmosphere is sacrificed; the dynamic accent is preserved on title /
seekbar / controls via their own var(--dynamic-fs-accent) bindings. */
/* Mesh blobs: stop animations in software mode each frame composites surfaces
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
displays this saturates the CPU and drops the player to ~10 FPS.
Static gradients are preserved; only the slow pan animation is removed. */
html.no-compositing .fs-mesh-blob {
animation: none;
background: #06060e;
transition: none;
}
/* Seekbar played bar: remove box-shadow its width changes on every playback
@@ -3942,45 +3946,6 @@ html.no-compositing .fs-seekbar-played {
box-shadow: none;
}
/* Track title: drop the 40 px text-shadow glow paints a giant blur on a
clamp(2868 px) font and re-rasterises whenever anything around it
redraws (cluster updates, seekbar tick, dynamic accent change). */
html.no-compositing .fs-track-title {
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.65);
transition: color 200ms ease-in-out;
}
/* Album art wrap: replace the accent-coloured outer glow with a flat
shadow. The 28 px blur was repainting every accent change. */
html.no-compositing .fs-art-wrap {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.07);
transition: none;
}
/* Play button: drop accent glow + hover filter. Keep flat dark shadow only. */
html.no-compositing .fs-btn-play {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
}
html.no-compositing .fs-btn-play:hover {
filter: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.65);
}
/* Rail-style FS lyrics: same problems as Apple style 28 px text-shadow
blur on every active line + every active word, plus per-line transform
scaleX. Strip them; rail still scrolls fine via the parent translateY. */
html.no-compositing .fsr-lyric-line {
transition: color 280ms ease;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active {
text-shadow: none;
transform: none;
}
html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
text-shadow: none;
}
/* Chat */
.chat-popup {
position: absolute;
@@ -7814,11 +7779,11 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
.folder-col-row.selected {
background: var(--accent);
color: #fff;
color: oklch(from var(--accent) clamp(0, (0.58 - l) * 100, 1) 0 h);
}
.folder-col-row.selected .folder-col-chevron {
color: rgba(255, 255, 255, 0.7);
color: oklch(from var(--accent) clamp(0, (0.58 - l) * 100, 1) 0 h / 0.7);
}
.folder-col-icon {
@@ -9065,10 +9030,21 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
background: var(--bg-app);
color: var(--text-primary);
user-select: none;
-webkit-user-select: none;
overflow: hidden;
box-sizing: border-box;
}
/* Belt-and-suspenders: WebKit/WebView2 occasionally lets descendants pick up
user-select: auto from UA defaults (especially inside <button>). Force it
off for every node in the mini so neither mouse-drag nor Ctrl+A can ever
highlight track meta or titlebar text. */
.mini-player-shell,
.mini-player-shell * {
user-select: none;
-webkit-user-select: none;
}
/* Custom in-page titlebar.
Win/Linux: full bar with drag region, track title, and action buttons.
macOS: slim transparent bar holding only the action buttons; the
@@ -9130,26 +9106,27 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
.mini-player {
flex: 1;
display: grid;
grid-template-columns: 84px 1fr;
grid-template-rows: 84px auto;
gap: 8px 10px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
overflow: hidden;
box-sizing: border-box;
min-height: 0;
}
.mini-player--queue-open {
grid-template-rows: 84px auto 1fr;
.mini-player__meta {
display: flex;
gap: 12px;
align-items: stretch;
flex-shrink: 0;
}
.mini-player__art {
grid-column: 1;
grid-row: 1;
aspect-ratio: 1;
flex: 0 0 84px;
width: 84px;
height: 84px;
aspect-ratio: 1;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
@@ -9166,31 +9143,25 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
}
.mini-player__body {
grid-column: 2;
grid-row: 1;
padding: 0 2px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
.mini-player__meta-text {
flex: 1;
min-width: 0;
}
.mini-player__titles {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 0;
}
.mini-player__title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player__artist {
.mini-player__artist,
.mini-player__album,
.mini-player__year {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
@@ -9198,6 +9169,90 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
text-overflow: ellipsis;
}
.mini-player__toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 0 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.mini-player__toolbar-sep {
width: 1px;
height: 18px;
background: var(--border-subtle);
margin: 0 2px;
flex-shrink: 0;
}
.mini-player__volume-wrap {
position: relative;
display: inline-flex;
}
.mini-player__volume-popover {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 50;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 10px 6px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
.mini-player__volume-pct {
font-size: 10px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 28px;
text-align: center;
}
.mini-player__volume-bar {
position: relative;
width: 5px;
height: 110px;
background: var(--ctp-surface1);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
/* Visual hit-box wider than the strip so dragging is forgiving. */
padding: 0 6px;
background-clip: content-box;
box-sizing: content-box;
}
.mini-player__volume-bar-fill {
position: absolute;
bottom: 0;
left: 6px;
width: 5px;
background: var(--volume-accent, var(--accent));
border-radius: 3px;
pointer-events: none;
}
.mini-player__bottom {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
/* When the queue is closed there is no flex-grow sibling to fill the
extra space push the bottom to the actual bottom of the player so
the controls + progress always sit on the floor. When the queue IS
open, queue takes flex: 1 and this margin collapses to 0. */
margin-top: auto;
}
.mini-player__controls {
display: flex;
align-items: center;
@@ -9232,8 +9287,6 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
}
.mini-player__progress {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
gap: 6px;
@@ -9259,13 +9312,13 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
.mini-queue-wrap {
grid-column: 1 / -1;
grid-row: 3;
flex: 1 1 auto;
position: relative;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
min-height: 0;
margin-top: 10px;
}
.mini-queue {
@@ -9384,20 +9437,31 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 30px;
height: 30px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.mini-player__tool:hover {
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-primary);
cursor: pointer;
flex-shrink: 0;
transition: background var(--transition-fast), color var(--transition-fast);
}
.mini-player__tool:hover:not(:disabled) {
background: var(--border);
color: var(--accent);
}
.mini-player__tool:disabled {
opacity: 0.35;
cursor: default;
}
.mini-player__tool--active {
color: var(--accent);
color: var(--ctp-base);
background: var(--accent);
}
.mini-player__tool--active:hover:not(:disabled) {
color: var(--ctp-base);
background: var(--accent);
}
html[data-app-hidden="true"] *,
+141 -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 */
@@ -1031,6 +1068,13 @@
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`
@@ -1152,6 +1196,76 @@
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 {
display: flex;
align-items: center;
@@ -1394,6 +1508,9 @@
.player-volume-slider {
width: 100%;
display: block;
margin: 0;
vertical-align: middle;
}
.player-volume-pct {
+12 -5
View File
@@ -11675,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 */
@@ -11707,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);
}
@@ -11729,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);
}
@@ -11740,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);
}
+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;
}
+74 -2
View File
@@ -1,6 +1,7 @@
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';
@@ -14,6 +15,7 @@ export interface MiniTrackInfo {
coverArt?: string;
duration?: number;
starred?: boolean;
year?: number;
}
export interface MiniSyncPayload {
@@ -21,6 +23,10 @@ export interface MiniSyncPayload {
queue: MiniTrackInfo[];
queueIndex: number;
isPlaying: boolean;
volume: number;
gaplessEnabled: boolean;
crossfadeEnabled: boolean;
infiniteQueueEnabled: boolean;
isMobile: false;
}
@@ -41,16 +47,22 @@ function toMini(t: any): MiniTrackInfo {
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,
};
}
@@ -71,7 +83,17 @@ export function initMiniPlayerBridgeOnMain(): () => void {
const push = () => {
const payload = snapshot();
const queueIds = payload.queue.map(q => q.id).join(',');
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}|${payload.queueIndex}|${queueIds}`;
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(() => {});
@@ -82,7 +104,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex
|| state.queue !== prev.queue) {
|| 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();
}
});
@@ -153,6 +186,39 @@ export function initMiniPlayerBridgeOnMain(): () => void {
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;
@@ -166,12 +232,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
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;
}