mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
+52
-44
@@ -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.
|
||||
|
||||
|
||||
## [1.45.0] - 2026-05-01
|
||||
## [1.45.0] - 2026-05-04
|
||||
|
||||
## 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
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
### 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)**
|
||||
@@ -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.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
### 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 player’s 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
|
||||
|
||||
**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.
|
||||
|
||||
### 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 player’s 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
### 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**
|
||||
@@ -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.
|
||||
|
||||
|
||||
### 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)**
|
||||
@@ -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.
|
||||
|
||||
|
||||
### 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)**
|
||||
@@ -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.
|
||||
|
||||
|
||||
### 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)**
|
||||
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+4
-1
@@ -165,13 +165,16 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
gdkX11Wrap = lib.optionalString forceGdkX11 ''
|
||||
--set GDK_BACKEND x11 \
|
||||
'';
|
||||
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
|
||||
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
|
||||
'';
|
||||
in
|
||||
''
|
||||
wrapProgram $out/bin/psysonic \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--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
|
||||
'';
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -963,12 +963,19 @@ fn spawn_progress_task(
|
||||
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 {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
// Local done-flag reference; swapped on gapless transition.
|
||||
let mut current_done = initial_done;
|
||||
// Local sample counter; swapped to chained source's counter on transition.
|
||||
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 {
|
||||
// 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);
|
||||
|
||||
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
|
||||
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();
|
||||
last_progress_emit_at = now;
|
||||
last_progress_emit_pos = pos;
|
||||
last_progress_emit_paused = is_paused;
|
||||
}
|
||||
|
||||
if is_paused {
|
||||
continue;
|
||||
|
||||
@@ -849,6 +849,7 @@ pub fn run() {
|
||||
set_logging_mode,
|
||||
export_runtime_logs,
|
||||
frontend_debug_log,
|
||||
performance_cpu_snapshot,
|
||||
set_subsonic_wire_user_agent,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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]
|
||||
pub(crate) fn greet(name: &str) -> String {
|
||||
@@ -121,4 +132,102 @@ pub(crate) fn set_subsonic_wire_user_agent(
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ fn detect_gpu_vendor() -> Option<GpuVendor> {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
|
||||
// Users can still override by setting these vars before launch.
|
||||
// WebKitGTK on Wayland can be unstable — default to X11 when GDK_BACKEND is unset,
|
||||
// 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
|
||||
// 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+).
|
||||
#[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");
|
||||
}
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
|
||||
|
||||
+72
-37
@@ -90,6 +90,7 @@ import i18n from './i18n';
|
||||
import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import {
|
||||
usePlayerStore,
|
||||
getPlaybackProgressSnapshot,
|
||||
initAudioListeners,
|
||||
songToTrack,
|
||||
shuffleArray,
|
||||
@@ -107,6 +108,7 @@ import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPl
|
||||
import { matchInAppShortcutAction } from './shortcuts/runtime';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
import PasteClipboardHandler from './components/PasteClipboardHandler';
|
||||
import { usePerfProbeFlags } from './utils/perfFlags';
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
|
||||
|
||||
@@ -233,6 +235,7 @@ function AppShell() {
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
// Mini player → main: route requests dispatched as `psy:navigate`
|
||||
// CustomEvents from the bridge land here so React Router can take over.
|
||||
@@ -657,37 +660,41 @@ function AppShell() {
|
||||
railInset="panel"
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
{perfFlags.disableMainRouteContentMount ? (
|
||||
<div style={{ minHeight: '60vh' }} />
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
)}
|
||||
</Suspense>
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
@@ -738,7 +745,7 @@ function AppShell() {
|
||||
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && <QueuePanel />}
|
||||
{!isMobile && !perfFlags.disableQueuePanelMount && <QueuePanel />}
|
||||
{isMobile && !isMobilePlayer && <BottomNav />}
|
||||
{!isMobilePlayer && <PlayerBar />}
|
||||
{isFullscreenOpen && (
|
||||
@@ -750,7 +757,7 @@ function AppShell() {
|
||||
<GlobalConfirmModal />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
<TooltipPortal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
<AppUpdater />
|
||||
</div>
|
||||
);
|
||||
@@ -1075,9 +1082,10 @@ function TauriEventBridge() {
|
||||
{
|
||||
const u = await listen<number>('media:seek-relative', e => {
|
||||
const s = usePlayerStore.getState();
|
||||
const p = getPlaybackProgressSnapshot();
|
||||
const dur = s.currentTrack?.duration;
|
||||
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; }
|
||||
unlisten.push(u);
|
||||
@@ -1152,6 +1160,11 @@ function TauriEventBridge() {
|
||||
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
|
||||
useEffect(() => {
|
||||
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 s = usePlayerStore.getState();
|
||||
const auth = useAuthStore.getState();
|
||||
@@ -1171,7 +1184,7 @@ function TauriEventBridge() {
|
||||
queue_index: s.queueIndex,
|
||||
queue_length: s.queue.length,
|
||||
is_playing: s.isPlaying,
|
||||
current_time: s.currentTime,
|
||||
current_time: getPlaybackProgressSnapshot().currentTime,
|
||||
volume: s.volume,
|
||||
repeat_mode: s.repeatMode,
|
||||
current_track_user_rating: currentTrackUserRating,
|
||||
@@ -1183,11 +1196,32 @@ function TauriEventBridge() {
|
||||
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(() => {});
|
||||
};
|
||||
publish();
|
||||
const schedule = () => {
|
||||
if (tid !== undefined) clearTimeout(tid);
|
||||
if (tid !== undefined) return;
|
||||
tid = setTimeout(() => {
|
||||
tid = undefined;
|
||||
publish();
|
||||
@@ -1210,6 +1244,7 @@ export default function App() {
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
// Mini Player window: detected via Tauri window label. Rendered without
|
||||
// router / sidebar / full audio listeners — it just listens for state + sends
|
||||
@@ -1293,7 +1328,7 @@ export default function App() {
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<GlobalConfirmModal />
|
||||
<TooltipPortal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -18,9 +18,20 @@ interface AlbumCardProps {
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
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 navigate = useNavigate();
|
||||
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;
|
||||
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 isNewAlbum = isAlbumRecentlyAdded(album.created);
|
||||
|
||||
@@ -73,8 +92,14 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
||||
}}
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" />
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${album.name} Cover`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
|
||||
+92
-19
@@ -4,6 +4,7 @@ import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -15,23 +16,63 @@ interface Props {
|
||||
showRating?: boolean;
|
||||
/** Optional content rendered in the row header, left of the scroll-nav. */
|
||||
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||
|
||||
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 = () => {
|
||||
if (interactivityDisabled) return;
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
recomputeArtworkBudget();
|
||||
|
||||
// Auto-load trigger
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
@@ -49,10 +90,27 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (interactivityDisabled) return;
|
||||
handleScroll();
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
// One post-layout pass ensures we account for final grid/card geometry.
|
||||
recomputeArtworkBudget();
|
||||
});
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [albums]);
|
||||
const ro = new ResizeObserver(() => {
|
||||
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') => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -74,26 +132,41 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
)}
|
||||
<div className="album-row-nav">
|
||||
{headerExtra}
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
{!interactivityDisabled && (
|
||||
<>
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
|
||||
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
||||
{albums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
disableArtwork={
|
||||
artworkDisabled ||
|
||||
(windowArtworkByViewport && idx >= artworkBudget)
|
||||
}
|
||||
artworkSize={artworkSize}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
|
||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||
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 [menuOpen, setMenuOpen] = useState(false);
|
||||
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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(() => {
|
||||
if (!menuOpen) return;
|
||||
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);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -103,55 +127,74 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{multi && menuOpen && (
|
||||
<div
|
||||
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
|
||||
role="menu"
|
||||
aria-label={t('connection.switchServerTitle')}
|
||||
>
|
||||
{multi &&
|
||||
menuOpen &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuPanelRef}
|
||||
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
|
||||
role="menu"
|
||||
aria-label={t('connection.switchServerTitle')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-muted)',
|
||||
padding: '6px 10px 4px',
|
||||
position: 'fixed',
|
||||
top: menuFixed.top,
|
||||
right: menuFixed.right,
|
||||
minWidth: 220,
|
||||
maxWidth: 'min(320px, 85vw)',
|
||||
zIndex: 10050,
|
||||
}}
|
||||
>
|
||||
{t('connection.switchServerTitle')}
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
key={srv.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => onPickServer(srv)}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{labelText}</span>
|
||||
{switchingId === srv.id ? (
|
||||
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
|
||||
) : active ? (
|
||||
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
|
||||
) : (
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<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}>
|
||||
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-muted)',
|
||||
padding: '6px 10px 4px',
|
||||
}}
|
||||
>
|
||||
{t('connection.switchServerTitle')}
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
key={srv.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => onPickServer(srv)}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{labelText}</span>
|
||||
{switchingId === srv.id ? (
|
||||
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
|
||||
) : active ? (
|
||||
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
|
||||
) : (
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<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}>
|
||||
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
|
||||
<span className="nav-library-dropdown-check-spacer" aria-hidden />
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
|
||||
Moon, Sunrise,
|
||||
} 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 { useCachedUrl } from './CachedImage';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
@@ -88,8 +88,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
|
||||
setActiveIdx(idx);
|
||||
}
|
||||
};
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
apply(getPlaybackProgressSnapshot().currentTime);
|
||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
||||
}, [hasSynced, currentTrack?.id]);
|
||||
|
||||
// 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 };
|
||||
};
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
apply(getPlaybackProgressSnapshot().currentTime);
|
||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
const handleUserScroll = useCallback(() => {
|
||||
@@ -275,8 +275,8 @@ const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack
|
||||
}
|
||||
prevWord.current = { line: li, word: wi };
|
||||
};
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
apply(getPlaybackProgressSnapshot().currentTime);
|
||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
||||
}, [useWords, wordLines]);
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
@@ -457,14 +457,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
}, [seek]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = usePlayerStore.getState();
|
||||
const s = getPlaybackProgressSnapshot();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return usePlayerStore.subscribe(state => {
|
||||
return subscribePlaybackProgress(state => {
|
||||
if (isDraggingRef.current) return;
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||
|
||||
+120
-6
@@ -11,6 +11,7 @@ import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
@@ -55,6 +56,7 @@ interface HeroProps {
|
||||
}
|
||||
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -67,6 +69,102 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
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(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
@@ -93,12 +191,27 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const startTimer = useCallback((len: number) => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (len <= 1 || windowHidden) return;
|
||||
if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
|
||||
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);
|
||||
}, 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(() => {
|
||||
startTimer(albums.length);
|
||||
@@ -106,7 +219,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
}, [albums.length, startTimer]);
|
||||
}, [albums.length, heroInView, startTimer]);
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
setActiveIdx(idx);
|
||||
@@ -144,14 +257,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className="hero"
|
||||
role="banner"
|
||||
aria-label={t('hero.eyebrow')}
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
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 { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -126,8 +126,8 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
prevActive.current = { line: lineIdx, word: wordIdx };
|
||||
};
|
||||
|
||||
apply(usePlayerStore.getState().currentTime);
|
||||
return usePlayerStore.subscribe(s => apply(s.currentTime));
|
||||
apply(getPlaybackProgressSnapshot().currentTime);
|
||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
||||
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
|
||||
|
||||
if (!currentTrack) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
@@ -13,6 +14,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [scrollAmount, setScrollAmount] = useState(0);
|
||||
const animationMode = useAuthStore(s => s.animationMode);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
const measure = useCallback(() => {
|
||||
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
|
||||
// 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 (
|
||||
<div
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
Moon, Sunrise,
|
||||
} 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 { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
@@ -164,8 +164,13 @@ export default function MobilePlayerView() {
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const playbackProgress = useSyncExternalStore(
|
||||
onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
|
||||
getPlaybackProgressSnapshot,
|
||||
getPlaybackProgressSnapshot,
|
||||
);
|
||||
const progress = playbackProgress.progress;
|
||||
const currentTime = playbackProgress.currentTime;
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
|
||||
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
|
||||
|
||||
@@ -56,6 +57,7 @@ export default function OverlayScrollArea({
|
||||
viewportOnWheel,
|
||||
viewportOnTouchMove,
|
||||
}: OverlayScrollAreaProps) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||
@@ -72,6 +74,7 @@ export default function OverlayScrollArea({
|
||||
const measureKey = JSON.stringify(measureDeps ?? []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (perfFlags.disableOverlayScrollbars) return;
|
||||
if (!meta.visible) return;
|
||||
const vp = viewportRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
@@ -89,9 +92,10 @@ export default function OverlayScrollArea({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [meta.visible]);
|
||||
}, [meta.visible, perfFlags.disableOverlayScrollbars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfFlags.disableOverlayScrollbars) return;
|
||||
recompute();
|
||||
const wrap = wrapRef.current;
|
||||
const onWinResize = () => recompute();
|
||||
@@ -105,7 +109,7 @@ export default function OverlayScrollArea({
|
||||
window.removeEventListener('resize', onWinResize);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [recompute, measureKey]);
|
||||
}, [recompute, measureKey, perfFlags.disableOverlayScrollbars]);
|
||||
|
||||
const setViewportNode = (el: HTMLDivElement | null) => {
|
||||
viewportRef.current = el;
|
||||
@@ -134,13 +138,13 @@ export default function OverlayScrollArea({
|
||||
id={viewportId}
|
||||
ref={setViewportNode}
|
||||
className={viewportClass}
|
||||
onScroll={recompute}
|
||||
onScroll={perfFlags.disableOverlayScrollbars ? undefined : recompute}
|
||||
onWheel={viewportOnWheel}
|
||||
onTouchMove={viewportOnTouchMove}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{meta.visible && (
|
||||
{!perfFlags.disableOverlayScrollbars && meta.visible && (
|
||||
<div className="overlay-scroll__rail" aria-hidden>
|
||||
<div
|
||||
className="overlay-scroll__thumb"
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
|
||||
} from 'lucide-react';
|
||||
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 { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -26,6 +26,7 @@ import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -40,9 +41,9 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
@@ -55,12 +56,12 @@ const RemainingTime = memo(function RemainingTime({ duration, className }: { dur
|
||||
useEffect(() => {
|
||||
const updateRemaining = () => {
|
||||
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)}`;
|
||||
}
|
||||
};
|
||||
updateRemaining();
|
||||
return usePlayerStore.subscribe(updateRemaining);
|
||||
return subscribePlaybackProgress(updateRemaining);
|
||||
}, [duration]);
|
||||
return <span className={className} ref={spanRef} />;
|
||||
});
|
||||
@@ -117,6 +118,7 @@ export default function PlayerBar() {
|
||||
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
|
||||
const volumeWheelMenuTimerRef = useRef<number | null>(null);
|
||||
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
useEffect(() => {
|
||||
if (!floatingPlayerBar) return;
|
||||
@@ -530,7 +532,9 @@ export default function PlayerBar() {
|
||||
<>
|
||||
<PlaybackTime className="player-time" />
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
{perfFlags.disableWaveformCanvas
|
||||
? <div className="radio-progress-bar" aria-hidden />
|
||||
: <WaveformSeek trackId={currentTrack?.id} />}
|
||||
</div>
|
||||
<span
|
||||
className="player-time player-time-toggle"
|
||||
|
||||
+504
-1
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
type SidebarNavDropTarget,
|
||||
} from '../utils/sidebarNavReorder';
|
||||
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_MOVE_CANCEL_PX = 10;
|
||||
@@ -81,6 +84,12 @@ export default function Sidebar({
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
|
||||
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 sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
@@ -151,6 +160,15 @@ export default function Sidebar({
|
||||
const newReleasesRefreshSeqRef = useRef(0);
|
||||
const newReleasesPageEnteredAtRef = 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(
|
||||
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
|
||||
@@ -572,10 +590,113 @@ export default function Sidebar({
|
||||
};
|
||||
}, [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 (
|
||||
<>
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand" aria-hidden>
|
||||
{isCollapsed
|
||||
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
|
||||
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
|
||||
@@ -953,6 +1074,388 @@ export default function Sidebar({
|
||||
</div>,
|
||||
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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,14 +11,24 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
disableArtwork?: boolean;
|
||||
artworkSize?: number;
|
||||
}
|
||||
|
||||
function SongCard({ song }: SongCardProps) {
|
||||
function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
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 { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
@@ -74,12 +84,13 @@ function SongCard({ song }: SongCardProps) {
|
||||
}}
|
||||
>
|
||||
<div className="song-card-cover">
|
||||
{coverUrl ? (
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${song.album} Cover`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="song-card-cover-placeholder">
|
||||
|
||||
+87
-20
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import SongCard from './SongCard';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -12,25 +13,79 @@ interface Props {
|
||||
loading?: boolean;
|
||||
/** Empty-state copy when songs is empty AND not loading. */
|
||||
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 [showLeft, setShowLeft] = useState(false);
|
||||
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 = () => {
|
||||
if (interactivityDisabled) return;
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
recomputeArtworkBudget();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (interactivityDisabled) return;
|
||||
handleScroll();
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
// One post-layout pass ensures we account for final grid/card geometry.
|
||||
recomputeArtworkBudget();
|
||||
});
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
}, [songs]);
|
||||
const ro = new ResizeObserver(() => {
|
||||
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') => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -58,20 +113,24 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
|
||||
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
{!interactivityDisabled && (
|
||||
<>
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,9 +138,17 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
|
||||
{songs.length === 0 && emptyText ? (
|
||||
<p className="song-row-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{songs.map(s => (
|
||||
<SongCard key={s.id} song={s} />
|
||||
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
|
||||
{songs.map((s, idx) => (
|
||||
<SongCard
|
||||
key={s.id}
|
||||
song={s}
|
||||
disableArtwork={
|
||||
artworkDisabled ||
|
||||
(windowArtworkByViewport && idx >= artworkBudget)
|
||||
}
|
||||
artworkSize={artworkSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+215
-59
@@ -1,6 +1,7 @@
|
||||
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 { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||
function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
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 FLAT_WAVE_NORM = 0.06;
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,13 +44,38 @@ const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'li
|
||||
|
||||
// ── color helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getColors() {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
|
||||
type SeekbarColors = {
|
||||
played: string;
|
||||
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',
|
||||
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
|
||||
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
|
||||
};
|
||||
return cachedColors;
|
||||
}
|
||||
|
||||
// ── canvas setup ──────────────────────────────────────────────────────────────
|
||||
@@ -56,9 +85,8 @@ function setupCanvas(
|
||||
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
|
||||
const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
|
||||
if (w === 0 || h === 0) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
@@ -72,6 +100,10 @@ function setupCanvas(
|
||||
return { ctx, w, h };
|
||||
}
|
||||
|
||||
function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
|
||||
ctx.shadowBlur = Math.max(0, blur);
|
||||
}
|
||||
|
||||
// ── waveform heights ──────────────────────────────────────────────────────────
|
||||
|
||||
function hashStr(str: string): number {
|
||||
@@ -156,6 +188,15 @@ function waveformBarThickness(logicalH: number, norm: number): number {
|
||||
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(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
@@ -166,6 +207,8 @@ function drawWaveform(
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
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) {
|
||||
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
|
||||
@@ -185,32 +228,30 @@ function drawWaveform(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
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.shadowBlur = 7;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = unplayed;
|
||||
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 x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
@@ -220,17 +261,17 @@ function drawWaveform(
|
||||
ctx.fillStyle = buffCol;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
if (frac < pNorm || frac >= bNorm) continue;
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
|
||||
if (progress > 0) {
|
||||
if (pNorm > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
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 x = x1Of(i);
|
||||
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));
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -300,11 +341,11 @@ function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number)
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -336,11 +377,11 @@ function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
setShadowBlur(ctx, 10);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, y, progress * w, bh, rad);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -359,13 +400,13 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const frac = i / SEG_COUNT;
|
||||
const x = i * (segW + gap);
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
if (frac < progress) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
if (i === playedIdx - 1) {
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
setShadowBlur(ctx, 5);
|
||||
}
|
||||
} else if (frac < buffered) {
|
||||
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.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -410,34 +451,34 @@ function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number)
|
||||
ctx.globalAlpha = 0.18;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 22;
|
||||
setShadowBlur(ctx, 22);
|
||||
ctx.fillRect(0, cy - 5, px, 10);
|
||||
|
||||
// Mid glow
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.shadowBlur = 12;
|
||||
setShadowBlur(ctx, 12);
|
||||
ctx.fillRect(0, cy - 2.5, px, 5);
|
||||
|
||||
// Inner glow
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillRect(0, cy - 1.5, px, 3);
|
||||
|
||||
// Bright white core
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 0.75, px, 1.5);
|
||||
|
||||
// End-cap flare
|
||||
ctx.shadowBlur = 16;
|
||||
setShadowBlur(ctx, 16);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -478,16 +519,16 @@ function drawPulseWave(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
setShadowBlur(ctx, 3);
|
||||
ctx.fillRect(0, cy - 1, startX, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
setShadowBlur(ctx, 7);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
@@ -499,7 +540,7 @@ function drawPulseWave(
|
||||
ctx.lineTo(x, cy - wave);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -561,22 +602,22 @@ function drawParticleTrail(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
// Particles
|
||||
ctx.shadowColor = played;
|
||||
for (const p of animState.particles) {
|
||||
ctx.globalAlpha = p.life * 0.85;
|
||||
ctx.shadowBlur = 5;
|
||||
setShadowBlur(ctx, 5);
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Playhead dot
|
||||
if (progress > 0) {
|
||||
@@ -584,11 +625,11 @@ function drawParticleTrail(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 10;
|
||||
setShadowBlur(ctx, 10);
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
@@ -660,9 +701,9 @@ function drawLiquidFill(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 9;
|
||||
setShadowBlur(ctx, 9);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Glass highlight on top
|
||||
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
|
||||
@@ -720,9 +761,9 @@ function drawRetroTape(
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
setShadowBlur(ctx, 4);
|
||||
ctx.fillRect(0, cy - 1, px - reelR, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
}
|
||||
|
||||
// Spinning reel at playhead
|
||||
@@ -730,13 +771,13 @@ function drawRetroTape(
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
setShadowBlur(ctx, 7);
|
||||
|
||||
// Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, reelR * 0.28);
|
||||
@@ -758,7 +799,7 @@ function drawRetroTape(
|
||||
}
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
setShadowBlur(ctx, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -772,6 +813,7 @@ export function drawSeekbar(
|
||||
buffered: number,
|
||||
animState?: AnimState,
|
||||
) {
|
||||
bumpPerfCounter('waveformDraws');
|
||||
const anim = animState ?? makeAnimState();
|
||||
switch (style) {
|
||||
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
|
||||
@@ -829,7 +871,7 @@ export function SeekbarPreview({
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||
if (document.hidden || window.__psyHidden) {
|
||||
pollId = window.setTimeout(() => {
|
||||
pollId = null;
|
||||
tick();
|
||||
@@ -902,17 +944,23 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const SEEK_COMMIT_MIN_HOLD_MS = 320;
|
||||
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
||||
const WHEEL_SEEK_STEP_SECONDS = 10;
|
||||
const WHEEL_SEEK_DEBOUNCE_MS = 1000;
|
||||
const WHEEL_SEEK_DEBOUNCE_MS = 350;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||
const progressRef = useRef(getPlaybackProgressSnapshot().progress);
|
||||
const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
|
||||
const visualProgressRef = useRef(progressRef.current);
|
||||
const visualTargetProgressRef = useRef(progressRef.current);
|
||||
const isDragging = useRef(false);
|
||||
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 seek = usePlayerStore(s => s.seek);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
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.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
useEffect(() => {
|
||||
return usePlayerStore.subscribe((state, prev) => {
|
||||
return subscribePlaybackProgress((state, prev) => {
|
||||
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
|
||||
// While user drags, keep the local preview stable. External progress ticks
|
||||
// during streaming/recovery would otherwise fight the cursor and flicker.
|
||||
@@ -1031,6 +1079,19 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
}
|
||||
progressRef.current = state.progress;
|
||||
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
|
||||
// loop drive paints. In `static` animation mode we skip the rAF loop
|
||||
// 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';
|
||||
if (drawNow) {
|
||||
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 = () => {
|
||||
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
|
||||
if (document.hidden || window.__psyHidden) {
|
||||
pollId = window.setTimeout(() => {
|
||||
pollId = null;
|
||||
tick();
|
||||
@@ -1112,6 +1191,66 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return () => stop();
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -1128,6 +1267,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
invalidateColorCache();
|
||||
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
@@ -1140,6 +1280,10 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
seekRef.current = seek;
|
||||
const pendingSeekRef = useRef<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 queuedWheelSeekFractionRef = 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.
|
||||
const previewFraction = (fraction: number) => {
|
||||
progressRef.current = fraction;
|
||||
visualProgressRef.current = fraction;
|
||||
visualTargetProgressRef.current = fraction;
|
||||
progressAnchorRef.current = {
|
||||
progress: fraction,
|
||||
atMs: performance.now(),
|
||||
};
|
||||
pendingSeekRef.current = fraction;
|
||||
const canvas = canvasRef.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.
|
||||
progressRef.current = nextFraction;
|
||||
visualProgressRef.current = nextFraction;
|
||||
visualTargetProgressRef.current = nextFraction;
|
||||
progressAnchorRef.current = {
|
||||
progress: nextFraction,
|
||||
atMs: performance.now(),
|
||||
};
|
||||
wheelPreviewFractionRef.current = nextFraction;
|
||||
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
@@ -22,6 +23,7 @@ export function isLanUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -61,6 +63,14 @@ export function useConnectionStatus() {
|
||||
}, [check]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfFlags.disableBackgroundPolling) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setStatus('connected');
|
||||
return;
|
||||
}
|
||||
check();
|
||||
intervalRef.current = setInterval(check, 120_000);
|
||||
|
||||
@@ -75,7 +85,7 @@ export function useConnectionStatus() {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [check]);
|
||||
}, [check, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
const server = useAuthStore(s => s.getActiveServer());
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { InternetRadioStation } from '../api/subsonic';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
import {
|
||||
guessAzuraCastApiUrl,
|
||||
normaliseAzuraCastHomepageUrl,
|
||||
@@ -88,6 +89,7 @@ const ICY_POLL_MS = 30_000;
|
||||
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
|
||||
|
||||
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
|
||||
|
||||
// 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(() => {
|
||||
if (perfFlags.disableBackgroundPolling) {
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
return;
|
||||
}
|
||||
if (!station) {
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
@@ -202,7 +210,7 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
|
||||
stopElapsedTick();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
|
||||
}, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
+87
-81
@@ -15,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
type CompFilter = 'all' | 'only' | 'hide';
|
||||
@@ -32,6 +33,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
}
|
||||
|
||||
export default function Albums() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
@@ -218,80 +220,82 @@ export default function Albums() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('albums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
|
||||
<ListPlus size={15} />
|
||||
{t('albums.enqueueSelected', { count: selectedIds.size })}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!yearActive && (
|
||||
<SortDropdown
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onChange={setSort}
|
||||
{!perfFlags.disableMainstageStickyHeader && (
|
||||
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('albums.selectionCount', { count: selectedIds.size })
|
||||
: t('albums.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{selectionMode && selectedIds.size > 0 ? (
|
||||
<>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
|
||||
<ListPlus size={15} />
|
||||
{t('albums.enqueueSelected', { count: selectedIds.size })}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!yearActive && (
|
||||
<SortDropdown
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onChange={setSort}
|
||||
/>
|
||||
)}
|
||||
|
||||
<YearFilterButton
|
||||
from={yearFrom}
|
||||
to={yearTo}
|
||||
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<YearFilterButton
|
||||
from={yearFrom}
|
||||
to={yearTo}
|
||||
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
|
||||
/>
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
data-tooltip={
|
||||
compFilter === 'all' ? t('albums.compilationTooltipAll')
|
||||
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
|
||||
: t('albums.compilationTooltipHide')
|
||||
}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
}}
|
||||
>
|
||||
<Disc3 size={14} />
|
||||
{compFilter === 'all' ? t('albums.compilationLabel')
|
||||
: compFilter === 'only' ? t('albums.compilationOnly')
|
||||
: t('albums.compilationHide')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
data-tooltip={
|
||||
compFilter === 'all' ? t('albums.compilationTooltipAll')
|
||||
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
|
||||
: t('albums.compilationTooltipHide')
|
||||
}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
}}
|
||||
>
|
||||
<Disc3 size={14} />
|
||||
{compFilter === 'all' ? t('albums.compilationLabel')
|
||||
: compFilter === 'only' ? t('albums.compilationOnly')
|
||||
: t('albums.compilationHide')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
@@ -299,18 +303,20 @@ export default function Albums() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{visibleAlbums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
<div className="album-grid-wrap">
|
||||
{visibleAlbums.map(a => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
|
||||
+94
-9
@@ -9,14 +9,27 @@ import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '../store/homeStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
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. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
const HOME_HERO_COUNT = 8;
|
||||
const HOME_DISCOVER_SLICE = 20;
|
||||
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() {
|
||||
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 activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
@@ -35,6 +48,10 @@ export default function Home() {
|
||||
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
bumpPerfCounter('homeCommits');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -105,10 +122,54 @@ export default function Home() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<div className="animate-fade-in">
|
||||
{isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
||||
{!perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
@@ -117,31 +178,43 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isVisible('recent') && (
|
||||
{!homeAlbumRowsDisabled && isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
titleLink="/new-releases"
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
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
|
||||
title={t('home.discover')}
|
||||
titleLink="/random/albums"
|
||||
albums={random}
|
||||
onLoadMore={() => loadMore('random', random, setRandom)}
|
||||
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
|
||||
title={t('home.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">
|
||||
<div className="album-row-header">
|
||||
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
|
||||
@@ -161,30 +234,42 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
|
||||
{!homeAlbumRowsDisabled && isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
|
||||
<AlbumRow
|
||||
title={t('home.recentlyPlayed')}
|
||||
albums={recentlyPlayed}
|
||||
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
|
||||
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
|
||||
title={t('home.starred')}
|
||||
titleLink="/favorites"
|
||||
albums={starred}
|
||||
onLoadMore={() => loadMore('starred', starred, setStarred)}
|
||||
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
|
||||
title={t('home.mostPlayed')}
|
||||
titleLink="/most-played"
|
||||
albums={mostPlayed}
|
||||
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
|
||||
moreText={t('home.loadMore')}
|
||||
disableArtwork={!mostPlayedArtworkEnabled}
|
||||
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
|
||||
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
|
||||
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+33
-18
@@ -15,6 +15,7 @@ import SongRail from '../components/SongRail';
|
||||
import VirtualSongList from '../components/VirtualSongList';
|
||||
import { playSongNow } from '../utils/playSong';
|
||||
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
const RANDOM_RAIL_SIZE = 18;
|
||||
/** 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
|
||||
* the only staleness path is a reroll-button click after >60 s. */
|
||||
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() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
@@ -97,14 +102,16 @@ export default function Tracks() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in tracks-page">
|
||||
<header className="tracks-header">
|
||||
<div className="tracks-header-text">
|
||||
<h1 className="page-title">{t('tracks.title')}</h1>
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
</div>
|
||||
</header>
|
||||
{!perfFlags.disableMainstageStickyHeader && (
|
||||
<header className="tracks-header">
|
||||
<div className="tracks-header-text">
|
||||
<h1 className="page-title">{t('tracks.title')}</h1>
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{hero && (
|
||||
{!perfFlags.disableMainstageHero && hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{heroCoverUrl ? (
|
||||
@@ -168,26 +175,34 @@ export default function Tracks() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
<SongRail
|
||||
title={t('tracks.railHighlyRated')}
|
||||
songs={rated}
|
||||
loading={ratedLoading}
|
||||
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
loading={randomLoading}
|
||||
onReroll={rerollRandom}
|
||||
/>
|
||||
{!perfFlags.disableMainstageRails && (
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
loading={randomLoading}
|
||||
onReroll={rerollRandom}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VirtualSongList
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
/>
|
||||
{!perfFlags.disableMainstageVirtualLists && (
|
||||
<VirtualSongList
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+127
-22
@@ -22,6 +22,8 @@ import {
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
passesMixMinRatings,
|
||||
} from '../utils/mixRatingFilter';
|
||||
import { getPerfProbeFlags } from '../utils/perfFlags';
|
||||
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||
|
||||
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_RETRY_INTERVAL_MS = 180;
|
||||
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. */
|
||||
let scheduledPauseTimer: number | null = null;
|
||||
@@ -1178,17 +1229,22 @@ function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, curr
|
||||
export function flushPlayQueuePosition(): Promise<void> {
|
||||
const s = usePlayerStore.getState();
|
||||
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) ───────────────────
|
||||
|
||||
function handleAudioPlaying(_duration: number) {
|
||||
setDeferHotCachePrefetch(false);
|
||||
lastLiveProgressEmitAt = 0;
|
||||
lastStoreProgressCommitAt = 0;
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
}
|
||||
|
||||
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
|
||||
// position. Accepting stale progress from the Rust engine would briefly
|
||||
// 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 track = store.currentTrack;
|
||||
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) {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
@@ -1230,11 +1290,26 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = displayTime / dur;
|
||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||
|
||||
// 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 (!progressUiDisabled) {
|
||||
const nowLive = Date.now();
|
||||
const live = getPlaybackProgressSnapshot();
|
||||
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
|
||||
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) {
|
||||
const now = Date.now();
|
||||
if (now - lastQueueHeartbeatAt >= 15_000) {
|
||||
@@ -1251,6 +1326,19 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
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.
|
||||
const {
|
||||
@@ -1741,7 +1829,7 @@ export function initAudioListeners(): () => void {
|
||||
let lastMprisPositionUpdate = 0;
|
||||
|
||||
const unsubMpris = usePlayerStore.subscribe((state) => {
|
||||
const { currentTrack, currentRadio, isPlaying, currentTime } = state;
|
||||
const { currentTrack, currentRadio, isPlaying } = state;
|
||||
|
||||
// Update metadata when track changes
|
||||
if (currentTrack && currentTrack.id !== prevTrackId) {
|
||||
@@ -1773,29 +1861,30 @@ export function initAudioListeners(): () => void {
|
||||
}).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;
|
||||
if (playbackChanged) {
|
||||
prevIsPlaying = isPlaying;
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
const pos = getPlaybackProgressSnapshot().currentTime;
|
||||
invoke('mpris_set_playback', {
|
||||
playing: isPlaying,
|
||||
positionSecs: currentTime > 0 ? currentTime : null,
|
||||
positionSecs: pos > 0 ? pos : null,
|
||||
}).catch(() => {});
|
||||
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep position in sync while playing — update every ~500 ms so Plasma
|
||||
// always shows the correct time without interpolation gaps.
|
||||
// Radio streams have no meaningful position, so skip for radio.
|
||||
if (!currentRadio && isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: true,
|
||||
positionSecs: currentTime,
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
|
||||
const { currentRadio, isPlaying } = usePlayerStore.getState();
|
||||
if (currentRadio || !isPlaying) return;
|
||||
if (Date.now() - lastMprisPositionUpdate < 1500) return;
|
||||
lastMprisPositionUpdate = Date.now();
|
||||
invoke('mpris_set_playback', {
|
||||
playing: true,
|
||||
positionSecs: currentTime,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// ── Radio ICY StreamTitle → MPRIS ─────────────────────────────────────────
|
||||
@@ -1833,7 +1922,8 @@ export function initAudioListeners(): () => void {
|
||||
let discordPrevTemplateLargeText: string | null = null;
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
|
||||
const { currentTrack, isPlaying } = usePlayerStore.getState();
|
||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||
const {
|
||||
discordRichPresence,
|
||||
enableAppleMusicCoversDiscord,
|
||||
@@ -1893,6 +1983,7 @@ export function initAudioListeners(): () => void {
|
||||
unsubAuth();
|
||||
unsubAnalysisSync();
|
||||
unsubMpris();
|
||||
unsubMprisProgress();
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
@@ -2832,7 +2923,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { queue, queueIndex, currentTime } = get();
|
||||
const { queue, queueIndex } = get();
|
||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||
if (currentTime > 3) {
|
||||
// Restart current track from the beginning.
|
||||
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';
|
||||
|
||||
/** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */
|
||||
|
||||
@@ -414,6 +414,12 @@
|
||||
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 {
|
||||
flex: 0 0 clamp(140px, 15vw, 180px);
|
||||
display: flex;
|
||||
@@ -2298,6 +2304,14 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
|
||||
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 {
|
||||
background: var(--ctp-surface0);
|
||||
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);
|
||||
}
|
||||
|
||||
.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-modal {
|
||||
max-height: min(85vh, 560px);
|
||||
@@ -6019,6 +6126,7 @@ html.no-compositing .fs-seekbar-played {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Server switch menu is portaled to body (ConnectionIndicator); rules kept for any inline-less use. */
|
||||
.connection-indicator-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
@@ -11129,6 +11237,78 @@ html[data-app-blurred="true"] .spin {
|
||||
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 ──────────────────────────────────────────────── */
|
||||
.np-info {
|
||||
flex: 1;
|
||||
|
||||
@@ -186,6 +186,12 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user