From a6cc2e2ad495c7b0b882ad173cebbd672e08cbe0 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 4 May 2026 20:06:49 +0300 Subject: [PATCH] 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 * 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 --- CHANGELOG.md | 96 ++-- nix/psysonic.nix | 5 +- package.json | 2 +- src-tauri/src/audio/commands.rs | 22 +- src-tauri/src/lib.rs | 1 + src-tauri/src/lib_commands/app_api/core.rs | 109 +++++ src-tauri/src/main.rs | 10 +- src/App.tsx | 109 +++-- src/components/AlbumCard.tsx | 35 +- src/components/AlbumRow.tsx | 111 ++++- src/components/ConnectionIndicator.tsx | 137 ++++-- src/components/FullscreenPlayer.tsx | 18 +- src/components/Hero.tsx | 126 ++++- src/components/LyricsPane.tsx | 6 +- src/components/MarqueeText.tsx | 4 +- src/components/MobilePlayerView.tsx | 13 +- src/components/OverlayScrollArea.tsx | 12 +- src/components/PlayerBar.tsx | 16 +- src/components/Sidebar.tsx | 505 ++++++++++++++++++++- src/components/SongCard.tsx | 21 +- src/components/SongRail.tsx | 107 ++++- src/components/WaveformSeek.tsx | 274 ++++++++--- src/hooks/useConnectionStatus.ts | 12 +- src/hooks/useRadioMetadata.ts | 10 +- src/pages/Albums.tsx | 168 +++---- src/pages/Home.tsx | 103 ++++- src/pages/Tracks.tsx | 51 ++- src/store/playerStore.ts | 149 +++++- src/styles/components.css | 180 ++++++++ src/styles/tracks.css | 6 + src/utils/perfFlags.ts | 149 ++++++ src/utils/perfTelemetry.ts | 25 + 32 files changed, 2184 insertions(+), 408 deletions(-) create mode 100644 src/utils/perfFlags.ts create mode 100644 src/utils/perfTelemetry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0270d156..cb1ec371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **``** **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 diff --git a/nix/psysonic.nix b/nix/psysonic.nix index 54520636..0bcda00b 100644 --- a/nix/psysonic.nix +++ b/nix/psysonic.nix @@ -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 ''; diff --git a/package.json b/package.json index 52a27ca8..2d01c78b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs index 538c14e8..b3106391 100644 --- a/src-tauri/src/audio/commands.rs +++ b/src-tauri/src/audio/commands.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 670919cd..17cc05ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs index 63c62469..e0d58d82 100644 --- a/src-tauri/src/lib_commands/app_api/core.rs +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -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::().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::().ok()?; + let stime = rest.get(10)?.parse::().ok()?; + Some((comm, ppid, utime, stime)) +} + +#[cfg(target_os = "linux")] +fn read_total_jiffies() -> Option { + 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::().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::() { + 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::(); + 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, + } + } +} + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e296bd0c..0f2503f1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -41,8 +41,9 @@ fn detect_gpu_vendor() -> Option { } 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() { diff --git a/src/App.tsx b/src/App.tsx index 0d9e2a71..deaaf6da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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" > - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {perfFlags.disableMainRouteContentMount ? ( +
+ ) : ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )}
@@ -738,7 +745,7 @@ function AppShell() { {isQueueVisible ? : } )} - {!isMobile && } + {!isMobile && !perfFlags.disableQueuePanelMount && } {isMobile && !isMobilePlayer && } {!isMobilePlayer && } {isFullscreenOpen && ( @@ -750,7 +757,7 @@ function AppShell() { - + {!perfFlags.disableTooltipPortal && } ); @@ -1075,9 +1082,10 @@ function TauriEventBridge() { { const u = await listen('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 | 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() { - + {!perfFlags.disableTooltipPortal && } ); } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 36c28ff3..52f3f81a 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -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 }} >
- {coverUrl ? ( - + {!disableArtwork && coverUrl ? ( + ) : (
diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 3d021897..078331c5 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -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(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('.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, )}
{headerExtra} - - + {!interactivityDisabled && ( + <> + + + + )}
-
- {albums.map(a => )} +
+ {albums.map((a, idx) => ( + = artworkBudget) + } + artworkSize={artworkSize} + /> + ))} {loadingMore && (
diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index b21305ea..400f3cab 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -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(null); + const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 }); const hostRef = useRef(null); + const menuPanelRef = useRef(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
- {multi && menuOpen && ( -
+ {multi && + menuOpen && + typeof document !== 'undefined' && + createPortal(
- {t('connection.switchServerTitle')} -
- {servers.map(srv => { - const active = srv.id === activeServerId; - const busy = switchingId !== null; - const labelText = serverListDisplayLabel(srv, servers); - return ( - - ); - })} -
- -
- )} +
+ {t('connection.switchServerTitle')} +
+ {servers.map(srv => { + const active = srv.id === activeServerId; + const busy = switchingId !== null; + const labelText = serverListDisplayLabel(srv, servers); + return ( + + ); + })} +
+ +
, + document.body + )}
); } diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 53d681d4..25f8e5b5 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -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); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 3603021b..e8505ace 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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 | null>(null); const windowHidden = useWindowVisibility(); + const [windowBlurred, setWindowBlurred] = useState(() => Boolean(window.__psyBlurred)); + const heroRef = useRef(null); + const heroScrollRootRef = useRef(null); + const visibilityRafRef = useRef(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 (
navigate(`/album/${album.id}`)} style={{ cursor: 'pointer' }} > - {enableCoverArtBackground && } - {enableCoverArtBackground &&