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 &&