perf(linux): WebKit probe, throttled progress IPC, snapshot playback UI (#452)

* feat(linux): optional native GDK for Nix gdk-session

Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11
pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from
the npm tauri:dev script so it does not override nix develop defaults.

* fix(ui): portal server switch menu above sidebar

Main column stacks below the sidebar (layout z-index), so an in-tree dropdown
could never win over the left nav. Render the menu via createPortal to
document.body with fixed coordinates, matching the library scope picker.

* feat(perf): add mainstage probe controls and cut WebKit repaint load

Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback.

* fix(perf): stop hero rotation when section is off-screen

Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible.

* fix(perf): isolate player progress updates from mainstage diagnostics

Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver.
Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only.

* fix(hero): resume background and autoplay after viewport return

Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(perf): decouple playback progress from mainstage compositing pressure

Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes.
Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling.

* fix(debug): open performance probe with Ctrl+Shift+D

Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation.

* docs(changelog): document experiment/performance probe and playback work

Add an [Unreleased] section for the performance probe, throttled audio
progress IPC, snapshot-based live UI updates, WaveformSeek scheduling
over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix
GDK dev ergonomics.

* perf(linux): add WebKit probe, throttle progress IPC, snapshot playback UI

Ship Performance Probe (Ctrl+Shift+D), Rust-throttled audio:progress, a
playback progress snapshot channel with coarse Zustand timeline commits,
Linux /proc CPU readout for the probe, Hero and Home rail artwork fixes,
Tracks SongRail windowing parity, MPRIS cleanup, gated perf counters, and
WaveformSeek paused-seek correctness. Documented in CHANGELOG for PR #452.

* docs(changelog): fold perf work into 1.45.0 and refresh date

Drop the separate 1.45.1 heading; keep PR #452 notes under 1.45.0 Added and
set the section date to 2026-05-04. Restore the safety preface before the
versioned sections.

* docs(changelog): order 1.45.0 Added entries by PR number

Sort the 1.45.0 release notes so subsections follow ascending PR id (390
through 452), with PR #452 last.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cucadmuh
2026-05-04 20:06:49 +03:00
committed by GitHub
parent 00045f755c
commit a6cc2e2ad4
32 changed files with 2184 additions and 408 deletions
+52 -44
View File
@@ -14,10 +14,24 @@ 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. > **📦 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.45.0] - 2026-05-01 ## [1.45.0] - 2026-05-04
## Added ## Added
### Themes — Kanagawa, Atom One and 1984 Palettes
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#390](https://github.com/Psychotoxical/psysonic/pull/390)**
Open Source Classics gains three new theme families: **Kanagawa**, **Atom One** and **1984**, adding nine new themes in total.
* **Kanagawa:** Wave, Dragon and Lotus
* **Atom One:** Dark and Light
* **1984:** Default, Cyberpunk, Light and Orwell
Each theme defines the full token set, including background, accent, text, Catppuccin compatibility, waveform, status and select-arrow tokens. This lets login, queue sidebar and subpages inherit the palette cleanly without component-specific overrides.
The theme picker now groups Open Source Classics by family with dedicated family headings. Theme scheduler dropdown labels are also family-prefixed, making it clearer which palette family a scheduled theme belongs to.
### Audio Preview — Rust Preview Engine and Tracklist Rollout ### Audio Preview — Rust Preview Engine and Tracklist Rollout
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#392](https://github.com/Psychotoxical/psysonic/pull/392), [#394](https://github.com/Psychotoxical/psysonic/pull/394)** **By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#392](https://github.com/Psychotoxical/psysonic/pull/392), [#394](https://github.com/Psychotoxical/psysonic/pull/394)**
@@ -50,7 +64,6 @@ Play Next in the context menu now uses a double-chevron icon, making it visually
The feature includes updated i18n coverage across all supported locales, including the new player-bar preview labels. The feature includes updated i18n coverage across all supported locales, including the new player-bar preview labels.
### Tray — Now Playing Tooltip and Localized Menu Labels ### Tray — Now Playing Tooltip and Localized Menu Labels
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#395](https://github.com/Psychotoxical/psysonic/pull/395), closes [#383](https://github.com/Psychotoxical/psysonic/issues/383)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#395](https://github.com/Psychotoxical/psysonic/pull/395), closes [#383](https://github.com/Psychotoxical/psysonic/issues/383)**
@@ -61,22 +74,6 @@ On Linux, where AppIndicator does not expose a hover-tooltip API, the same now-p
Tray menu labels are now localized across all supported languages, including Play/Pause, Next/Previous, Show/Hide, Exit and the Linux-only empty-state label. The frontend updates the tray labels on startup and whenever the app language changes, without rebuilding the tray icon. Tray menu labels are now localized across all supported languages, including Play/Pause, Next/Previous, Show/Hide, Exit and the Linux-only empty-state label. The frontend updates the tray labels on startup and whenever the app language changes, without rebuilding the tray icon.
### Themes — Kanagawa, Atom One and 1984 Palettes
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#390](https://github.com/Psychotoxical/psysonic/pull/390)**
Open Source Classics gains three new theme families: **Kanagawa**, **Atom One** and **1984**, adding nine new themes in total.
* **Kanagawa:** Wave, Dragon and Lotus
* **Atom One:** Dark and Light
* **1984:** Default, Cyberpunk, Light and Orwell
Each theme defines the full token set, including background, accent, text, Catppuccin compatibility, waveform, status and select-arrow tokens. This lets login, queue sidebar and subpages inherit the palette cleanly without component-specific overrides.
The theme picker now groups Open Source Classics by family with dedicated family headings. Theme scheduler dropdown labels are also family-prefixed, making it clearer which palette family a scheduled theme belongs to.
### Sidebar Discovery Indicators ### Sidebar Discovery Indicators
**By [@cucadmuh](https://github.com/cucadmuh), PR [#397](https://github.com/Psychotoxical/psysonic/pull/397)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#397](https://github.com/Psychotoxical/psysonic/pull/397)**
@@ -97,27 +94,6 @@ The top header behavior was reworked for narrow widths: search, Live and Orbit c
Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling. Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling.
### Queue — Drag Outside to Remove
**By [@cucadmuh](https://github.com/cucadmuh), PR [#420](https://github.com/Psychotoxical/psysonic/pull/420)**
You can remove a track from the play queue by dragging its row **outside** the queue sidebar (main window) or outside the mini players queue list. Drop targets still support reordering when you release inside the queue area.
The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself.
### Statistics — Shareable Top-Albums Card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#425](https://github.com/Psychotoxical/psysonic/pull/425)**
Statistics page can now export your most-played albums as a shareable PNG, accessible via a share icon next to the **Most Played Albums** section header. Three aspect ratios for different platforms (Story 9:16, Square 1:1, Twitter Card 16:9), three grid sizes (3×3, 4×4, 5×5), with each cover carrying a thin info strip showing rank + play count.
The card pulls the wordmark and accent color directly from the active theme, so a Catppuccin export looks Catppuccin and a Nord export looks Nord. Cover art reuses the existing IndexedDB cache, so no extra Subsonic round-trips on repeat exports. The header label is hardcoded English ("Top Albums") so a shared image stays legible to followers regardless of their language.
Saving uses the native OS save dialog — no silent dump into Downloads, the user picks the path each time. Data source is local-only (Subsonic `getAlbumList(frequent)`); Last.fm is intentionally not used. There is no time-window selector because Navidrome's API exposes only cumulative play counts, not per-event play history.
### Queue Panel — Position Counter, Tri-State Duration Toggle, Collapsible Now Playing, EQ Indicator ### Queue Panel — Position Counter, Tri-State Duration Toggle, Collapsible Now Playing, EQ Indicator
**By [@kveld9](https://github.com/kveld9), PR [#419](https://github.com/Psychotoxical/psysonic/pull/419)** **By [@kveld9](https://github.com/kveld9), PR [#419](https://github.com/Psychotoxical/psysonic/pull/419)**
@@ -130,6 +106,23 @@ A new chevron next to the queue title **collapses the Now Playing section and qu
The currently playing row in the queue list is now indicated by **animated equalizer bars** to the left of the track title; the bars freeze in place when playback is paused. The previous small play icon next to the title is removed since the EQ bars carry the same signal more clearly. The currently playing row in the queue list is now indicated by **animated equalizer bars** to the left of the track title; the bars freeze in place when playback is paused. The previous small play icon next to the title is removed since the EQ bars carry the same signal more clearly.
### Queue — Drag Outside to Remove
**By [@cucadmuh](https://github.com/cucadmuh), PR [#420](https://github.com/Psychotoxical/psysonic/pull/420)**
You can remove a track from the play queue by dragging its row **outside** the queue sidebar (main window) or outside the mini players queue list. Drop targets still support reordering when you release inside the queue area.
The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself.
### Statistics — Shareable Top-Albums Card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#425](https://github.com/Psychotoxical/psysonic/pull/425)**
Statistics page can now export your most-played albums as a shareable PNG, accessible via a share icon next to the **Most Played Albums** section header. Three aspect ratios for different platforms (Story 9:16, Square 1:1, Twitter Card 16:9), three grid sizes (3×3, 4×4, 5×5), with each cover carrying a thin info strip showing rank + play count.
The card pulls the wordmark and accent color directly from the active theme, so a Catppuccin export looks Catppuccin and a Nord export looks Nord. Cover art reuses the existing IndexedDB cache, so no extra Subsonic round-trips on repeat exports. The header label is hardcoded English ("Top Albums") so a shared image stays legible to followers regardless of their language.
Saving uses the native OS save dialog — no silent dump into Downloads, the user picks the path each time. Data source is local-only (Subsonic `getAlbumList(frequent)`); Last.fm is intentionally not used. There is no time-window selector because Navidrome's API exposes only cumulative play counts, not per-event play history.
### Shortcuts — Action Registry, Dynamic CLI Help, New Input Targets ### Shortcuts — Action Registry, Dynamic CLI Help, New Input Targets
@@ -141,7 +134,6 @@ Nine new input actions were added (requested by zunoz on Discord): start search,
Translations for the new action labels follow in a separate i18n nachhol-PR (de, fr, nl, zh, nb, ru, es). Translations for the new action labels follow in a separate i18n nachhol-PR (de, fr, nl, zh, nb, ru, es).
### Settings — 3-State Animation Mode (Full / Reduced / Static) ### Settings — 3-State Animation Mode (Full / Reduced / Static)
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#441](https://github.com/Psychotoxical/psysonic/pull/441), suggested by harumscarum on Telegram** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#441](https://github.com/Psychotoxical/psysonic/pull/441), suggested by harumscarum on Telegram**
@@ -154,7 +146,6 @@ The boolean **Reduce animations** toggle in **Settings → Appearance** is now a
Existing users with `reducedAnimations: true` are migrated 1:1 to **Reduced** on first launch; everyone else lands on **Full**. The picker is in the same place as before. A contextual hint below the picker explains what the selected mode does. Existing users with `reducedAnimations: true` are migrated 1:1 to **Reduced** on first launch; everyone else lands on **Full**. The picker is in the same place as before. A contextual hint below the picker explains what the selected mode does.
### Tracks — Highly Rated Rail and Per-Card Star Display ### Tracks — Highly Rated Rail and Per-Card Star Display
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#443](https://github.com/Psychotoxical/psysonic/pull/443), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#443](https://github.com/Psychotoxical/psysonic/pull/443), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)**
@@ -165,7 +156,6 @@ Every song card across the app whose rating is greater than zero now shows a sma
Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button. Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button.
### Random Mix — Playlist Size Selector and Filter Panel Cleanup ### Random Mix — Playlist Size Selector and Filter Panel Cleanup
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#445](https://github.com/Psychotoxical/psysonic/pull/445), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#445](https://github.com/Psychotoxical/psysonic/pull/445), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)**
@@ -176,20 +166,38 @@ The filter panel layout was tightened up at the same time: two sub-headings (**M
Under the hood, `fetchRandomMixSongsUntilFull` now scales batch size, max-batch ceiling and dup-streak budget with the requested target — so a 150-track mix can finish in a single round-trip on most libraries instead of stalling out at ~120. Under the hood, `fetchRandomMixSongsUntilFull` now scales batch size, max-batch ceiling and dup-streak budget with the requested target — so a 150-track mix can finish in a single round-trip on most libraries instead of stalling out at ~120.
### UI — Bulk Entity Ratings, Random Albums Multi-Select, Album New Badge ### UI — Bulk Entity Ratings, Random Albums Multi-Select, Album New Badge
**By [@cucadmuh](https://github.com/cucadmuh), PR [#446](https://github.com/Psychotoxical/psysonic/pull/446)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#446](https://github.com/Psychotoxical/psysonic/pull/446)**
Multi-album and multi-artist **context menus** now include a shared star-rating row for the current selection (mixed ratings show empty until you set a value; keyboard navigation supported), with new aria-label strings across locales. **Random Albums** passes the active selection into each **AlbumCard** so the same bulk context menu works from the roll grid. The album **New** badge moves to the **top-right** of the cover and **stacks** with the offline badge so the two no longer overlap. Multi-album and multi-artist **context menus** now include a shared star-rating row for the current selection (mixed ratings show empty until you set a value; keyboard navigation supported), with new aria-label strings across locales. **Random Albums** passes the active selection into each **AlbumCard** so the same bulk context menu works from the roll grid. The album **New** badge moves to the **top-right** of the cover and **stacks** with the offline badge so the two no longer overlap.
### NixOS — Flake: X11-wrapped default vs session GDK ### NixOS — Flake: X11-wrapped default vs session GDK
**By [@cucadmuh](https://github.com/cucadmuh), PR [#447](https://github.com/Psychotoxical/psysonic/pull/447)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#447](https://github.com/Psychotoxical/psysonic/pull/447)**
The flake exposes two Linux installables: **`psysonic`** / **`default`** pins **`GDK_BACKEND=x11`** for a stable GTK/WebKit stack on mixed Wayland setups; **`psysonic-gdk-session`** drops that override so GDK follows the session (native Wayland where the stack supports it). **[nixos-install.md](nixos-install.md)** documents trade-offs and **zsh-safe** quoting for `nix run 'github:…#…'` URLs. The flake exposes two Linux installables: **`psysonic`** / **`default`** pins **`GDK_BACKEND=x11`** for a stable GTK/WebKit stack on mixed Wayland setups; **`psysonic-gdk-session`** drops that override so GDK follows the session (native Wayland where the stack supports it). **[nixos-install.md](nixos-install.md)** documents trade-offs and **zsh-safe** quoting for `nix run 'github:…#…'` URLs.
### Linux / WebKit — performance probe, progress IPC, and UI isolation
**By [@cucadmuh](https://github.com/cucadmuh), PR [#452](https://github.com/Psychotoxical/psysonic/pull/452)**
* **Performance Probe** — Modal at **Ctrl+Shift+D** (sidebar logo is decorative only). Collapsible Phase 1/2 and an open-by-default Phase 3 for the toggles used most in profiling. Flags persist in `localStorage`, map to `data-perf-*` on the document root, and can disable targeted subsystems (shell/network hooks, mainstage sections, PlayerBar waveform only, live progress UI, rail artwork, and similar) to isolate WebKit/WebProcess CPU on Linux.
* **Approximate live CPU (Linux)** — Tauri command reading `/proc` (including WebKit helper process names) for rough host CPU share while the probe is open.
* **`getPlaybackProgressSnapshot` / `subscribePlaybackProgress`** — Live time, seekbars, lyrics, and related UI subscribe without writing every progress tick into the persisted player store.
* **Perf telemetry gating** — Hot-path counters (`audioProgressEvents`, `waveformDraws`, `homeCommits`) increment in development builds always, and in production only while the Performance Probe is open, avoiding extra global writes during normal playback.
* **`audio:progress` (Rust)** — Throttled by minimum interval and position delta, with immediate emit on pause transitions, to reduce IPC during playback.
* **Persisted player store**`currentTime` / `progress` / `buffered` commits are coarse-grained; live UI reads the snapshot channel instead.
* **`WaveformSeek`** — Same **`<canvas>`** **2D `fillRect`** bar renderer as before (not pre-rendered bitmap layers). Progress is fed from the snapshot channel; sparse backend ticks are bridged with prediction/smoothing and a capped repaint cadence; animated preview ticks no longer stop solely because the window lost focus while still visible; static styles stay aligned with external seeks while **paused** by syncing the visual progress ref to the snapshot when the interpolation `rAF` loop is not running.
* **MPRIS** — Position updates while playing use the snapshot channel on a conservative cadence; play/pause transitions send **snapshot** time (not coarse store `currentTime`). Removed the redundant store-driven position branch that could push stale values after timeline coarsening.
* **Home / card artwork** — Album and song cards always use **`CachedImage`** for covers; removed unused `directImageSrc` plumbing from `Home`, `AlbumRow`, `SongRail`, `AlbumCard`, and `SongCard`.
* **Tracks** — Highly Rated and Random Mix **`SongRail`** rows enable the same horizontal **artwork windowing** defaults as Home.
* **Hero** — Auto-advance respects visibility in the app scroll viewport, pauses when the window is blurred, and recovers after returning on-screen or on focus/visibility changes.
* **Home `AlbumRow` / `SongRail`** — Artwork visibility budget uses real card geometry so covers fill the viewport without requiring an initial horizontal scroll nudge.
* **Server switch menu** — Portaled with fixed coordinates so it stacks above the sidebar.
* **Linux / Nix**`PSYSONIC_ALLOW_NATIVE_GDK` skips the default `GDK_BACKEND=x11` pin when using the `gdk-session` wrapper; `tauri:dev` no longer forces `GDK_BACKEND` over `nix develop` defaults.
## Fixed ## Fixed
+4 -1
View File
@@ -165,13 +165,16 @@ stdenv.mkDerivation (finalAttrs: {
gdkX11Wrap = lib.optionalString forceGdkX11 '' gdkX11Wrap = lib.optionalString forceGdkX11 ''
--set GDK_BACKEND x11 \ --set GDK_BACKEND x11 \
''; '';
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
'';
in in
'' ''
wrapProgram $out/bin/psysonic \ wrapProgram $out/bin/psysonic \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \ --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \ --prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \ --prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
${gdkX11Wrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \ ${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
--set WEBKIT_DISABLE_DMABUF_RENDERER 1 --set WEBKIT_DISABLE_DMABUF_RENDERER 1
''; '';
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev", "tauri:dev": "tauri dev",
"tauri:build": "tauri build", "tauri:build": "tauri build",
"test": "vitest run" "test": "vitest run"
}, },
+20
View File
@@ -963,12 +963,19 @@ fn spawn_progress_task(
0.0 0.0
} }
// Keep near-end detection at 100 ms, but throttle progress IPC to webview.
const PROGRESS_EMIT_MIN_MS: u64 = 1500;
const PROGRESS_EMIT_MIN_DELTA_SECS: f64 = 0.9;
tokio::spawn(async move { tokio::spawn(async move {
let mut near_end_ticks: u32 = 0; let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition. // Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done; let mut current_done = initial_done;
// Local sample counter; swapped to chained source's counter on transition. // Local sample counter; swapped to chained source's counter on transition.
let mut samples_played = samples_played; let mut samples_played = samples_played;
let mut last_progress_emit_at = Instant::now() - Duration::from_millis(PROGRESS_EMIT_MIN_MS);
let mut last_progress_emit_pos = -1.0f64;
let mut last_progress_emit_paused = false;
loop { loop {
// 100 ms tick keeps near-end detection timely for crossfade/gapless // 100 ms tick keeps near-end detection timely for crossfade/gapless
@@ -1070,7 +1077,20 @@ fn spawn_progress_task(
}; };
let pos = (pos_raw - progress_latency).max(0.0); let pos = (pos_raw - progress_latency).max(0.0);
let now = Instant::now();
let should_emit_progress = if is_paused != last_progress_emit_paused {
true
} else if now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS) {
true
} else {
(pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS
};
if should_emit_progress {
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
last_progress_emit_at = now;
last_progress_emit_pos = pos;
last_progress_emit_paused = is_paused;
}
if is_paused { if is_paused {
continue; continue;
+1
View File
@@ -849,6 +849,7 @@ pub fn run() {
set_logging_mode, set_logging_mode,
export_runtime_logs, export_runtime_logs,
frontend_debug_log, frontend_debug_log,
performance_cpu_snapshot,
set_subsonic_wire_user_agent, set_subsonic_wire_user_agent,
no_compositing_mode, no_compositing_mode,
is_tiling_wm_cmd, is_tiling_wm_cmd,
+109
View File
@@ -1,4 +1,15 @@
use super::*; use super::*;
use serde::Serialize;
use std::fs;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PerformanceCpuSnapshot {
pub supported: bool,
pub total_jiffies: u64,
pub app_jiffies: u64,
pub webkit_jiffies: u64,
pub logical_cpus: u32,
}
#[tauri::command] #[tauri::command]
pub(crate) fn greet(name: &str) -> String { pub(crate) fn greet(name: &str) -> String {
@@ -121,4 +132,102 @@ pub(crate) fn set_subsonic_wire_user_agent(
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")]
fn parse_proc_stat_line(stat_line: &str) -> Option<(String, i32, u64, u64)> {
let close_idx = stat_line.rfind(')')?;
let open_idx = stat_line.find('(')?;
if open_idx + 1 >= close_idx {
return None;
}
let comm = stat_line.get(open_idx + 1..close_idx)?.to_string();
let after = stat_line.get(close_idx + 2..)?;
let mut parts = after.split_whitespace();
let _state = parts.next()?;
let ppid = parts.next()?.parse::<i32>().ok()?;
let rest: Vec<&str> = parts.collect();
// After `state` and `ppid`, remaining fields start at `pgrp` (field #5).
// `utime` = field #14 => rest[9], `stime` = field #15 => rest[10].
let utime = rest.get(9)?.parse::<u64>().ok()?;
let stime = rest.get(10)?.parse::<u64>().ok()?;
Some((comm, ppid, utime, stime))
}
#[cfg(target_os = "linux")]
fn read_total_jiffies() -> Option<u64> {
let content = fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().next()?;
let mut it = line.split_whitespace();
if it.next()? != "cpu" {
return None;
}
Some(it.filter_map(|n| n.parse::<u64>().ok()).sum())
}
#[cfg(target_os = "linux")]
fn collect_proc_stats() -> Vec<(i32, String, i32, u64)> {
let mut rows = Vec::new();
let entries = match fs::read_dir("/proc") {
Ok(v) => v,
Err(_) => return rows,
};
for entry in entries.flatten() {
let name = entry.file_name();
let pid = match name.to_string_lossy().parse::<i32>() {
Ok(v) => v,
Err(_) => continue,
};
let stat_path = format!("/proc/{pid}/stat");
let stat_line = match fs::read_to_string(stat_path) {
Ok(v) => v,
Err(_) => continue,
};
if let Some((comm, ppid, utime, stime)) = parse_proc_stat_line(stat_line.trim()) {
rows.push((pid, comm, ppid, utime.saturating_add(stime)));
}
}
rows
}
#[tauri::command]
pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot {
#[cfg(target_os = "linux")]
{
let total_jiffies = read_total_jiffies().unwrap_or(0);
let logical_cpus = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(1);
let self_pid = std::process::id() as i32;
let rows = collect_proc_stats();
let app_jiffies = rows
.iter()
.find(|(pid, _, _, _)| *pid == self_pid)
.map(|(_, _, _, ticks)| *ticks)
.unwrap_or(0);
let webkit_jiffies = rows
.iter()
// Linux `/proc/*/stat` `comm` is capped to 15 chars, so
// "WebKitWebProcess" appears as "WebKitWebProces".
.filter(|(_, comm, ppid, _)| comm.starts_with("WebKitWebProces") && *ppid == self_pid)
.map(|(_, _, _, ticks)| *ticks)
.sum::<u64>();
return PerformanceCpuSnapshot {
supported: true,
total_jiffies,
app_jiffies,
webkit_jiffies,
logical_cpus,
};
}
#[cfg(not(target_os = "linux"))]
{
PerformanceCpuSnapshot {
supported: false,
total_jiffies: 0,
app_jiffies: 0,
webkit_jiffies: 0,
logical_cpus: 1,
}
}
}
+7 -3
View File
@@ -41,8 +41,9 @@ fn detect_gpu_vendor() -> Option<GpuVendor> {
} }
fn main() { fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages. // WebKitGTK on Wayland can be unstable — default to X11 when GDK_BACKEND is unset,
// Users can still override by setting these vars before launch. // except when PSYSONIC_ALLOW_NATIVE_GDK is set (e.g. Nix psysonic-gdk-session wrapper).
// Users can still override by setting GDK_BACKEND before launch.
// //
// Safety: set_var modifies global process state. These calls are safe here // 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 // because we're in main() before the Tauri runtime starts — no other threads
@@ -50,7 +51,10 @@ fn main() {
// need synchronization or marking as unsafe (Rust 2024+). // need synchronization or marking as unsafe (Rust 2024+).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if std::env::var("GDK_BACKEND").is_err() { // Nix `psysonic-gdk-session` sets this so we do not pin X11 when the packager asked for
// session-native GDK (e.g. Wayland). Local dev can export the same var before `tauri dev`.
let allow_native_gdk = std::env::var("PSYSONIC_ALLOW_NATIVE_GDK").is_ok();
if std::env::var("GDK_BACKEND").is_err() && !allow_native_gdk {
std::env::set_var("GDK_BACKEND", "x11"); std::env::set_var("GDK_BACKEND", "x11");
} }
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
+41 -6
View File
@@ -90,6 +90,7 @@ import i18n from './i18n';
import { switchActiveServer } from './utils/switchActiveServer'; import { switchActiveServer } from './utils/switchActiveServer';
import { import {
usePlayerStore, usePlayerStore,
getPlaybackProgressSnapshot,
initAudioListeners, initAudioListeners,
songToTrack, songToTrack,
shuffleArray, shuffleArray,
@@ -107,6 +108,7 @@ import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPl
import { matchInAppShortcutAction } from './shortcuts/runtime'; import { matchInAppShortcutAction } from './shortcuts/runtime';
import ZipDownloadOverlay from './components/ZipDownloadOverlay'; import ZipDownloadOverlay from './components/ZipDownloadOverlay';
import PasteClipboardHandler from './components/PasteClipboardHandler'; import PasteClipboardHandler from './components/PasteClipboardHandler';
import { usePerfProbeFlags } from './utils/perfFlags';
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed'; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
@@ -233,6 +235,7 @@ function AppShell() {
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
// Mini player → main: route requests dispatched as `psy:navigate` // Mini player → main: route requests dispatched as `psy:navigate`
// CustomEvents from the bridge land here so React Router can take over. // CustomEvents from the bridge land here so React Router can take over.
@@ -657,6 +660,9 @@ function AppShell() {
railInset="panel" railInset="panel"
> >
<Suspense fallback={null}> <Suspense fallback={null}>
{perfFlags.disableMainRouteContentMount ? (
<div style={{ minHeight: '60vh' }} />
) : (
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} /> <Route path="/albums" element={<Albums />} />
@@ -688,6 +694,7 @@ function AppShell() {
<Route path="/folders" element={<FolderBrowser />} /> <Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} /> <Route path="/device-sync" element={<DeviceSync />} />
</Routes> </Routes>
)}
</Suspense> </Suspense>
</OverlayScrollArea> </OverlayScrollArea>
</div> </div>
@@ -738,7 +745,7 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />} {isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
</button> </button>
)} )}
{!isMobile && <QueuePanel />} {!isMobile && !perfFlags.disableQueuePanelMount && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />} {isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />} {!isMobilePlayer && <PlayerBar />}
{isFullscreenOpen && ( {isFullscreenOpen && (
@@ -750,7 +757,7 @@ function AppShell() {
<GlobalConfirmModal /> <GlobalConfirmModal />
<OrbitAccountPicker /> <OrbitAccountPicker />
<OrbitHelpModal /> <OrbitHelpModal />
<TooltipPortal /> {!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater /> <AppUpdater />
</div> </div>
); );
@@ -1075,9 +1082,10 @@ function TauriEventBridge() {
{ {
const u = await listen<number>('media:seek-relative', e => { const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
const p = getPlaybackProgressSnapshot();
const dur = s.currentTrack?.duration; const dur = s.currentTrack?.duration;
if (!dur) return; if (!dur) return;
s.seek(Math.max(0, s.currentTime + e.payload) / dur); s.seek(Math.max(0, p.currentTime + e.payload) / dur);
}); });
if (cancelled) { u(); return; } if (cancelled) { u(); return; }
unlisten.push(u); unlisten.push(u);
@@ -1152,6 +1160,11 @@ function TauriEventBridge() {
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically). // `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
useEffect(() => { useEffect(() => {
let tid: ReturnType<typeof setTimeout> | undefined; let tid: ReturnType<typeof setTimeout> | undefined;
let lastPublishAt = 0;
let lastStableKey = '';
let lastPlaying = false;
const SNAPSHOT_PLAYING_HEARTBEAT_MS = 4000;
const SNAPSHOT_IDLE_HEARTBEAT_MS = 15000;
const publish = () => { const publish = () => {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
const auth = useAuthStore.getState(); const auth = useAuthStore.getState();
@@ -1171,7 +1184,7 @@ function TauriEventBridge() {
queue_index: s.queueIndex, queue_index: s.queueIndex,
queue_length: s.queue.length, queue_length: s.queue.length,
is_playing: s.isPlaying, is_playing: s.isPlaying,
current_time: s.currentTime, current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume, volume: s.volume,
repeat_mode: s.repeatMode, repeat_mode: s.repeatMode,
current_track_user_rating: currentTrackUserRating, current_track_user_rating: currentTrackUserRating,
@@ -1183,11 +1196,32 @@ function TauriEventBridge() {
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })), folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
}, },
}; };
const stableKey = JSON.stringify({
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
queueLength: s.queue.length,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
serverId: sid ?? null,
selected,
currentTrackUserRating,
currentTrackStarred,
});
const now = Date.now();
const heartbeatMs = s.isPlaying ? SNAPSHOT_PLAYING_HEARTBEAT_MS : SNAPSHOT_IDLE_HEARTBEAT_MS;
const stableChanged = stableKey !== lastStableKey;
const playingEdge = s.isPlaying !== lastPlaying;
if (!stableChanged && !playingEdge && now - lastPublishAt < heartbeatMs) return;
lastStableKey = stableKey;
lastPlaying = s.isPlaying;
lastPublishAt = now;
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {}); invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
}; };
publish(); publish();
const schedule = () => { const schedule = () => {
if (tid !== undefined) clearTimeout(tid); if (tid !== undefined) return;
tid = setTimeout(() => { tid = setTimeout(() => {
tid = undefined; tid = undefined;
publish(); publish();
@@ -1210,6 +1244,7 @@ export default function App() {
const effectiveTheme = useThemeScheduler(); const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font); const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false); const [exportPickerOpen, setExportPickerOpen] = useState(false);
const perfFlags = usePerfProbeFlags();
// Mini Player window: detected via Tauri window label. Rendered without // Mini Player window: detected via Tauri window label. Rendered without
// router / sidebar / full audio listeners — it just listens for state + sends // router / sidebar / full audio listeners — it just listens for state + sends
@@ -1293,7 +1328,7 @@ export default function App() {
<DragDropProvider> <DragDropProvider>
<MiniPlayer /> <MiniPlayer />
<GlobalConfirmModal /> <GlobalConfirmModal />
<TooltipPortal /> {!perfFlags.disableTooltipPortal && <TooltipPortal />}
</DragDropProvider> </DragDropProvider>
); );
} }
+30 -5
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react'; import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -18,9 +18,20 @@ interface AlbumCardProps {
onToggleSelect?: (id: string) => void; onToggleSelect?: (id: string) => void;
showRating?: boolean; showRating?: boolean;
selectedAlbums?: SubsonicAlbum[]; selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
artworkSize?: number;
} }
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) { function AlbumCard({
album,
selected,
selectionMode,
onToggleSelect,
showRating = false,
selectedAlbums = [],
disableArtwork = false,
artworkSize = 300,
}: AlbumCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -31,7 +42,15 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
if (!meta || meta.trackIds.length === 0) return false; if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
}); });
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; // buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created); const isNewAlbum = isAlbumRecentlyAdded(album.created);
@@ -73,8 +92,14 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
}} }}
> >
<div className="album-card-cover"> <div className="album-card-cover">
{coverUrl ? ( {!disableArtwork && coverUrl ? (
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" /> <CachedImage
src={coverUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
loading="lazy"
decoding="async"
/>
) : ( ) : (
<div className="album-card-cover-placeholder"> <div className="album-card-cover-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
+78 -5
View File
@@ -4,6 +4,7 @@ import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props { interface Props {
title: string; title: string;
@@ -15,23 +16,63 @@ interface Props {
showRating?: boolean; showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */ /** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode; headerExtra?: React.ReactNode;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
} }
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) { export default function AlbumRow({
title,
titleLink,
albums,
moreLink,
moreText,
onLoadMore,
showRating,
headerExtra,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false); const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true); const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.album-card, .artist-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 170;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => { const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return; if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0); setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5); setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
// Auto-load trigger // Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) { if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
@@ -49,10 +90,27 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
}; };
useEffect(() => { useEffect(() => {
if (interactivityDisabled) return;
handleScroll(); handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll); window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll); const ro = new ResizeObserver(() => {
}, [albums]); recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, albums.length]);
const scroll = (dir: 'left' | 'right') => { const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
@@ -74,6 +132,8 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
)} )}
<div className="album-row-nav"> <div className="album-row-nav">
{headerExtra} {headerExtra}
{!interactivityDisabled && (
<>
<button <button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`} className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')} onClick={() => scroll('left')}
@@ -88,12 +148,25 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
> >
<ChevronRight size={20} /> <ChevronRight size={20} />
</button> </button>
</>
)}
</div> </div>
</div> </div>
<div className="album-grid-wrapper"> <div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}> <div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)} {albums.map((a, idx) => (
<AlbumCard
key={a.id}
album={a}
showRating={showRating}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
{loadingMore && ( {loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}> <div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}> <div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+48 -5
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
@@ -21,14 +22,37 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null); const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1; const multi = servers.length > 1;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setMenuFixed({ top: r.bottom + 6, right: window.innerWidth - r.right });
}, []);
useLayoutEffect(() => {
if (!menuOpen) return;
updateMenuPosition();
const onWin = () => updateMenuPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [menuOpen, updateMenuPosition]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (hostRef.current?.contains(e.target as Node)) return; const t = e.target as Node;
if (hostRef.current?.contains(t)) return;
if (menuPanelRef.current?.contains(t)) return;
setMenuOpen(false); setMenuOpen(false);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -103,11 +127,23 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</span> </span>
</div> </div>
</div> </div>
{multi && menuOpen && ( {multi &&
menuOpen &&
typeof document !== 'undefined' &&
createPortal(
<div <div
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel" className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu" role="menu"
aria-label={t('connection.switchServerTitle')} aria-label={t('connection.switchServerTitle')}
style={{
position: 'fixed',
top: menuFixed.top,
right: menuFixed.right,
minWidth: 220,
maxWidth: 'min(320px, 85vw)',
zIndex: 10050,
}}
> >
<div <div
style={{ style={{
@@ -145,12 +181,19 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</button> </button>
); );
})} })}
<div style={{ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)', marginTop: 2, paddingTop: 2 }} /> <div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}> <button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span> <span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
<span className="nav-library-dropdown-check-spacer" aria-hidden /> <span className="nav-library-dropdown-check-spacer" aria-hidden />
</button> </button>
</div> </div>,
document.body
)} )}
</div> </div>
); );
+9 -9
View File
@@ -4,7 +4,7 @@ import {
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal, ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
Moon, Sunrise, Moon, Sunrise,
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache'; import { getCachedBlob } from '../utils/imageCache';
@@ -88,8 +88,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
setActiveIdx(idx); setActiveIdx(idx);
} }
}; };
apply(usePlayerStore.getState().currentTime); apply(getPlaybackProgressSnapshot().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime)); return subscribePlaybackProgress(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]); }, [hasSynced, currentTrack?.id]);
// Ease-scroll active line to ~35% from the top of the container. // Ease-scroll active line to ~35% from the top of the container.
@@ -129,8 +129,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
} }
prevWord.current = { line: li, word: wi }; prevWord.current = { line: li, word: wi };
}; };
apply(usePlayerStore.getState().currentTime); apply(getPlaybackProgressSnapshot().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime)); return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]); }, [useWords, wordLines]);
const handleUserScroll = useCallback(() => { const handleUserScroll = useCallback(() => {
@@ -275,8 +275,8 @@ const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack
} }
prevWord.current = { line: li, word: wi }; prevWord.current = { line: li, word: wi };
}; };
apply(usePlayerStore.getState().currentTime); apply(getPlaybackProgressSnapshot().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime)); return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]); }, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null; if (!currentTrack || loading || !hasSynced) return null;
@@ -457,14 +457,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
}, [seek]); }, [seek]);
useEffect(() => { useEffect(() => {
const s = usePlayerStore.getState(); const s = getPlaybackProgressSnapshot();
const pct = s.progress * 100; const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime); if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`; if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress); if (inputRef.current) inputRef.current.value = String(s.progress);
return usePlayerStore.subscribe(state => { return subscribePlaybackProgress(state => {
if (isDraggingRef.current) return; if (isDraggingRef.current) return;
const p = state.progress * 100; const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime); if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
+120 -6
View File
@@ -11,6 +11,7 @@ import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perfFlags';
const INTERVAL_MS = 10000; const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8; const HERO_ALBUM_COUNT = 8;
@@ -55,6 +56,7 @@ interface HeroProps {
} }
export default function Hero({ albums: albumsProp }: HeroProps = {}) { export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -67,6 +69,102 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility(); const windowHidden = useWindowVisibility();
const [windowBlurred, setWindowBlurred] = useState<boolean>(() => Boolean(window.__psyBlurred));
const heroRef = useRef<HTMLDivElement | null>(null);
const heroScrollRootRef = useRef<HTMLElement | null>(null);
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
const node = heroRef.current;
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) {
return false;
}
const root = heroScrollRootRef.current;
const viewportTop = root ? root.getBoundingClientRect().top : 0;
const viewportBottom = root ? root.getBoundingClientRect().bottom : window.innerHeight;
const overlap = Math.max(0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop));
// Consider hero visible only when at least a meaningful slice is on screen.
const minVisiblePx = Math.min(56, rect.height * 0.2);
return overlap >= minVisiblePx;
}, []);
const updateHeroVisibility = useCallback(() => {
const visible = computeHeroVisibleNow();
setHeroInView(prev => (prev === visible ? prev : visible));
}, [computeHeroVisibleNow]);
useEffect(() => {
const node = heroRef.current;
if (!node) return;
// Prefer the nearest actual scrolling ancestor; class fallback for safety.
let scrollRoot: HTMLElement | null = null;
let parent = node.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
const overflowY = styles.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight + 2) {
scrollRoot = parent;
break;
}
parent = parent.parentElement;
}
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
updateHeroVisibility();
});
};
const onResize = () => updateHeroVisibility();
const onFocusLike = () => updateHeroVisibility();
root?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
window.addEventListener('focus', onFocusLike);
document.addEventListener('visibilitychange', onFocusLike);
return () => {
root?.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
window.removeEventListener('focus', onFocusLike);
document.removeEventListener('visibilitychange', onFocusLike);
if (visibilityRafRef.current != null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
};
}, [updateHeroVisibility]);
useEffect(() => {
const updateBlurState = () => {
setWindowBlurred(Boolean(window.__psyBlurred));
};
window.addEventListener('focus', updateBlurState);
window.addEventListener('blur', updateBlurState);
updateBlurState();
return () => {
window.removeEventListener('focus', updateBlurState);
window.removeEventListener('blur', updateBlurState);
};
}, []);
useEffect(() => {
if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside
// viewport, keep checking briefly so autoplay/background resume immediately
// after returning into view.
const id = window.setInterval(() => {
updateHeroVisibility();
}, 220);
return () => window.clearInterval(id);
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; } if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -93,12 +191,27 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
if (len <= 1 || windowHidden) return; if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return; const visibleNow = computeHeroVisibleNow();
if (!visibleNow && heroInViewRef.current) setHeroInView(false);
if (document.hidden || window.__psyHidden || window.__psyBlurred || !heroInViewRef.current || !visibleNow) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return;
}
setActiveIdx(prev => (prev + 1) % len); setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS); }, INTERVAL_MS);
}, [windowHidden]); }, [windowHidden, windowBlurred, computeHeroVisibleNow]);
useEffect(() => {
// Hard-stop timer immediately when hero leaves viewport.
if (heroInView && !windowBlurred) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [heroInView, windowBlurred]);
useEffect(() => { useEffect(() => {
startTimer(albums.length); startTimer(albums.length);
@@ -106,7 +219,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
}; };
}, [albums.length, startTimer]); }, [albums.length, heroInView, startTimer]);
const goTo = useCallback((idx: number) => { const goTo = useCallback((idx: number) => {
setActiveIdx(idx); setActiveIdx(idx);
@@ -144,14 +257,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
return ( return (
<div <div
ref={heroRef}
className="hero" className="hero"
role="banner" role="banner"
aria-label={t('hero.eyebrow')} aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)} onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />} {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />} {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */} {/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}> <div className="hero-content animate-fade-in" key={album.id}>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import type { LrcLine } from '../api/lrclib'; import type { LrcLine } from '../api/lrclib';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics'; import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -126,8 +126,8 @@ export default function LyricsPane({ currentTrack }: Props) {
prevActive.current = { line: lineIdx, word: wordIdx }; prevActive.current = { line: lineIdx, word: wordIdx };
}; };
apply(usePlayerStore.getState().currentTime); apply(getPlaybackProgressSnapshot().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime)); return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]); }, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
if (!currentTrack) { if (!currentTrack) {
+3 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props { interface Props {
text: string; text: string;
@@ -13,6 +14,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
const textRef = useRef<HTMLSpanElement>(null); const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0); const [scrollAmount, setScrollAmount] = useState(0);
const animationMode = useAuthStore(s => s.animationMode); const animationMode = useAuthStore(s => s.animationMode);
const perfFlags = usePerfProbeFlags();
const measure = useCallback(() => { const measure = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -34,7 +36,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
// In `static` animation mode the marquee never scrolls — overflowing text // In `static` animation mode the marquee never scrolls — overflowing text
// is truncated with an ellipsis (handled by CSS via data-anim-mode). // is truncated with an ellipsis (handled by CSS via data-anim-mode).
const shouldScroll = scrollAmount > 0 && animationMode !== 'static'; const shouldScroll = scrollAmount > 0 && animationMode !== 'static' && !perfFlags.disableMarqueeScroll;
return ( return (
<div <div
+9 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react'; import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -6,7 +6,7 @@ import {
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X, Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise, Moon, Sunrise,
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore'; import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane'; import LyricsPane from './LyricsPane';
@@ -164,8 +164,13 @@ export default function MobilePlayerView() {
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const progress = usePlayerStore(s => s.progress); const playbackProgress = useSyncExternalStore(
const currentTime = usePlayerStore(s => s.currentTime); onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
getPlaybackProgressSnapshot,
getPlaybackProgressSnapshot,
);
const progress = playbackProgress.progress;
const currentTime = playbackProgress.currentTime;
const togglePlay = usePlayerStore(s => s.togglePlay); const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null); const transportAnchorRef = useRef<HTMLDivElement>(null);
+8 -4
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics'; import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb'; import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
import { usePerfProbeFlags } from '../utils/perfFlags';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel'; export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
@@ -56,6 +57,7 @@ export default function OverlayScrollArea({
viewportOnWheel, viewportOnWheel,
viewportOnTouchMove, viewportOnTouchMove,
}: OverlayScrollAreaProps) { }: OverlayScrollAreaProps) {
const perfFlags = usePerfProbeFlags();
const wrapRef = useRef<HTMLDivElement | null>(null); const wrapRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false }); const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
@@ -72,6 +74,7 @@ export default function OverlayScrollArea({
const measureKey = JSON.stringify(measureDeps ?? []); const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
if (!meta.visible) return; if (!meta.visible) return;
const vp = viewportRef.current; const vp = viewportRef.current;
const wrap = wrapRef.current; const wrap = wrapRef.current;
@@ -89,9 +92,10 @@ export default function OverlayScrollArea({
} }
return next; return next;
}); });
}, [meta.visible]); }, [meta.visible, perfFlags.disableOverlayScrollbars]);
useEffect(() => { useEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
recompute(); recompute();
const wrap = wrapRef.current; const wrap = wrapRef.current;
const onWinResize = () => recompute(); const onWinResize = () => recompute();
@@ -105,7 +109,7 @@ export default function OverlayScrollArea({
window.removeEventListener('resize', onWinResize); window.removeEventListener('resize', onWinResize);
ro?.disconnect(); ro?.disconnect();
}; };
}, [recompute, measureKey]); }, [recompute, measureKey, perfFlags.disableOverlayScrollbars]);
const setViewportNode = (el: HTMLDivElement | null) => { const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el; viewportRef.current = el;
@@ -134,13 +138,13 @@ export default function OverlayScrollArea({
id={viewportId} id={viewportId}
ref={setViewportNode} ref={setViewportNode}
className={viewportClass} className={viewportClass}
onScroll={recompute} onScroll={perfFlags.disableOverlayScrollbars ? undefined : recompute}
onWheel={viewportOnWheel} onWheel={viewportOnWheel}
onTouchMove={viewportOnTouchMove} onTouchMove={viewportOnTouchMove}
> >
{children} {children}
</div> </div>
{meta.visible && ( {!perfFlags.disableOverlayScrollbars && meta.visible && (
<div className="overlay-scroll__rail" aria-hidden> <div className="overlay-scroll__rail" aria-hidden>
<div <div
className="overlay-scroll__thumb" className="overlay-scroll__thumb"
+10 -6
View File
@@ -6,7 +6,7 @@ import {
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis, PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
} from 'lucide-react'; } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
@@ -26,6 +26,7 @@ import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore'; import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -40,9 +41,9 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
const spanRef = useRef<HTMLSpanElement>(null); const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => { useEffect(() => {
if (spanRef.current) { if (spanRef.current) {
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime); spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime);
} }
return usePlayerStore.subscribe(state => { return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
}); });
}, []); }, []);
@@ -55,12 +56,12 @@ const RemainingTime = memo(function RemainingTime({ duration, className }: { dur
useEffect(() => { useEffect(() => {
const updateRemaining = () => { const updateRemaining = () => {
if (spanRef.current) { if (spanRef.current) {
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime); const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = `-${formatTime(remaining)}`; spanRef.current.textContent = `-${formatTime(remaining)}`;
} }
}; };
updateRemaining(); updateRemaining();
return usePlayerStore.subscribe(updateRemaining); return subscribePlaybackProgress(updateRemaining);
}, [duration]); }, [duration]);
return <span className={className} ref={spanRef} />; return <span className={className} ref={spanRef} />;
}); });
@@ -117,6 +118,7 @@ export default function PlayerBar() {
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({}); const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
const volumeWheelMenuTimerRef = useRef<number | null>(null); const volumeWheelMenuTimerRef = useRef<number | null>(null);
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false); const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
const perfFlags = usePerfProbeFlags();
useEffect(() => { useEffect(() => {
if (!floatingPlayerBar) return; if (!floatingPlayerBar) return;
@@ -530,7 +532,9 @@ export default function PlayerBar() {
<> <>
<PlaybackTime className="player-time" /> <PlaybackTime className="player-time" />
<div className="player-waveform-wrap"> <div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} /> {perfFlags.disableWaveformCanvas
? <div className="radio-progress-bar" aria-hidden />
: <WaveformSeek trackId={currentTrack?.id} />}
</div> </div>
<span <span
className="player-time player-time-toggle" className="player-time player-time-toggle"
+504 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore'; import { useOfflineJobStore } from '../store/offlineJobStore';
@@ -28,6 +29,8 @@ import {
type SidebarNavDropTarget, type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder'; } from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { resetPerfProbeFlags, setPerfProbeFlag, usePerfProbeFlags } from '../utils/perfFlags';
import { setPerfProbeTelemetryActive } from '../utils/perfTelemetry';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000; const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -81,6 +84,12 @@ export default function Sidebar({
const musicFolders = useAuthStore(s => s.musicFolders); const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer); const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items); const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems); const setSidebarItems = useSidebarStore(s => s.setItems);
@@ -151,6 +160,15 @@ export default function Sidebar({
const newReleasesRefreshSeqRef = useRef(0); const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null); const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null); const newReleasesResetTimerRef = useRef<number | null>(null);
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const perfFlags = usePerfProbeFlags();
const [perfCpu, setPerfCpu] = useState<{ app: number; webkit: number; supported: boolean } | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<{ progress: number; waveform: number; home: number } | null>(null);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
return () => setPerfProbeTelemetryActive(false);
}, [perfProbeOpen]);
const newReleasesSeenStorageKey = useMemo( const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`, () => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
@@ -572,10 +590,113 @@ export default function Sidebar({
}; };
}, [location.pathname, refreshNewReleasesUnread]); }, [location.pathname, refreshNewReleasesUnread]);
useEffect(() => {
if (!perfProbeOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPerfProbeOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
type Snapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
};
let cancelled = false;
let prev: Snapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let timer: number | null = null;
const poll = async () => {
try {
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
if (cancelled) return;
if (!snap.supported) {
setPerfCpu({ app: 0, webkit: 0, supported: false });
return;
}
if (prev) {
const totalDelta = snap.total_jiffies - prev.total_jiffies;
const appDelta = snap.app_jiffies - prev.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
setPerfCpu({
app: Number.isFinite(appPct) ? appPct : 0,
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
supported: true,
});
}
}
const now = Date.now();
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
const nextCounters = {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
setPerfDiagRates({
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
});
}
prevCounters = nextCounters;
prevCountersAt = now;
prev = snap;
} catch {
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
} finally {
if (!cancelled) timer = window.setTimeout(poll, 2000);
}
};
void poll();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
}
}, [perfProbeOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return ( return (
<> <>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}> <aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand"> <div className="sidebar-brand" aria-hidden>
{isCollapsed {isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} /> ? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} /> : <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
@@ -953,6 +1074,388 @@ export default function Sidebar({
</div>, </div>,
document.body, document.body,
)} )}
{perfProbeOpen &&
createPortal(
<div className="modal-overlay modal-overlay--perf-probe" onClick={() => setPerfProbeOpen(false)} role="dialog" aria-modal="true">
<div
className="modal-content sidebar-perf-modal"
onClick={e => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button className="modal-close" onClick={() => setPerfProbeOpen(false)}><X size={18} /></button>
<h3 className="modal-title">Performance Probe</h3>
<p className="sidebar-perf-modal__hint">
Temporary runtime switches to estimate UI effect cost.
</p>
<div className="sidebar-perf-modal__cpu">
<div className="sidebar-perf-modal__cpu-title">Live CPU (approx)</div>
{perfCpu == null ? (
<div className="sidebar-perf-modal__cpu-row">Collecting samples</div>
) : perfCpu.supported ? (
<>
<div className="sidebar-perf-modal__cpu-row">psysonic: {perfCpu.app.toFixed(1)}%</div>
<div className="sidebar-perf-modal__cpu-row">WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%</div>
{perfDiagRates && (
<>
<div className="sidebar-perf-modal__cpu-row">audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">Home commits rate: {perfDiagRates.home.toFixed(1)}/s</div>
</>
)}
</>
) : (
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
)}
</div>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 1 Global / Shell / Network</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMarqueeScroll}
onChange={e => setPerfProbeFlag('disableMarqueeScroll', e.target.checked)}
/>
<span>Disable marquee text scrolling</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackdropBlur}
onChange={e => setPerfProbeFlag('disableBackdropBlur', e.target.checked)}
/>
<span>Disable backdrop blur effects</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableCssAnimations}
onChange={e => setPerfProbeFlag('disableCssAnimations', e.target.checked)}
/>
<span>Disable CSS animations and transitions</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableOverlayScrollbars}
onChange={e => setPerfProbeFlag('disableOverlayScrollbars', e.target.checked)}
/>
<span>Disable overlay scrollbar engine (JS + rail)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableTooltipPortal}
onChange={e => setPerfProbeFlag('disableTooltipPortal', e.target.checked)}
/>
<span>Disable global tooltip portal/listeners</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableQueuePanelMount}
onChange={e => setPerfProbeFlag('disableQueuePanelMount', e.target.checked)}
/>
<span>Disable QueuePanel mount (desktop right column)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackgroundPolling}
onChange={e => setPerfProbeFlag('disableBackgroundPolling', e.target.checked)}
/>
<span>Disable background polling (connection + radio metadata)</span>
</label>
<div className="sidebar-perf-modal__subhead">Engine/network toggles</div>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={!hotCacheEnabled}
onChange={e => setHotCacheEnabled(!e.target.checked)}
/>
<span>Disable hot-cache prefetch downloads</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={normalizationEngine === 'off'}
onChange={e => setNormalizationEngine(e.target.checked ? 'off' : 'loudness')}
/>
<span>Disable normalization engine (set to Off)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={loggingMode === 'off'}
onChange={e => setLoggingMode(e.target.checked ? 'off' : 'normal')}
/>
<span>Set runtime logging mode to Off</span>
</label>
</details>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 2 Mainstage (Center Content)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainRouteContentMount}
onChange={e => setPerfProbeFlag('disableMainRouteContentMount', e.target.checked)}
/>
<span>Disable central route content mount</span>
</label>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Shared mainstage layers (multiple pages)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageStickyHeader}
onChange={e => setPerfProbeFlag('disableMainstageStickyHeader', e.target.checked)}
/>
<span>Disable sticky headers (Tracks + Albums)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Home (`/`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Home hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeAlbumRows}
onChange={e => setPerfProbeFlag('disableHomeAlbumRows', e.target.checked)}
/>
<span>Disable Home `AlbumRow` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeSongRails}
onChange={e => setPerfProbeFlag('disableHomeSongRails', e.target.checked)}
/>
<span>Disable Home `SongRail` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Home rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Home discover artists chip-grid</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Tracks (`/tracks`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Tracks hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Tracks rails (Highly Rated + Random)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Tracks rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Tracks rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageVirtualLists}
onChange={e => setPerfProbeFlag('disableMainstageVirtualLists', e.target.checked)}
/>
<span>Disable Tracks virtual browse list (`VirtualSongList`)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Albums (`/albums`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Albums card grid (`AlbumCard` list)</span>
</label>
</details>
</details>
<details className="sidebar-perf-modal__phase" open>
<summary className="sidebar-perf-modal__phase-title">Phase 3 Active diagnostics (quick access)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
</details>
<div className="sidebar-perf-modal__actions">
<button type="button" className="btn btn-ghost" onClick={() => resetPerfProbeFlags()}>
Reset
</button>
<button type="button" className="btn btn-primary" onClick={() => setPerfProbeOpen(false)}>
Close
</button>
</div>
</div>
</div>,
document.body,
)}
</> </>
); );
} }
+16 -5
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react'; import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star } from 'lucide-react'; import { Play, ListPlus, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -11,14 +11,24 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
interface SongCardProps { interface SongCardProps {
song: SubsonicSong; song: SubsonicSong;
disableArtwork?: boolean;
artworkSize?: number;
} }
function SongCard({ song }: SongCardProps) { function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : ''; // buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
@@ -74,12 +84,13 @@ function SongCard({ song }: SongCardProps) {
}} }}
> >
<div className="song-card-cover"> <div className="song-card-cover">
{coverUrl ? ( {!disableArtwork && coverUrl ? (
<CachedImage <CachedImage
src={coverUrl} src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)} cacheKey={coverCacheKey}
alt={`${song.album} Cover`} alt={`${song.album} Cover`}
loading="lazy" loading="lazy"
decoding="async"
/> />
) : ( ) : (
<div className="song-card-cover-placeholder"> <div className="song-card-cover-placeholder">
+73 -6
View File
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic'; import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard'; import SongCard from './SongCard';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props { interface Props {
title: string; title: string;
@@ -12,25 +13,79 @@ interface Props {
loading?: boolean; loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */ /** Empty-state copy when songs is empty AND not loading. */
emptyText?: string; emptyText?: string;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
} }
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) { export default function SongRail({
title,
songs,
onReroll,
loading,
emptyText,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 10,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false); const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true); const [showRight, setShowRight] = useState(true);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.song-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 140;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => { const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return; if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0); setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5); setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
}; };
useEffect(() => { useEffect(() => {
if (interactivityDisabled) return;
handleScroll(); handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll); window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll); const ro = new ResizeObserver(() => {
}, [songs]); recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, songs.length]);
const scroll = (dir: 'left' | 'right') => { const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
@@ -58,6 +113,8 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} /> <RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button> </button>
)} )}
{!interactivityDisabled && (
<>
<button <button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`} className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')} onClick={() => scroll('left')}
@@ -72,6 +129,8 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
> >
<ChevronRight size={20} /> <ChevronRight size={20} />
</button> </button>
</>
)}
</div> </div>
</div> </div>
@@ -79,9 +138,17 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
{songs.length === 0 && emptyText ? ( {songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p> <p className="song-row-empty">{emptyText}</p>
) : ( ) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}> <div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{songs.map(s => ( {songs.map((s, idx) => (
<SongCard key={s.id} song={s} /> <SongCard
key={s.id}
song={s}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))} ))}
</div> </div>
)} )}
+214 -58
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useAuthStore, type SeekbarStyle } from '../store/authStore'; import { useAuthStore, type SeekbarStyle } from '../store/authStore';
import { bumpPerfCounter } from '../utils/perfTelemetry';
function fmt(s: number): string { function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00'; if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`; return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
@@ -15,6 +16,9 @@ const WAVE_MIX_MAX = 0.3;
const SEG_COUNT = 60; const SEG_COUNT = 60;
const FLAT_WAVE_NORM = 0.06; const FLAT_WAVE_NORM = 0.06;
const WAVE_MORPH_MS = 1000; const WAVE_MORPH_MS = 1000;
const STATIC_REDRAW_MIN_MS = 90;
const STATIC_REDRAW_FORCE_MS = 220;
const INTERPOLATION_PAINT_MIN_MS = 80;
// ── animation state ─────────────────────────────────────────────────────────── // ── animation state ───────────────────────────────────────────────────────────
@@ -40,13 +44,38 @@ const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'li
// ── color helper ────────────────────────────────────────────────────────────── // ── color helper ──────────────────────────────────────────────────────────────
function getColors() { type SeekbarColors = {
const s = getComputedStyle(document.documentElement); played: string;
return { buffered: string;
unplayed: string;
};
let cachedColors: SeekbarColors | null = null;
let cachedColorsKey = '';
function invalidateColorCache() {
cachedColors = null;
}
function getColors(): SeekbarColors {
const root = document.documentElement;
const style = root.style;
const key = [
root.getAttribute('data-theme') ?? '',
style.getPropertyValue('--accent'),
style.getPropertyValue('--waveform-played'),
style.getPropertyValue('--waveform-buffered'),
style.getPropertyValue('--waveform-unplayed'),
].join('|');
if (cachedColors && cachedColorsKey === key) return cachedColors;
const s = getComputedStyle(root);
cachedColorsKey = key;
cachedColors = {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7', played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086', buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244', unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
}; };
return cachedColors;
} }
// ── canvas setup ────────────────────────────────────────────────────────────── // ── canvas setup ──────────────────────────────────────────────────────────────
@@ -56,9 +85,8 @@ function setupCanvas(
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null { ): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return null; if (!ctx) return null;
const rect = canvas.getBoundingClientRect(); const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
const w = rect.width || canvas.clientWidth; const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
const h = rect.height || canvas.clientHeight;
if (w === 0 || h === 0) return null; if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr); const pw = Math.round(w * dpr);
@@ -72,6 +100,10 @@ function setupCanvas(
return { ctx, w, h }; return { ctx, w, h };
} }
function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
ctx.shadowBlur = Math.max(0, blur);
}
// ── waveform heights ────────────────────────────────────────────────────────── // ── waveform heights ──────────────────────────────────────────────────────────
function hashStr(str: string): number { function hashStr(str: string): number {
@@ -156,6 +188,15 @@ function waveformBarThickness(logicalH: number, norm: number): number {
return Math.max(1, safeNorm * logicalH); return Math.max(1, safeNorm * logicalH);
} }
function quantizeProgressByBars(progress: number): number {
const clamped = Math.max(0, Math.min(1, progress));
return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT));
}
function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean {
return style === 'truewave' || style === 'pseudowave';
}
function drawWaveform( function drawWaveform(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
heights: Float32Array | null, heights: Float32Array | null,
@@ -166,6 +207,8 @@ function drawWaveform(
if (!r) return; if (!r) return;
const { ctx, w, h } = r; const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors(); const { played, buffered: buffCol, unplayed } = getColors();
const pNorm = Math.max(0, Math.min(1, progress));
const bNorm = Math.max(pNorm, Math.min(1, buffered));
if (!heights) { if (!heights) {
// No waveform data yet: flat rail like `drawLineDot`, but do not return early // No waveform data yet: flat rail like `drawLineDot`, but do not return early
@@ -185,32 +228,30 @@ function drawWaveform(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 5; setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh); ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
if (w > 0) { if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w)); const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w));
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 7; setShadowBlur(ctx, 7);
ctx.beginPath(); ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2); ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
return; return;
} }
const x1Of = (i: number) => (i / BAR_COUNT) * w; const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
ctx.globalAlpha = 0.28; ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed; ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue; if (i / BAR_COUNT < bNorm) continue;
const bh = waveformBarThickness(h, heights[i]); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -220,17 +261,17 @@ function drawWaveform(
ctx.fillStyle = buffCol; ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT; const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue; if (frac < pNorm || frac >= bNorm) continue;
const bh = waveformBarThickness(h, heights[i]); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
if (progress > 0) { if (pNorm > 0) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break; if (i / BAR_COUNT >= pNorm) break;
const bh = waveformBarThickness(h, heights[i]); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -264,12 +305,12 @@ function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: numb
const dx = Math.max(dotR, Math.min(w - dotR, progress * w)); const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 7; setShadowBlur(ctx, 7);
ctx.beginPath(); ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2); ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -300,11 +341,11 @@ function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 5; setShadowBlur(ctx, 5);
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad); ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -336,11 +377,11 @@ function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 10; setShadowBlur(ctx, 10);
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad); ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -359,13 +400,13 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
for (let i = 0; i < SEG_COUNT; i++) { for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT; const frac = i / SEG_COUNT;
const x = i * (segW + gap); const x = i * (segW + gap);
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
if (frac < progress) { if (frac < progress) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
if (i === playedIdx - 1) { if (i === playedIdx - 1) {
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 5; setShadowBlur(ctx, 5);
} }
} else if (frac < buffered) { } else if (frac < buffered) {
ctx.globalAlpha = 0.55; ctx.globalAlpha = 0.55;
@@ -378,7 +419,7 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
ctx.roundRect(x, y, Math.max(1, segW), segH, 1); ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill(); ctx.fill();
} }
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -410,34 +451,34 @@ function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 0.18; ctx.globalAlpha = 0.18;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 22; setShadowBlur(ctx, 22);
ctx.fillRect(0, cy - 5, px, 10); ctx.fillRect(0, cy - 5, px, 10);
// Mid glow // Mid glow
ctx.globalAlpha = 0.45; ctx.globalAlpha = 0.45;
ctx.shadowBlur = 12; setShadowBlur(ctx, 12);
ctx.fillRect(0, cy - 2.5, px, 5); ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow // Inner glow
ctx.globalAlpha = 0.85; ctx.globalAlpha = 0.85;
ctx.shadowBlur = 5; setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - 1.5, px, 3); ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core // Bright white core
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 4; setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 0.75, px, 1.5); ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare // End-cap flare
ctx.shadowBlur = 16; setShadowBlur(ctx, 16);
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2); ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -478,16 +519,16 @@ function drawPulseWave(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 3; setShadowBlur(ctx, 3);
ctx.fillRect(0, cy - 1, startX, 2); ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.strokeStyle = played; ctx.strokeStyle = played;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 7; setShadowBlur(ctx, 7);
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
@@ -499,7 +540,7 @@ function drawPulseWave(
ctx.lineTo(x, cy - wave); ctx.lineTo(x, cy - wave);
} }
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -561,22 +602,22 @@ function drawParticleTrail(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 4; setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px, 2); ctx.fillRect(0, cy - 1, px, 2);
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
// Particles // Particles
ctx.shadowColor = played; ctx.shadowColor = played;
for (const p of animState.particles) { for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85; ctx.globalAlpha = p.life * 0.85;
ctx.shadowBlur = 5; setShadowBlur(ctx, 5);
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
// Playhead dot // Playhead dot
if (progress > 0) { if (progress > 0) {
@@ -584,11 +625,11 @@ function drawParticleTrail(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 10; setShadowBlur(ctx, 10);
ctx.beginPath(); ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2); ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
@@ -660,9 +701,9 @@ function drawLiquidFill(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 9; setShadowBlur(ctx, 9);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
// Glass highlight on top // Glass highlight on top
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45); const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
@@ -720,9 +761,9 @@ function drawRetroTape(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 4; setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px - reelR, 2); ctx.fillRect(0, cy - 1, px - reelR, 2);
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
} }
// Spinning reel at playhead // Spinning reel at playhead
@@ -730,13 +771,13 @@ function drawRetroTape(
ctx.strokeStyle = played; ctx.strokeStyle = played;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.shadowColor = played; ctx.shadowColor = played;
ctx.shadowBlur = 7; setShadowBlur(ctx, 7);
// Outer ring // Outer ring
ctx.beginPath(); ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2); ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
// Hub // Hub
const hubR = Math.max(1.5, reelR * 0.28); const hubR = Math.max(1.5, reelR * 0.28);
@@ -758,7 +799,7 @@ function drawRetroTape(
} }
} }
ctx.shadowBlur = 0; setShadowBlur(ctx, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -772,6 +813,7 @@ export function drawSeekbar(
buffered: number, buffered: number,
animState?: AnimState, animState?: AnimState,
) { ) {
bumpPerfCounter('waveformDraws');
const anim = animState ?? makeAnimState(); const anim = animState ?? makeAnimState();
switch (style) { switch (style) {
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break; case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
@@ -829,7 +871,7 @@ export function SeekbarPreview({
} }
}; };
const tick = () => { const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) { if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => { pollId = window.setTimeout(() => {
pollId = null; pollId = null;
tick(); tick();
@@ -902,17 +944,23 @@ export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_MIN_HOLD_MS = 320; const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02; const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const WHEEL_SEEK_STEP_SECONDS = 10; const WHEEL_SEEK_STEP_SECONDS = 10;
const WHEEL_SEEK_DEBOUNCE_MS = 1000; const WHEEL_SEEK_DEBOUNCE_MS = 350;
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null); const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress); const progressRef = useRef(getPlaybackProgressSnapshot().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered); const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
const visualProgressRef = useRef(progressRef.current);
const visualTargetProgressRef = useRef(progressRef.current);
const isDragging = useRef(false); const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState()); const animStateRef = useRef<AnimState>(makeAnimState());
const lastStaticDrawAtRef = useRef(0);
const lastStaticDrawProgressRef = useRef(-1);
const lastStaticDrawBufferedRef = useRef(-1);
const [hoverPct, setHoverPct] = useState<number | null>(null); const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek); const seek = usePlayerStore(s => s.seek);
const isPlaying = usePlayerStore(s => s.isPlaying);
const waveformBins = usePlayerStore(s => s.waveformBins); const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle); const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -1009,7 +1057,7 @@ export default function WaveformSeek({ trackId }: Props) {
// Imperative subscription — no React re-renders from progress changes. // Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs. // Static styles draw here; animated styles only update refs.
useEffect(() => { useEffect(() => {
return usePlayerStore.subscribe((state, prev) => { return subscribePlaybackProgress((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return; if (state.progress === prev.progress && state.buffered === prev.buffered) return;
// While user drags, keep the local preview stable. External progress ticks // While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker. // during streaming/recovery would otherwise fight the cursor and flicker.
@@ -1031,6 +1079,19 @@ export default function WaveformSeek({ trackId }: Props) {
} }
progressRef.current = state.progress; progressRef.current = state.progress;
bufferedRef.current = state.buffered; bufferedRef.current = state.buffered;
progressAnchorRef.current = {
progress: state.progress,
atMs: performance.now(),
};
visualTargetProgressRef.current = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(state.progress)
: state.progress;
// While paused the interpolation rAF is disabled, so keep the drawn playhead
// in sync with external seeks (keyboard, MPRIS, queue). Drag/wheel still
// update these refs via previewFraction.
if (!usePlayerStore.getState().isPlaying) {
visualProgressRef.current = visualTargetProgressRef.current;
}
// Static styles always redraw on progress; animated styles let the rAF // Static styles always redraw on progress; animated styles let the rAF
// loop drive paints. In `static` animation mode we skip the rAF loop // loop drive paints. In `static` animation mode we skip the rAF loop
// entirely, so animated styles also need to repaint here on every tick. // entirely, so animated styles also need to repaint here on every tick.
@@ -1038,7 +1099,25 @@ export default function WaveformSeek({ trackId }: Props) {
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static'; !ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (drawNow) { if (drawNow) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered); if (!canvas) return;
if (!ANIMATED_STYLES.has(styleRef.current) && !isDragging.current) {
const now = Date.now();
const widthPx = Math.max(1, canvas.clientWidth || canvas.width || 1);
const minVisualDelta = 0.35 / widthPx; // allow smoother progress while still skipping no-op paints
const progressDelta = Math.abs(state.progress - lastStaticDrawProgressRef.current);
const bufferedDelta = Math.abs(state.buffered - lastStaticDrawBufferedRef.current);
const ageMs = now - lastStaticDrawAtRef.current;
const visuallySame = progressDelta < minVisualDelta && bufferedDelta < minVisualDelta;
if (
ageMs < STATIC_REDRAW_MIN_MS &&
visuallySame
) return;
if (visuallySame && ageMs < STATIC_REDRAW_FORCE_MS) return;
lastStaticDrawAtRef.current = now;
lastStaticDrawProgressRef.current = state.progress;
lastStaticDrawBufferedRef.current = state.buffered;
}
drawSeekbar(canvas, styleRef.current, heightsRef.current, visualProgressRef.current, state.buffered);
} }
}); });
}, []); }, []);
@@ -1088,7 +1167,7 @@ export default function WaveformSeek({ trackId }: Props) {
} }
}; };
const tick = () => { const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) { if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => { pollId = window.setTimeout(() => {
pollId = null; pollId = null;
tick(); tick();
@@ -1112,6 +1191,66 @@ export default function WaveformSeek({ trackId }: Props) {
return () => stop(); return () => stop();
}, [seekbarStyle, animationMode]); }, [seekbarStyle, animationMode]);
// Smoothly advance progress between sparse transport ticks.
useEffect(() => {
if (!isPlaying || duration <= 0 || !isFinite(duration)) return;
let rafId: number | null = null;
let lastPaintAt = 0;
const tick = (now: number) => {
if (document.hidden || window.__psyHidden) {
rafId = requestAnimationFrame(tick);
return;
}
if (isDragging.current) {
rafId = requestAnimationFrame(tick);
return;
}
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
if (pendingCommittedSeekRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
const anchor = progressAnchorRef.current;
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(predicted)
: predicted;
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
visualTargetProgressRef.current = nextTargetProgress;
}
const currentVisual = visualProgressRef.current;
const targetVisual = visualTargetProgressRef.current;
const delta = targetVisual - currentVisual;
if (Math.abs(delta) > 0.000001) {
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
const nextVisualProgress = Math.abs(delta) < 0.002
? targetVisual
: currentVisual + delta * smoothing;
visualProgressRef.current = nextVisualProgress;
progressRef.current = nextVisualProgress;
const needsDirectDraw =
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
const canvas = canvasRef.current;
if (canvas) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
lastPaintAt = now;
}
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId != null) cancelAnimationFrame(rafId);
};
}, [duration, isPlaying]);
// Resize observer. // Resize observer.
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -1128,6 +1267,7 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
invalidateColorCache();
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current); drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
}); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
@@ -1140,6 +1280,10 @@ export default function WaveformSeek({ trackId }: Props) {
seekRef.current = seek; seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null); const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null); const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const progressAnchorRef = useRef<{ progress: number; atMs: number }>({
progress: progressRef.current,
atMs: performance.now(),
});
const wheelSeekTimerRef = useRef<number | null>(null); const wheelSeekTimerRef = useRef<number | null>(null);
const queuedWheelSeekFractionRef = useRef<number | null>(null); const queuedWheelSeekFractionRef = useRef<number | null>(null);
const wheelPreviewFractionRef = useRef<number | null>(null); const wheelPreviewFractionRef = useRef<number | null>(null);
@@ -1158,6 +1302,12 @@ export default function WaveformSeek({ trackId }: Props) {
// responsiveness; the actual seek is committed on mouseup. // responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => { const previewFraction = (fraction: number) => {
progressRef.current = fraction; progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
};
pendingSeekRef.current = fraction; pendingSeekRef.current = fraction;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) { if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
@@ -1222,6 +1372,12 @@ export default function WaveformSeek({ trackId }: Props) {
// Preventive UI update: move visual playhead immediately on every wheel event. // Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction; progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
wheelPreviewFractionRef.current = nextFraction; wheelPreviewFractionRef.current = nextFraction;
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS; wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current; const canvas = canvasRef.current;
+11 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { serverListDisplayLabel } from '../utils/serverDisplayName'; import { serverListDisplayLabel } from '../utils/serverDisplayName';
import { usePerfProbeFlags } from '../utils/perfFlags';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -22,6 +23,7 @@ export function isLanUrl(url: string): boolean {
} }
export function useConnectionStatus() { export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
const [status, setStatus] = useState<ConnectionStatus>('checking'); const [status, setStatus] = useState<ConnectionStatus>('checking');
const [isRetrying, setIsRetrying] = useState(false); const [isRetrying, setIsRetrying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -61,6 +63,14 @@ export function useConnectionStatus() {
}, [check]); }, [check]);
useEffect(() => { useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setStatus('connected');
return;
}
check(); check();
intervalRef.current = setInterval(check, 120_000); intervalRef.current = setInterval(check, 120_000);
@@ -75,7 +85,7 @@ export function useConnectionStatus() {
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline); window.removeEventListener('offline', handleOffline);
}; };
}, [check]); }, [check, perfFlags.disableBackgroundPolling]);
const server = useAuthStore(s => s.getActiveServer()); const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers); const servers = useAuthStore(s => s.servers);
+9 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { InternetRadioStation } from '../api/subsonic'; import type { InternetRadioStation } from '../api/subsonic';
import { usePerfProbeFlags } from '../utils/perfFlags';
import { import {
guessAzuraCastApiUrl, guessAzuraCastApiUrl,
normaliseAzuraCastHomepageUrl, normaliseAzuraCastHomepageUrl,
@@ -88,6 +89,7 @@ const ICY_POLL_MS = 30_000;
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] }; const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata { export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
const perfFlags = usePerfProbeFlags();
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA); const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing. // Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
@@ -124,6 +126,12 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
} }
useEffect(() => { useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
if (!station) { if (!station) {
setMetadata(EMPTY_METADATA); setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null; azuraCastUrlRef.current = null;
@@ -202,7 +210,7 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
stopElapsedTick(); stopElapsedTick();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [station?.id, station?.streamUrl, station?.homepageUrl]); }, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
return metadata; return metadata;
} }
+6
View File
@@ -15,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react'; import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
import { usePerfProbeFlags } from '../utils/perfFlags';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide'; type CompFilter = 'all' | 'only' | 'hide';
@@ -32,6 +33,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
} }
export default function Albums() { export default function Albums() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore(); const auth = useAuthStore();
@@ -218,6 +220,7 @@ export default function Albums() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
{!perfFlags.disableMainstageStickyHeader && (
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}> <div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}> <h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0 {selectionMode && selectedIds.size > 0
@@ -292,6 +295,7 @@ export default function Albums() {
</button> </button>
</div> </div>
</div> </div>
)}
{loading && albums.length === 0 ? ( {loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
@@ -299,6 +303,7 @@ export default function Albums() {
</div> </div>
) : ( ) : (
<> <>
{!perfFlags.disableMainstageGridCards && (
<div className="album-grid-wrap"> <div className="album-grid-wrap">
{visibleAlbums.map(a => ( {visibleAlbums.map(a => (
<AlbumCard <AlbumCard
@@ -311,6 +316,7 @@ export default function Albums() {
/> />
))} ))}
</div> </div>
)}
{!genreFiltered && ( {!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}> <div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />} {loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
+94 -9
View File
@@ -9,14 +9,27 @@ import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore'; import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perfFlags';
import { bumpPerfCounter } from '../utils/perfTelemetry';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */ /** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100; const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8; const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20; const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18; const HOME_DISCOVER_SONGS_SIZE = 18;
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
const HOME_ARTWORK_WINDOWING = true;
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 3;
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4;
// Keep artwork enabled across Home rows in normal mode.
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
export default function Home() { export default function Home() {
const perfFlags = usePerfProbeFlags();
const homeAlbumRowsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeAlbumRows;
const homeSongRailsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeSongRails;
const homeRailArtworkDisabled = perfFlags.disableMainstageRailArtwork || perfFlags.disableHomeRailArtwork;
const homeSections = useHomeStore(s => s.sections); const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -35,6 +48,10 @@ export default function Home() {
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]); const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => {
bumpPerfCounter('homeCommits');
});
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
@@ -105,10 +122,54 @@ export default function Home() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
let artworkRowsLeft = homeRailArtworkDisabled ? 0 : HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED;
const reserveArtworkRow = () => {
if (artworkRowsLeft <= 0) return false;
artworkRowsLeft -= 1;
return true;
};
const recentArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recent') &&
recent.length > 0 &&
reserveArtworkRow();
const discoverArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('discover') &&
random.length > 0 &&
reserveArtworkRow();
const discoverSongsArtworkEnabled =
!homeRailArtworkDisabled &&
!homeSongRailsDisabled &&
isVisible('discoverSongs') &&
discoverSongs.length > 0 &&
reserveArtworkRow();
const recentlyPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recentlyPlayed') &&
recentlyPlayed.length > 0 &&
reserveArtworkRow();
const starredArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('starred') &&
starred.length > 0 &&
reserveArtworkRow();
const mostPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('mostPlayed') &&
mostPlayed.length > 0 &&
reserveArtworkRow();
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
return ( return (
<div className="animate-fade-in"> <div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
{isVisible('hero') && <Hero albums={heroAlbums} />} {!perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}> <div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? ( {loading ? (
@@ -117,31 +178,43 @@ export default function Home() {
</div> </div>
) : ( ) : (
<> <>
{isVisible('recent') && ( {!homeAlbumRowsDisabled && isVisible('recent') && (
<AlbumRow <AlbumRow
title={t('home.recent')} title={t('home.recent')}
titleLink="/new-releases" titleLink="/new-releases"
albums={recent} albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)} onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
disableArtwork={!recentArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{isVisible('discover') && ( {!homeAlbumRowsDisabled && isVisible('discover') && (
<AlbumRow <AlbumRow
title={t('home.discover')} title={t('home.discover')}
titleLink="/random/albums" titleLink="/random/albums"
albums={random} albums={random}
onLoadMore={() => loadMore('random', random, setRandom)} onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')} moreText={t('home.discoverMore')}
disableArtwork={!discoverArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{isVisible('discoverSongs') && discoverSongs.length > 0 && ( {!homeSongRailsDisabled && isVisible('discoverSongs') && discoverSongs.length > 0 && (
<SongRail <SongRail
title={t('home.discoverSongs')} title={t('home.discoverSongs')}
songs={discoverSongs} songs={discoverSongs}
disableArtwork={!discoverSongsArtworkEnabled}
artworkSize={HOME_SONG_RAIL_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{isVisible('discoverArtists') && randomArtists.length > 0 && ( {!perfFlags.disableMainstageGridCards && isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section"> <section className="album-row-section">
<div className="album-row-header"> <div className="album-row-header">
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}> <NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
@@ -161,30 +234,42 @@ export default function Home() {
</div> </div>
</section> </section>
)} )}
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && ( {!homeAlbumRowsDisabled && isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
<AlbumRow <AlbumRow
title={t('home.recentlyPlayed')} title={t('home.recentlyPlayed')}
albums={recentlyPlayed} albums={recentlyPlayed}
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)} onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
disableArtwork={!recentlyPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{isVisible('starred') && starred.length > 0 && ( {!homeAlbumRowsDisabled && isVisible('starred') && starred.length > 0 && (
<AlbumRow <AlbumRow
title={t('home.starred')} title={t('home.starred')}
titleLink="/favorites" titleLink="/favorites"
albums={starred} albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)} onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
disableArtwork={!starredArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{isVisible('mostPlayed') && ( {!homeAlbumRowsDisabled && isVisible('mostPlayed') && (
<AlbumRow <AlbumRow
title={t('home.mostPlayed')} title={t('home.mostPlayed')}
titleLink="/most-played" titleLink="/most-played"
albums={mostPlayed} albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)} onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')} moreText={t('home.loadMore')}
disableArtwork={!mostPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
</> </>
+17 -2
View File
@@ -15,6 +15,7 @@ import SongRail from '../components/SongRail';
import VirtualSongList from '../components/VirtualSongList'; import VirtualSongList from '../components/VirtualSongList';
import { playSongNow } from '../utils/playSong'; import { playSongNow } from '../utils/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse'; import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
import { usePerfProbeFlags } from '../utils/perfFlags';
const RANDOM_RAIL_SIZE = 18; const RANDOM_RAIL_SIZE = 18;
/** Over-fetch buffer so the client-side `userRating > 0` filter still leaves /** Over-fetch buffer so the client-side `userRating > 0` filter still leaves
@@ -25,8 +26,12 @@ const RATED_RAIL_DISPLAY = 30;
/** Stay-fresh window for the Highly Rated rail. Cleared on rating mutation, so /** Stay-fresh window for the Highly Rated rail. Cleared on rating mutation, so
* the only staleness path is a reroll-button click after >60 s. */ * the only staleness path is a reroll-button click after >60 s. */
const RATED_RAIL_CACHE_MS = 60_000; const RATED_RAIL_CACHE_MS = 60_000;
/** Match Home: only mount artwork for cards near the horizontal viewport. */
const TRACKS_SONG_RAIL_WINDOWING = true;
const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4;
export default function Tracks() { export default function Tracks() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
@@ -97,14 +102,16 @@ export default function Tracks() {
return ( return (
<div className="content-body animate-fade-in tracks-page"> <div className="content-body animate-fade-in tracks-page">
{!perfFlags.disableMainstageStickyHeader && (
<header className="tracks-header"> <header className="tracks-header">
<div className="tracks-header-text"> <div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1> <h1 className="page-title">{t('tracks.title')}</h1>
<p className="tracks-subtitle">{t('tracks.subtitle')}</p> <p className="tracks-subtitle">{t('tracks.subtitle')}</p>
</div> </div>
</header> </header>
)}
{hero && ( {!perfFlags.disableMainstageHero && hero && (
<section className="tracks-hero"> <section className="tracks-hero">
<div className="tracks-hero-cover"> <div className="tracks-hero-cover">
{heroCoverUrl ? ( {heroCoverUrl ? (
@@ -168,26 +175,34 @@ export default function Tracks() {
</section> </section>
)} )}
{ratedSupported && (ratedLoading || rated.length > 0) && ( {!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
<SongRail <SongRail
title={t('tracks.railHighlyRated')} title={t('tracks.railHighlyRated')}
songs={rated} songs={rated}
loading={ratedLoading} loading={ratedLoading}
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }} onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/> />
)} )}
{!perfFlags.disableMainstageRails && (
<SongRail <SongRail
title={t('tracks.railRandom')} title={t('tracks.railRandom')}
songs={railSongs} songs={railSongs}
loading={randomLoading} loading={randomLoading}
onReroll={rerollRandom} onReroll={rerollRandom}
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/> />
)}
{!perfFlags.disableMainstageVirtualLists && (
<VirtualSongList <VirtualSongList
title={t('tracks.browseTitle')} title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')} emptyBrowseText={t('tracks.browseUnsupported')}
/> />
)}
</div> </div>
); );
} }
+122 -17
View File
@@ -22,6 +22,8 @@ import {
getMixMinRatingsConfigFromAuth, getMixMinRatingsConfigFromAuth,
passesMixMinRatings, passesMixMinRatings,
} from '../utils/mixRatingFilter'; } from '../utils/mixRatingFilter';
import { getPerfProbeFlags } from '../utils/perfFlags';
import { bumpPerfCounter } from '../utils/perfTelemetry';
const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible'; const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible';
@@ -619,6 +621,55 @@ let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: numbe
const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600; const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180; const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
const SEEK_FALLBACK_RETRY_MAX_MS = 6000; const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
const LIVE_PROGRESS_EMIT_MIN_MS = 1500;
const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9;
let lastLiveProgressEmitAt = 0;
const STORE_PROGRESS_COMMIT_MIN_MS = 20_000;
const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
let lastStoreProgressCommitAt = 0;
export type PlaybackProgressSnapshot = {
currentTime: number;
progress: number;
buffered: number;
};
let playbackProgressSnapshot: PlaybackProgressSnapshot = {
currentTime: 0,
progress: 0,
buffered: 0,
};
const playbackProgressListeners = new Set<(
next: PlaybackProgressSnapshot,
prev: PlaybackProgressSnapshot
) => void>();
function emitPlaybackProgress(next: PlaybackProgressSnapshot): void {
const prev = playbackProgressSnapshot;
if (
Math.abs(prev.currentTime - next.currentTime) < 0.005 &&
Math.abs(prev.progress - next.progress) < 0.0002 &&
Math.abs(prev.buffered - next.buffered) < 0.0002
) {
return;
}
playbackProgressSnapshot = next;
playbackProgressListeners.forEach(cb => cb(next, prev));
}
export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot {
return playbackProgressSnapshot;
}
export function subscribePlaybackProgress(
cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void,
): () => void {
playbackProgressListeners.add(cb);
return () => {
playbackProgressListeners.delete(cb);
};
}
/** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */ /** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */
let scheduledPauseTimer: number | null = null; let scheduledPauseTimer: number | null = null;
@@ -1178,17 +1229,22 @@ function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, curr
export function flushPlayQueuePosition(): Promise<void> { export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve(); if (s.currentRadio) return Promise.resolve();
return flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime); return flushQueueSyncToServer(s.queue, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
} }
// ─── Audio event handlers (called from initAudioListeners) ─────────────────── // ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) { function handleAudioPlaying(_duration: number) {
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
lastLiveProgressEmitAt = 0;
lastStoreProgressCommitAt = 0;
usePlayerStore.setState({ isPlaying: true }); usePlayerStore.setState({ isPlaying: true });
} }
function handleAudioProgress(current_time: number, duration: number) { function handleAudioProgress(current_time: number, duration: number) {
bumpPerfCounter('audioProgressEvents');
const perfFlags = getPerfProbeFlags();
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
// While a seek is pending, the store already holds the optimistic target // While a seek is pending, the store already holds the optimistic target
// position. Accepting stale progress from the Rust engine would briefly // position. Accepting stale progress from the Rust engine would briefly
// snap the waveform back to the old position before the seek completes. // snap the waveform back to the old position before the seek completes.
@@ -1209,6 +1265,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
const track = store.currentTrack; const track = store.currentTrack;
if (!track) return; if (!track) return;
// Some backends can emit stale progress ticks shortly after pause/stop.
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
const transportActive = store.isPlaying || store.currentRadio != null;
if (!transportActive && !seekFallbackVisualTarget) return;
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) { if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
seekFallbackVisualTarget = null; seekFallbackVisualTarget = null;
} }
@@ -1230,11 +1290,26 @@ function handleAudioProgress(current_time: number, duration: number) {
const dur = duration > 0 ? duration : track.duration; const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return; if (dur <= 0) return;
const progress = displayTime / dur; const progress = displayTime / dur;
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); if (!progressUiDisabled) {
const nowLive = Date.now();
// Heartbeat: push current position to the server every 15 s while const live = getPlaybackProgressSnapshot();
// playing so cross-device resume works even on a hard close — pause() const liveTimeDelta = Math.abs(live.currentTime - displayTime);
// and the close handler flush on top of this for clean shutdowns. if (
nowLive - lastLiveProgressEmitAt >= LIVE_PROGRESS_EMIT_MIN_MS ||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
seekFallbackVisualTarget != null
) {
emitPlaybackProgress({
currentTime: displayTime,
progress,
buffered: 0,
});
lastLiveProgressEmitAt = nowLive;
}
}
// Heartbeat: push current position to the server every 15 s while playing so
// cross-device resume works even on a hard close — pause() and the close
// handler flush on top of this for clean shutdowns.
if (store.isPlaying && !store.currentRadio) { if (store.isPlaying && !store.currentRadio) {
const now = Date.now(); const now = Date.now();
if (now - lastQueueHeartbeatAt >= 15_000) { if (now - lastQueueHeartbeatAt >= 15_000) {
@@ -1251,6 +1326,19 @@ function handleAudioProgress(current_time: number, duration: number) {
lastfmScrobble(track, Date.now(), lastfmSessionKey); lastfmScrobble(track, Date.now(), lastfmSessionKey);
} }
} }
if (progressUiDisabled) return;
// Critical architectural guard: avoid high-frequency writes to the persisted
// Zustand store (each write serializes queue state). Keep only coarse commits.
const nowCommit = Date.now();
const commitDelta = Math.abs(store.currentTime - displayTime);
const shouldCommitStore =
seekFallbackVisualTarget != null ||
nowCommit - lastStoreProgressCommitAt >= STORE_PROGRESS_COMMIT_MIN_MS ||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
if (shouldCommitStore) {
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
lastStoreProgressCommitAt = nowCommit;
}
// Pre-buffer / pre-chain next track based on preload mode and crossfade. // Pre-buffer / pre-chain next track based on preload mode and crossfade.
const { const {
@@ -1741,7 +1829,7 @@ export function initAudioListeners(): () => void {
let lastMprisPositionUpdate = 0; let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => { const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, currentRadio, isPlaying, currentTime } = state; const { currentTrack, currentRadio, isPlaying } = state;
// Update metadata when track changes // Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) { if (currentTrack && currentTrack.id !== prevTrackId) {
@@ -1773,29 +1861,30 @@ export function initAudioListeners(): () => void {
}).catch(() => {}); }).catch(() => {});
} }
// Update playback state on play/pause change // Update playback state on play/pause change (use live snapshot — persisted
// store currentTime is intentionally coarse between commits).
const playbackChanged = isPlaying !== prevIsPlaying; const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) { if (playbackChanged) {
prevIsPlaying = isPlaying; prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now(); lastMprisPositionUpdate = Date.now();
const pos = getPlaybackProgressSnapshot().currentTime;
invoke('mpris_set_playback', { invoke('mpris_set_playback', {
playing: isPlaying, playing: isPlaying,
positionSecs: currentTime > 0 ? currentTime : null, positionSecs: pos > 0 ? pos : null,
}).catch(() => {}); }).catch(() => {});
invoke('update_taskbar_icon', { isPlaying }).catch(() => {}); invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
return; return;
} }
});
// Keep position in sync while playing — update every ~500 ms so Plasma const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
// always shows the correct time without interpolation gaps. const { currentRadio, isPlaying } = usePlayerStore.getState();
// Radio streams have no meaningful position, so skip for radio. if (currentRadio || !isPlaying) return;
if (!currentRadio && isPlaying && Date.now() - lastMprisPositionUpdate >= 500) { if (Date.now() - lastMprisPositionUpdate < 1500) return;
lastMprisPositionUpdate = Date.now(); lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', { invoke('mpris_set_playback', {
playing: true, playing: true,
positionSecs: currentTime, positionSecs: currentTime,
}).catch(() => {}); }).catch(() => {});
}
}); });
// ── Radio ICY StreamTitle → MPRIS ───────────────────────────────────────── // ── Radio ICY StreamTitle → MPRIS ─────────────────────────────────────────
@@ -1833,7 +1922,8 @@ export function initAudioListeners(): () => void {
let discordPrevTemplateLargeText: string | null = null; let discordPrevTemplateLargeText: string | null = null;
function syncDiscord() { function syncDiscord() {
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState(); const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const { const {
discordRichPresence, discordRichPresence,
enableAppleMusicCoversDiscord, enableAppleMusicCoversDiscord,
@@ -1893,6 +1983,7 @@ export function initAudioListeners(): () => void {
unsubAuth(); unsubAuth();
unsubAnalysisSync(); unsubAnalysisSync();
unsubMpris(); unsubMpris();
unsubMprisProgress();
unsubDiscordPlayer(); unsubDiscordPlayer();
unsubDiscordAuth(); unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten())); pending.forEach(p => p.then(unlisten => unlisten()));
@@ -2832,7 +2923,8 @@ export const usePlayerStore = create<PlayerState>()(
}, },
previous: () => { previous: () => {
const { queue, queueIndex, currentTime } = get(); const { queue, queueIndex } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) { if (currentTime > 3) {
// Restart current track from the beginning. // Restart current track from the beginning.
invoke('audio_seek', { seconds: 0 }).catch(console.error); invoke('audio_seek', { seconds: 0 }).catch(console.error);
@@ -3186,6 +3278,19 @@ export const usePlayerStore = create<PlayerState>()(
) )
); );
usePlayerStore.subscribe((state, prev) => {
if (
state.currentTime === prev.currentTime &&
state.progress === prev.progress &&
state.buffered === prev.buffered
) return;
emitPlaybackProgress({
currentTime: state.currentTime,
progress: state.progress,
buffered: state.buffered,
});
});
const QUEUE_UNDO_HOTKEY_FLAG = '__psyQueueUndoListenerInstalled'; const QUEUE_UNDO_HOTKEY_FLAG = '__psyQueueUndoListenerInstalled';
/** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */ /** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */
+180
View File
@@ -414,6 +414,12 @@
scroll-snap-align: start; scroll-snap-align: start;
} }
/* Horizontal rails can hold many off-screen cards; skip their paint/layout until visible. */
.album-grid .album-card {
content-visibility: auto;
contain-intrinsic-size: 0 240px;
}
.album-card-more { .album-card-more {
flex: 0 0 clamp(140px, 15vw, 180px); flex: 0 0 clamp(140px, 15vw, 180px);
display: flex; display: flex;
@@ -2298,6 +2304,14 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
animation: fadeIn 150ms ease both; animation: fadeIn 150ms ease both;
} }
/* Perf probe must not blur/composite the whole app while profiling. */
.modal-overlay--perf-probe {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(17, 17, 27, 0.96);
animation: none;
}
.modal-content { .modal-content {
background: var(--ctp-surface0); background: var(--ctp-surface0);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -2338,6 +2352,99 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
font-family: var(--font-display); font-family: var(--font-display);
} }
.sidebar-perf-modal {
max-width: 560px;
}
.sidebar-perf-modal__hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-muted);
}
.sidebar-perf-modal__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 13px;
color: var(--text-primary);
}
.sidebar-perf-modal__item input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
.sidebar-perf-modal__phase {
margin-top: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
border-radius: 10px;
padding: 8px 10px 10px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 78%, transparent);
}
.sidebar-perf-modal__phase--nested {
margin-left: 10px;
margin-right: 2px;
padding-top: 6px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 68%, transparent);
}
.sidebar-perf-modal__phase-title {
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
user-select: none;
list-style: none;
}
.sidebar-perf-modal__phase-title::-webkit-details-marker {
display: none;
}
.sidebar-perf-modal__cpu {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 80%, transparent);
}
.sidebar-perf-modal__cpu-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
}
.sidebar-perf-modal__cpu-row {
font-size: 13px;
color: var(--text-primary);
line-height: 1.45;
}
.sidebar-perf-modal__subhead {
margin-top: 10px;
margin-bottom: 2px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
color: var(--text-muted);
}
.sidebar-perf-modal__actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ─ Playback delay (sleep / delayed start) modal ─ */ /* ─ Playback delay (sleep / delayed start) modal ─ */
.playback-delay-modal { .playback-delay-modal {
max-height: min(85vh, 560px); max-height: min(85vh, 560px);
@@ -6019,6 +6126,7 @@ html.no-compositing .fs-seekbar-played {
flex-shrink: 0; flex-shrink: 0;
} }
/* Server switch menu is portaled to body (ConnectionIndicator); rules kept for any inline-less use. */
.connection-indicator-dropdown-panel { .connection-indicator-dropdown-panel {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
@@ -11129,6 +11237,78 @@ html[data-app-blurred="true"] .spin {
animation-play-state: paused !important; animation-play-state: paused !important;
} }
html[data-perf-disable-blur="true"] *,
html[data-perf-disable-blur="true"] *::before,
html[data-perf-disable-blur="true"] *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
html[data-perf-disable-animations="true"] *,
html[data-perf-disable-animations="true"] *::before,
html[data-perf-disable-animations="true"] *::after {
animation: none !important;
transition: none !important;
}
html[data-perf-disable-overlay-scroll="true"] .overlay-scroll__rail {
display: none !important;
}
html[data-perf-disable-mainstage-header="true"] .page-sticky-header,
html[data-perf-disable-mainstage-header="true"] .tracks-header {
display: none !important;
}
/* Home artwork diagnostics: keep images, strip expensive card compositing effects. */
.home-lite-artwork .album-card,
.home-lite-artwork .song-card {
box-shadow: inset 0 0 0 1px var(--border-subtle) !important;
transition: none !important;
transform: none !important;
contain: layout paint;
}
.home-lite-artwork .album-card:hover,
.home-lite-artwork .song-card:hover {
transform: none !important;
box-shadow: inset 0 0 0 1px var(--border-subtle) !important;
}
.home-lite-artwork .album-card-cover img,
.home-lite-artwork .song-card-cover img {
transition: none !important;
transform: none !important;
}
.home-lite-artwork .album-card-play-overlay,
.home-lite-artwork .song-card-play-overlay {
display: none !important;
}
.home-lite-artwork .album-row-section,
.home-lite-artwork .song-row-section {
content-visibility: auto;
contain-intrinsic-size: 0 340px;
}
/* Home artwork diagnostic mode: keep images, remove rounded clipping/masks. */
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card-cover,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-cover,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card-cover img,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-cover img,
.home-flat-artwork-clip.home-lite-artwork .album-card,
.home-flat-artwork-clip.home-lite-artwork .song-card,
.home-flat-artwork-clip.home-lite-artwork .album-card-cover,
.home-flat-artwork-clip.home-lite-artwork .song-card-cover,
.home-flat-artwork-clip.home-lite-artwork .album-card-cover img,
.home-flat-artwork-clip.home-lite-artwork .song-card-cover img {
border-radius: 0 !important;
clip-path: none !important;
}
/* ── Now Playing Info panel ──────────────────────────────────────────────── */ /* ── Now Playing Info panel ──────────────────────────────────────────────── */
.np-info { .np-info {
flex: 1; flex: 1;
+6
View File
@@ -186,6 +186,12 @@
scrollbar-width: none; scrollbar-width: none;
} }
/* Keep off-screen rail cards out of paint/compositing work. */
.song-grid .song-card {
content-visibility: auto;
contain-intrinsic-size: 0 190px;
}
.song-grid::-webkit-scrollbar { .song-grid::-webkit-scrollbar {
display: none; display: none;
} }
+149
View File
@@ -0,0 +1,149 @@
import { useSyncExternalStore } from 'react';
export type PerfProbeFlags = {
disableWaveformCanvas: boolean;
disablePlayerProgressUi: boolean;
disableMarqueeScroll: boolean;
disableBackdropBlur: boolean;
disableCssAnimations: boolean;
disableOverlayScrollbars: boolean;
disableTooltipPortal: boolean;
disableQueuePanelMount: boolean;
disableBackgroundPolling: boolean;
disableMainRouteContentMount: boolean;
disableMainstageHero: boolean;
disableMainstageRails: boolean;
disableMainstageGridCards: boolean;
disableMainstageVirtualLists: boolean;
disableMainstageStickyHeader: boolean;
disableMainstageHeroBackdrop: boolean;
disableMainstageRailArtwork: boolean;
disableMainstageRailInteractivity: boolean;
disableHomeAlbumRows: boolean;
disableHomeSongRails: boolean;
disableHomeRailArtwork: boolean;
disableHomeArtworkFx: boolean;
disableHomeArtworkClip: boolean;
};
const STORAGE_KEY = 'psysonic_perf_probe_flags_v1';
const DEFAULT_FLAGS: PerfProbeFlags = {
disableWaveformCanvas: false,
disablePlayerProgressUi: false,
disableMarqueeScroll: false,
disableBackdropBlur: false,
disableCssAnimations: false,
disableOverlayScrollbars: false,
disableTooltipPortal: false,
disableQueuePanelMount: false,
disableBackgroundPolling: false,
disableMainRouteContentMount: false,
disableMainstageHero: false,
disableMainstageRails: false,
disableMainstageGridCards: false,
disableMainstageVirtualLists: false,
disableMainstageStickyHeader: false,
disableMainstageHeroBackdrop: false,
disableMainstageRailArtwork: false,
disableMainstageRailInteractivity: false,
disableHomeAlbumRows: false,
disableHomeSongRails: false,
disableHomeRailArtwork: false,
disableHomeArtworkFx: false,
disableHomeArtworkClip: false,
};
let flags: PerfProbeFlags = { ...DEFAULT_FLAGS };
const listeners = new Set<() => void>();
function safeParseFlags(raw: string | null): Partial<PerfProbeFlags> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Partial<PerfProbeFlags>;
return parsed ?? {};
} catch {
return {};
}
}
function applyFlagsToDom(next: PerfProbeFlags): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.perfDisableWaveform = next.disableWaveformCanvas ? 'true' : 'false';
root.dataset.perfDisablePlayerProgressUi = next.disablePlayerProgressUi ? 'true' : 'false';
root.dataset.perfDisableMarquee = next.disableMarqueeScroll ? 'true' : 'false';
root.dataset.perfDisableBlur = next.disableBackdropBlur ? 'true' : 'false';
root.dataset.perfDisableAnimations = next.disableCssAnimations ? 'true' : 'false';
root.dataset.perfDisableOverlayScroll = next.disableOverlayScrollbars ? 'true' : 'false';
root.dataset.perfDisableTooltipPortal = next.disableTooltipPortal ? 'true' : 'false';
root.dataset.perfDisableQueuePanel = next.disableQueuePanelMount ? 'true' : 'false';
root.dataset.perfDisableBackgroundPolling = next.disableBackgroundPolling ? 'true' : 'false';
root.dataset.perfDisableMainRoute = next.disableMainRouteContentMount ? 'true' : 'false';
root.dataset.perfDisableMainstageHero = next.disableMainstageHero ? 'true' : 'false';
root.dataset.perfDisableMainstageRails = next.disableMainstageRails ? 'true' : 'false';
root.dataset.perfDisableMainstageGrid = next.disableMainstageGridCards ? 'true' : 'false';
root.dataset.perfDisableMainstageVirtual = next.disableMainstageVirtualLists ? 'true' : 'false';
root.dataset.perfDisableMainstageHeader = next.disableMainstageStickyHeader ? 'true' : 'false';
root.dataset.perfDisableMainstageHeroBackdrop = next.disableMainstageHeroBackdrop ? 'true' : 'false';
root.dataset.perfDisableMainstageRailArtwork = next.disableMainstageRailArtwork ? 'true' : 'false';
root.dataset.perfDisableMainstageRailInteractivity = next.disableMainstageRailInteractivity ? 'true' : 'false';
root.dataset.perfDisableHomeAlbumRows = next.disableHomeAlbumRows ? 'true' : 'false';
root.dataset.perfDisableHomeSongRails = next.disableHomeSongRails ? 'true' : 'false';
root.dataset.perfDisableHomeRailArtwork = next.disableHomeRailArtwork ? 'true' : 'false';
root.dataset.perfDisableHomeArtworkFx = next.disableHomeArtworkFx ? 'true' : 'false';
root.dataset.perfDisableHomeArtworkClip = next.disableHomeArtworkClip ? 'true' : 'false';
}
function persistFlags(next: PerfProbeFlags): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Ignore storage errors; runtime state still works.
}
}
function emit(): void {
listeners.forEach(fn => fn());
}
function setFlags(next: PerfProbeFlags): void {
flags = next;
applyFlagsToDom(flags);
persistFlags(flags);
emit();
}
function initFlags(): void {
if (typeof window === 'undefined') return;
const fromStorage = safeParseFlags(window.localStorage.getItem(STORAGE_KEY));
flags = {
...DEFAULT_FLAGS,
...fromStorage,
};
applyFlagsToDom(flags);
}
initFlags();
export function getPerfProbeFlags(): PerfProbeFlags {
return flags;
}
export function subscribePerfProbeFlags(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function setPerfProbeFlag<K extends keyof PerfProbeFlags>(key: K, value: PerfProbeFlags[K]): void {
setFlags({ ...flags, [key]: value });
}
export function resetPerfProbeFlags(): void {
setFlags({ ...DEFAULT_FLAGS });
}
export function usePerfProbeFlags(): PerfProbeFlags {
return useSyncExternalStore(subscribePerfProbeFlags, getPerfProbeFlags, () => DEFAULT_FLAGS);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Hot-path counters for the Performance Probe modal. In production builds these
* no-op unless the probe is open (`data-psy-perf-probe-open` on the root), so
* normal playback does not pay for object churn. Dev builds always record.
*/
const ROOT_FLAG = 'psyPerfProbeOpen';
export function setPerfProbeTelemetryActive(active: boolean): void {
if (typeof document === 'undefined') return;
if (active) document.documentElement.dataset[ROOT_FLAG] = 'true';
else delete document.documentElement.dataset[ROOT_FLAG];
}
function shouldRecordPerfCounters(): boolean {
if (import.meta.env.DEV) return true;
if (typeof document === 'undefined') return false;
return document.documentElement.dataset[ROOT_FLAG] === 'true';
}
export function bumpPerfCounter(name: string): void {
if (!shouldRecordPerfCounters()) return;
const w = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const c = w.__psyPerfCounters ?? (w.__psyPerfCounters = Object.create(null) as Record<string, number>);
c[name] = (c[name] ?? 0) + 1;
}