From 70c2fdfbf90fd2c57160893f7027c32cd25da524 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 18 May 2026 21:00:46 +0300 Subject: [PATCH] Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731) * feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_* and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing; refresh Help (a45) and nixos-install docs. * fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install docs, CHANGELOG, and help FAQ with practical user-facing workarounds. * fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case to WEBKIT_DISABLE_DMABUF_RENDERER like native X11. * fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards Sidebar nav links avoid transition:all and promote icons with translateZ(0). Artist rows and album/artist/song cards use compositing hints; card shadows and borders no longer interpolate so cover zoom can stay smooth without jitter. * fix(ui): isolate artist/album card text and cover paint on WebKitGTK Promote cover blocks with contain/paint and text stacks with translateZ(0); use artist-card-info on the artists grid for the same layout as other cards. * feat(artists): in-page overlay scroll and locked main viewport Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from owning the route scroll, align the rail with the main panel edge, and skip the main-route overlay thumb when the viewport cannot scroll vertically. * feat(browse): extend in-page overlay scroll to more library routes Reuse the locked main viewport pattern from Artists for Albums, Composers, Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome to the matching in-page viewport ids. * fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK Use on-demand hardware acceleration on main and mini webviews when the session is Wayland and compositing stays on; gate subpixel body AA on the same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING for opt-out and changelog. * fix(rust): satisfy clippy needless_return in Linux webkit helpers * fix(linux): tune Wayland text rendering with HW policy env and CSS Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware acceleration policy (never/always vs default on-demand). Extend Wayland font CSS to #root with geometricPrecision and text-size-adjust on html. * feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default Persist profile to app config; apply WebKit policy at startup/mini only to avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default preset is sharp (CPU-friendly). * fix(linux): map Wayland sharp preset to OnDemand WebKit policy HardwareAccelerationPolicy::Never at startup broke main-viewport wheel scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path. Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy. * fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so Windows compiles without unused-import and dead-code warnings. * chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731) Consolidate scattered incremental changelog notes into two [1.47.0] entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed. Append settings credit line for cucadmuh. --- CHANGELOG.md | 20 +- flake.nix | 51 ++- nix/psysonic.nix | 14 +- nixos-install.md | 20 +- packages/aur/PKGBUILD | 5 +- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 8 + src-tauri/src/lib_commands/app_api/mod.rs | 12 +- .../src/lib_commands/app_api/platform.rs | 219 ++++++++++ src-tauri/src/lib_commands/sync/mod.rs | 2 +- src-tauri/src/lib_commands/sync/tray.rs | 14 + src-tauri/src/lib_commands/ui/mini.rs | 6 + src-tauri/src/main.rs | 84 ++-- src/app/AppShell.tsx | 11 +- src/components/VirtualCardGrid.tsx | 25 +- src/components/artists/ArtistsGridView.tsx | 2 +- src/components/settings/SystemTab.tsx | 32 +- src/config/settingsCredits.ts | 1 + src/constants/appScroll.ts | 16 + src/hooks/useArtistsInfiniteScroll.ts | 7 +- src/hooks/useMainScrollingIndicator.ts | 24 +- src/hooks/useMainstageInpageHeaderTight.ts | 39 ++ src/hooks/usePlatformShellSetup.ts | 63 +++ src/hooks/useResizeClientHeight.ts | 20 + src/locales/de/help.ts | 2 +- src/locales/de/settings.ts | 7 + src/locales/en/help.ts | 2 +- src/locales/en/settings.ts | 7 + src/locales/es/help.ts | 2 +- src/locales/es/settings.ts | 7 + src/locales/fr/help.ts | 2 +- src/locales/fr/settings.ts | 7 + src/locales/nb/help.ts | 2 +- src/locales/nb/settings.ts | 7 + src/locales/nl/help.ts | 2 +- src/locales/nl/settings.ts | 7 + src/locales/ro/help.ts | 2 +- src/locales/ro/settings.ts | 7 + src/locales/ru/help.ts | 2 +- src/locales/ru/settings.ts | 7 + src/locales/zh/help.ts | 2 +- src/locales/zh/settings.ts | 6 + src/pages/Albums.tsx | 274 +++++++----- src/pages/Artists.tsx | 307 ++++++++------ src/pages/Composers.tsx | 400 ++++++++++-------- src/pages/LosslessAlbums.tsx | 195 +++++---- src/pages/NewReleases.tsx | 184 ++++---- src/store/authStore.settings.test.ts | 1 + src/store/authStore.ts | 1 + src/store/authStoreRehydrate.ts | 7 + src/store/authStoreTypes.ts | 6 + src/store/authUiAppearanceActions.ts | 2 + src/styles/components/album-card.css | 13 +- .../components/artist-card-grid-view.css | 15 +- src/styles/components/artists-page.css | 3 +- src/styles/layout/main-content.css | 81 ++++ src/styles/layout/sidebar.css | 3 +- src/styles/themes/reset.css | 30 ++ src/styles/tracks/song-card.css | 1 - src/utils/ui/overlayScrollbarMetrics.ts | 8 + 61 files changed, 1602 insertions(+), 712 deletions(-) create mode 100644 src/hooks/useMainstageInpageHeaderTight.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c53d0db..29cbaf7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Linux — session GDK, WebKitGTK mitigations, and Wayland text + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#731](https://github.com/Psychotoxical/psysonic/pull/731)** + +* **Nix / AUR** default installs follow the session GDK backend instead of pinning `GDK_BACKEND=x11`; startup applies **`webkit2gtk-nvidia-quirk`** only (skip with **`PSYSONIC_WEBKIT_GPU_ACCEL`**). **`nix run .#psysonic-x11-legacy`** keeps the old explicit X11 launcher. +* **NVIDIA + forced X11** on a Wayland user session no longer greys out the webview — the quirk uses the DMABUF renderer path instead of Wayland explicit-sync disable. +* **Wayland + GPU compositing:** clearer UI text via on-demand hardware acceleration on main and mini webviews; **Settings → System** adds **Wayland text rendering** presets (Balanced / Sharp / GPU / Minimal). Opt out with **`PSYSONIC_SKIP_WAYLAND_FONT_TUNING`**. + + + +### Library browse — in-page overlay scroll + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#731](https://github.com/Psychotoxical/psysonic/pull/731)** + +* **Artists**, **Albums**, **Composers**, **Lossless albums**, and **New releases** scroll inside the route on a locked in-page viewport — toolbars stay sticky, virtual grids use the matching scroll root. +* Sidebar hover and album/artist card covers no longer jitter on WebKitGTK + Wayland during pointer moves. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. @@ -437,7 +456,6 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Frontend counterpart to the backend split: largest page components, stores, and stylesheets broken into focused files; duplicated helpers consolidated; i18n and CSS split per namespace. **No user-visible behaviour change** — moves verified by TypeScript, Vitest, and production builds, with characterization tests added along the way. - ## Removed ### Settings — Animations 3-state setting under Seekbar Style diff --git a/flake.nix b/flake.nix index fac8f576..423631f2 100644 --- a/flake.nix +++ b/flake.nix @@ -3,17 +3,19 @@ Psysonic for NixOS / nixpkgs: installable app + dev shell. Packages: - nix build .#psysonic # or .#default — desktop app (.desktop + icon); GDK_BACKEND=x11 (default, fewer WebKit surprises) - nix build .#psysonic-gdk-session # same app, no forced GDK x11 — optional; can misbehave on some stacks (see nixos-install.md) + nix build .#psysonic # or .#default — desktop app; GDK follows session (no wrapper pin) + nix build .#psysonic-gdk-session # same derivation (back-compat alias); see nixos-install.md + nix build .#psysonic-x11-legacy # legacy: GDK_BACKEND=x11 wrapper (old default) nix profile install .#psysonic Run (after build, or from any clone with flake): nix run .#psysonic - nix run .#psysonic-gdk-session + nix run .#psysonic-gdk-session # identical to psysonic + nix run .#psysonic-x11-legacy # GDK x11 pinned (former default wrap) nix run github:Psychotoxical/psysonic Development: - nix develop # mkShell (Rust/Node/WebKit deps + hooks) + nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin) nix shell .#devShells.default # same environment without entering subshell semantics Local cargo output: .build-local/ (gitignored; not copied into flake source tarball) @@ -87,9 +89,6 @@ export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}" export LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov" export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata" - export GDK_BACKEND=x11 - export WEBKIT_DISABLE_COMPOSITING_MODE=1 - export WEBKIT_DISABLE_DMABUF_RENDERER=1 unset CI ''; @@ -106,28 +105,38 @@ inherit upstreamMeta; }; - psysonicGdkSessionFor = + # Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md). + psysonicX11LegacyFor = system: nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix { src = self; inherit upstreamMeta; - forceGdkX11 = false; + forceGdkX11 = true; }; + in { devShells = forSystem (system: { default = mkShellFor system; }); - packages = forSystem (system: { - psysonic = psysonicFor system; - psysonic-gdk-session = psysonicGdkSessionFor system; - default = psysonicFor system; - }); + packages = forSystem ( + system: + let + p = psysonicFor system; + pX11 = psysonicX11LegacyFor system; + in + { + psysonic = p; + psysonic-gdk-session = p; + psysonic-x11-legacy = pX11; + default = p; + } + ); apps = forSystem ( system: let p = psysonicFor system; - pGdk = psysonicGdkSessionFor system; + pX11 = psysonicX11LegacyFor system; in { default = { @@ -140,9 +149,17 @@ }; psysonic-gdk-session = { type = "app"; - program = lib.getExe pGdk; + program = lib.getExe p; meta = { - inherit (pGdk.meta) description homepage license; + inherit (p.meta) description homepage license; + mainProgram = "psysonic"; + }; + }; + psysonic-x11-legacy = { + type = "app"; + program = lib.getExe pX11; + meta = { + inherit (pX11.meta) description homepage license; mainProgram = "psysonic"; }; }; diff --git a/nix/psysonic.nix b/nix/psysonic.nix index 0bcda00b..86efabfc 100644 --- a/nix/psysonic.nix +++ b/nix/psysonic.nix @@ -35,9 +35,9 @@ gst_all_1, src, upstreamMeta, - # When true (default), wrapProgram sets GDK_BACKEND=x11 for WebKit stability on many setups. - # When false, GDK follows the session (e.g. native Wayland) — often better HiDPI sizing. - forceGdkX11 ? true, + # When true, wrapProgram sets GDK_BACKEND=x11 (legacy conservative stack). + # When false (default), only lib paths — GDK follows session; binary applies webkit2gtk-nvidia-quirk only. + forceGdkX11 ? false, }: let @@ -163,10 +163,7 @@ stdenv.mkDerivation (finalAttrs: { postFixup = let gdkX11Wrap = lib.optionalString forceGdkX11 '' - --set GDK_BACKEND x11 \ - ''; - allowNativeGdkWrap = lib.optionalString (!forceGdkX11) '' - --set PSYSONIC_ALLOW_NATIVE_GDK 1 \ + --set GDK_BACKEND x11 ''; in '' @@ -174,8 +171,7 @@ stdenv.mkDerivation (finalAttrs: { --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \ --prefix GST_PLUGIN_PATH : "${gstPluginPath}" \ --prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \ - ${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \ - --set WEBKIT_DISABLE_DMABUF_RENDERER 1 + ${gdkX11Wrap} ''; meta = { diff --git a/nixos-install.md b/nixos-install.md index 3492a5ab..7524c5b6 100644 --- a/nixos-install.md +++ b/nixos-install.md @@ -85,25 +85,27 @@ environment.systemPackages = with pkgs; [ ]; ``` -### Linux wrapper: default vs gdk-session +### Linux wrapper (default vs legacy X11) -The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs: +The flake exposes **three** Linux attributes (two are the **same derivation**): | Flake attribute | Wrapper behaviour | |----------------|-------------------| -| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. | -| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. | +| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. | +| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. | -Use the alternate package when you understand that trade-off: +`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path). + +### Example: legacy X11 wrap ```nix -inputs.psysonic.packages.${system}.psysonic-gdk-session +inputs.psysonic.packages.${system}.psysonic-x11-legacy ``` Or one-shot (quote the URL in **zsh** — `?` / `#` are special): ```bash -nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help +nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help ``` ### Pinning a revision, branch, or tag @@ -142,7 +144,7 @@ From any machine with flakes: nix run 'github:Psychotoxical/psysonic' ``` -Same as `nix build` / `packages..default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**. +Same as `nix build` / `packages..default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**. ### Apply configuration @@ -179,8 +181,6 @@ From a **flake-enabled** clone of the repo: The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**). -Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`). - ## Desktop entry The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app. diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 065936bc..dcbbb9bd 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -53,12 +53,9 @@ package() { # Binary (in /usr/lib to make room for the wrapper) install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic" - # Wrapper script that sets necessary env vars for WebKitGTK on Wayland + # Wrapper: thin exec (path hygiene only); GDK/session + WebKit mitigations come from main.rs / quirk (no GDK pin). install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" < &'static Mutex> { + static CELL: OnceLock>> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(None)) +} + +#[cfg(target_os = "linux")] +fn sanitized_wayland_text_profile(profile: &str) -> String { + match profile.trim() { + "balanced" | "sharp" | "gpu" | "minimal" => profile.trim().to_string(), + _ => "sharp".to_string(), + } +} + +#[cfg(target_os = "linux")] +fn wayland_text_profile_persist_path(app: &tauri::AppHandle) -> Option { + app.path().app_config_dir().ok().map(|p| p.join(LINUX_WAYLAND_TEXT_PROFILE_FILE)) +} + +/// Load persisted Wayland text profile into the in-process cache before the main webview is tuned. +#[cfg(target_os = "linux")] +pub(crate) fn sync_wayland_text_profile_cache_from_disk(app: &tauri::AppHandle) { + let Some(path) = wayland_text_profile_persist_path(app) else { + return; + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + let s = sanitized_wayland_text_profile(&text); + if let Ok(mut g) = last_wayland_text_render_profile_cell().lock() { + *g = Some(s); + } +} + +#[cfg(target_os = "linux")] +fn remember_wayland_text_render_profile(profile: &str, app: Option<&tauri::AppHandle>) { + let s = sanitized_wayland_text_profile(profile); + if let Ok(mut g) = last_wayland_text_render_profile_cell().lock() { + *g = Some(s.clone()); + } + if let Some(app) = app { + if let Some(path) = wayland_text_profile_persist_path(app) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, s); + } + } +} + +/// Re-apply the last **Settings** Wayland text profile to a webview (used when the mini window is built). +#[cfg(target_os = "linux")] +pub(crate) fn linux_webkit_reapply_cached_wayland_text_render_profile(win: &tauri::WebviewWindow) -> Result<(), String> { + let p = last_wayland_text_render_profile_cell() + .lock() + .ok() + .and_then(|g| g.clone()) + .unwrap_or_else(|| "sharp".to_string()); + linux_webkit_apply_wayland_text_render_profile(win, &p) +} + +/// `PSYSONIC_WEBKIT_WAYLAND_HW_POLICY` → WebKit hardware acceleration policy when +/// [`linux_webkit_apply_wayland_gpu_font_tuning`] runs. Default **`ondemand`**; +/// set **`never`** / **`software`** to force CPU-friendly layers (often sharper text +/// at the cost of compositor work); **`always`** forces the previous aggressive GPU path for A/B. +#[cfg(target_os = "linux")] +fn wayland_hw_acceleration_policy_from_env() -> webkit2gtk::HardwareAccelerationPolicy { + use webkit2gtk::HardwareAccelerationPolicy; + let v = std::env::var("PSYSONIC_WEBKIT_WAYLAND_HW_POLICY") + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_default(); + match v.as_str() { + "never" | "off" | "0" | "software" => HardwareAccelerationPolicy::Never, + "always" | "on" | "1" | "gpu" => HardwareAccelerationPolicy::Always, + _ => HardwareAccelerationPolicy::OnDemand, + } +} + +/// Wayland session with WebKit GPU compositing (`WEBKIT_DISABLE_COMPOSITING_MODE` not forced on). +#[cfg(target_os = "linux")] +pub(crate) fn linux_wayland_gpu_compositing_context() -> bool { + let wayland = std::env::var("XDG_SESSION_TYPE") + .map(|v| v.eq_ignore_ascii_case("wayland")) + .unwrap_or(false); + let no_comp = std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE") + .map(|v| v == "1") + .unwrap_or(false); + wayland && !no_comp +} + +/// True when [`linux_webkit_apply_wayland_gpu_font_tuning`] would change WebKit settings +/// (Wayland + GPU compositing, user has not set `PSYSONIC_SKIP_WAYLAND_FONT_TUNING`). +#[cfg(target_os = "linux")] +pub(crate) fn linux_wayland_gpu_font_tuning_should_apply() -> bool { + fn skip_tuning() -> bool { + matches!( + std::env::var("PSYSONIC_SKIP_WAYLAND_FONT_TUNING").as_deref(), + Ok("1") | Ok("true") | Ok("yes") + ) + } + if skip_tuning() { + return false; + } + linux_wayland_gpu_compositing_context() +} + +/// WebKitGTK on Wayland with compositing: prefer on-demand GPU promotion so body +/// text is less often rasterised into GL layers (common "washed" / blurry look). +/// No-op when [`linux_wayland_gpu_font_tuning_should_apply`] is false. +#[cfg(target_os = "linux")] +pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWindow) -> Result<(), String> { + if !linux_wayland_gpu_font_tuning_should_apply() { + return Ok(()); + } + win + .with_webview(|platform| { + use webkit2gtk::{SettingsExt, WebViewExt}; + if let Some(settings) = platform.inner().settings() { + let policy = wayland_hw_acceleration_policy_from_env(); + if settings.hardware_acceleration_policy() != policy { + settings.set_hardware_acceleration_policy(policy); + } + } + }) + .map_err(|e| e.to_string()) +} + /// Toggle native window decorations at runtime (Linux custom title bar opt-out). #[tauri::command] pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { @@ -47,3 +181,88 @@ pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri } Ok(()) } + +/// True when [`linux_webkit_apply_wayland_gpu_font_tuning`] would change WebKit settings +/// (Wayland + GPU compositing, user has not set `PSYSONIC_SKIP_WAYLAND_FONT_TUNING`). +#[tauri::command] +pub(crate) fn linux_wayland_gpu_font_tuning_active() -> bool { + #[cfg(target_os = "linux")] + { + linux_wayland_gpu_font_tuning_should_apply() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +#[cfg(target_os = "linux")] +fn hardware_acceleration_policy_from_render_profile(profile: &str) -> webkit2gtk::HardwareAccelerationPolicy { + use webkit2gtk::HardwareAccelerationPolicy; + match profile.trim() { + // `Never` here has been observed to break main-viewport wheel scrolling on WebKitGTK + // under Wayland+GPU compositing after the policy is applied at startup. CSS still + // differentiates "sharp"; use `PSYSONIC_WEBKIT_WAYLAND_HW_POLICY=never` for true Never. + "sharp" => HardwareAccelerationPolicy::OnDemand, + "gpu" => HardwareAccelerationPolicy::Always, + "balanced" | "minimal" => HardwareAccelerationPolicy::OnDemand, + _ => HardwareAccelerationPolicy::OnDemand, + } +} + +/// Apply WebKit hardware acceleration policy from a **Settings** profile (`balanced` / `sharp` / +/// `gpu` / `minimal`). Call only at webview creation / startup — toggling this at runtime wedges +/// WebKitGTK on some Wayland stacks after a few changes. +#[cfg(target_os = "linux")] +pub(crate) fn linux_webkit_apply_wayland_text_render_profile( + win: &tauri::WebviewWindow, + profile: &str, +) -> Result<(), String> { + if !linux_wayland_gpu_compositing_context() { + return Ok(()); + } + let policy = hardware_acceleration_policy_from_render_profile(profile); + win + .with_webview(move |platform| { + use webkit2gtk::{SettingsExt, WebViewExt}; + if let Some(settings) = platform.inner().settings() { + if settings.hardware_acceleration_policy() != policy { + settings.set_hardware_acceleration_policy(policy); + } + } + }) + .map_err(|e| e.to_string()) +} + +/// Persist the Wayland text profile for the next app start and for new mini-player webviews. +/// Does **not** touch WebKit on existing windows (avoids WebKitGTK hangs when toggling policy live). +#[tauri::command] +pub(crate) fn set_linux_wayland_text_render_profile( + profile: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + if !linux_wayland_gpu_compositing_context() { + return Ok(()); + } + remember_wayland_text_render_profile(&profile, Some(&app_handle)); + } + #[cfg(not(target_os = "linux"))] + { + let _ = (profile, app_handle); + } + Ok(()) +} + +#[tauri::command] +pub(crate) fn linux_wayland_text_render_settings_available() -> bool { + #[cfg(target_os = "linux")] + { + linux_wayland_gpu_compositing_context() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} diff --git a/src-tauri/src/lib_commands/sync/mod.rs b/src-tauri/src/lib_commands/sync/mod.rs index 1d517145..225a9385 100644 --- a/src-tauri/src/lib_commands/sync/mod.rs +++ b/src-tauri/src/lib_commands/sync/mod.rs @@ -1,7 +1,7 @@ mod tray; pub(crate) use tray::{ - is_tiling_wm_cmd, no_compositing_mode, set_tray_menu_labels, set_tray_tooltip, + is_tiling_wm_cmd, linux_xdg_session_type, no_compositing_mode, set_tray_menu_labels, set_tray_tooltip, toggle_tray_icon, }; // Internal helpers consumed elsewhere in the shell crate: diff --git a/src-tauri/src/lib_commands/sync/tray.rs b/src-tauri/src/lib_commands/sync/tray.rs index 44fcaac2..33fd3d57 100644 --- a/src-tauri/src/lib_commands/sync/tray.rs +++ b/src-tauri/src/lib_commands/sync/tray.rs @@ -398,6 +398,20 @@ pub(crate) fn no_compositing_mode() -> bool { .unwrap_or(false) } +/// Tauri command: `XDG_SESSION_TYPE` from the host environment (e.g. `wayland`, `x11`). +/// Used for Linux-only UI tweaks such as font rasterisation hints; empty string when unset. +#[tauri::command] +pub(crate) fn linux_xdg_session_type() -> String { + #[cfg(target_os = "linux")] + { + std::env::var("XDG_SESSION_TYPE").unwrap_or_default() + } + #[cfg(not(target_os = "linux"))] + { + String::new() + } +} + #[cfg(not(target_os = "linux"))] pub(crate) fn is_tiling_wm() -> bool { false diff --git a/src-tauri/src/lib_commands/ui/mini.rs b/src-tauri/src/lib_commands/ui/mini.rs index e995669a..1287321a 100644 --- a/src-tauri/src/lib_commands/ui/mini.rs +++ b/src-tauri/src/lib_commands/ui/mini.rs @@ -249,6 +249,12 @@ pub(crate) fn build_mini_player_window( .build() .map_err(|e| format!("failed to build mini player window: {e}"))?; + #[cfg(target_os = "linux")] + { + let _ = crate::lib_commands::linux_webkit_apply_wayland_gpu_font_tuning(&win); + let _ = crate::lib_commands::linux_webkit_reapply_cached_wayland_text_render_profile(&win); + } + // Inject pause script immediately when the window is created hidden. // On Windows WebView2 keeps the GPU context alive even with // `SetIsVisible(false)` — this JS stops all rendering work. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0f2503f1..f3e5d7df 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,75 +2,41 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #[cfg(target_os = "linux")] -#[derive(Debug, Clone, Copy, PartialEq)] -enum GpuVendor { - Nvidia, - Intel, - Amd, -} +use webkit2gtk_nvidia_quirk::{ + apply_workaround_with_options, needs_workaround, set_webkit_disable_dmabuf_renderer, + ApplyWorkaroundOptions, WorkaroundKind, +}; #[cfg(target_os = "linux")] -fn detect_gpu_vendor() -> Option { - use std::fs; - - if fs::metadata("/proc/driver/nvidia/version").is_ok() { - return Some(GpuVendor::Nvidia); +fn apply_linux_webkit_nvidia_quirk() { + if std::env::var("PSYSONIC_WEBKIT_GPU_ACCEL").is_ok() { + return; } - // Iterate every `/sys/class/drm/card*` — hybrid laptops expose multiple - // cards, and some systems have no `card0` at all. - let entries = fs::read_dir("/sys/class/drm").ok()?; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if !name.starts_with("card") || name.contains('-') { - continue; - } - let Ok(vendor_id) = fs::read_to_string(entry.path().join("device/vendor")) else { - continue; - }; - match vendor_id.trim() { - "0x10de" => return Some(GpuVendor::Nvidia), - "0x8086" => return Some(GpuVendor::Intel), - "0x1002" => return Some(GpuVendor::Amd), - _ => {} + // dev.sh gpu-x11 / nix psysonic-x11-legacy: WebKit uses the X11 GDK path while the session + // may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`, + // which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11 + // branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list. + let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| { + matches!(s.split(',').next().map(str::trim), Some("x11")) + }); + if forced_x11_gdk { + match needs_workaround() { + WorkaroundKind::None => {} + WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => { + set_webkit_disable_dmabuf_renderer(); + } } + } else { + apply_workaround_with_options(ApplyWorkaroundOptions::default()); } - - None } fn main() { - // 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 - // exist yet. If this code moves to lazy init or a plugin context, it would - // need synchronization or marking as unsafe (Rust 2024+). + // Linux GTK/WebKit: `webkit2gtk-nvidia-quirk` (skipped when `PSYSONIC_WEBKIT_GPU_ACCEL` is set). + // Forced `GDK_BACKEND=x11` uses the X11-only mitigation path — see `apply_linux_webkit_nvidia_quirk`. #[cfg(target_os = "linux")] - { - // 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() { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); - } - - // NVIDIA proprietary adds a small but reproducible overhead on the - // DMA-BUF renderer path (blind A/B confirmed on NVIDIA + proprietary). - // Unknown GPUs keep the WebKitGTK default — VMs, ARM SBCs and anything - // exotic should not be regressed by a guess. - if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() - && matches!(detect_gpu_vendor(), Some(GpuVendor::Nvidia)) - { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - } + apply_linux_webkit_nvidia_quirk(); let args: Vec = std::env::args().collect(); if psysonic_lib::cli::wants_version(&args) { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index f2dc6cad..fd307267 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -22,7 +22,10 @@ import OrbitAccountPicker from '../components/OrbitAccountPicker'; import OrbitHelpModal from '../components/OrbitHelpModal'; import TooltipPortal from '../components/TooltipPortal'; import OverlayScrollArea from '../components/OverlayScrollArea'; -import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { + APP_MAIN_SCROLL_VIEWPORT_ID, + MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH, +} from '../constants/appScroll'; import ConnectionIndicator from '../components/ConnectionIndicator'; import LastfmIndicator from '../components/LastfmIndicator'; import OfflineBanner from '../components/OfflineBanner'; @@ -212,7 +215,11 @@ export function AppShell() {
= { wrapStyle?: React.CSSProperties; /** Defaults to `var(--space-4)`; composer grid uses `var(--space-2)`. */ gridGap?: string; + /** When set, row virtualization uses this scroll container instead of the main route viewport. */ + scrollRootId?: string; }; /** * Album-/playlist-style card grids: at most six columns, proportional stretch, - * optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID`. + * optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID` + * (or `scrollRootId` when the grid lives in an in-page overlay viewport). */ export function VirtualCardGrid({ items, @@ -36,13 +39,25 @@ export function VirtualCardGrid({ wrapClassName = 'album-grid-wrap', wrapStyle, gridGap = 'var(--space-4)', + scrollRootId, }: VirtualCardGridProps): React.JSX.Element { const wrapRef = useRef(null); const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal); const cols = Math.max(1, gridCols); const virtualRowCount = Math.max(0, Math.ceil(items.length / cols)); - const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); - const overscan = Math.max(2, Math.ceil(mainScrollViewportHeight / Math.max(1, rowHeightEst))); + const scrollMetricsElementId = scrollRootId ?? APP_MAIN_SCROLL_VIEWPORT_ID; + const scrollViewportClientHeight = useElementClientHeightById(scrollMetricsElementId); + const overscan = Math.max(2, Math.ceil(scrollViewportClientHeight / Math.max(1, rowHeightEst))); + + const getScrollElement = useCallback((): HTMLElement | null => { + if (scrollRootId) { + return ( + document.getElementById(scrollRootId) as HTMLElement | null + ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null) + ); + } + return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; + }, [scrollRootId]); const scrollMargin = useVirtualizerScrollMargin( wrapRef, @@ -55,7 +70,7 @@ export function VirtualCardGrid({ const virtualizer = useVirtualizer({ count: disableVirtualization ? 0 : virtualRowCount, - getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getScrollElement, estimateSize: () => rowHeightEst, overscan, scrollMargin, diff --git a/src/components/artists/ArtistsGridView.tsx b/src/components/artists/ArtistsGridView.tsx index f2a9ab04..ff1722ef 100644 --- a/src/components/artists/ArtistsGridView.tsx +++ b/src/components/artists/ArtistsGridView.tsx @@ -57,7 +57,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
)} -
+
{artist.name}
{artist.albumCount != null && (
{rest.t('artists.albumCount', { count: artist.albumCount })}
diff --git a/src/components/settings/SystemTab.tsx b/src/components/settings/SystemTab.tsx index 1dd3f6bb..b07b0dea 100644 --- a/src/components/settings/SystemTab.tsx +++ b/src/components/settings/SystemTab.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { invoke } from '@tauri-apps/api/core'; @@ -7,7 +8,7 @@ import { AppWindow, ChevronDown, Download, ExternalLink, Globe, HardDrive, Info, import { version as appVersion } from '../../../package.json'; import i18n from '../../i18n'; import { useAuthStore } from '../../store/authStore'; -import type { ClockFormat, LoggingMode } from '../../store/authStoreTypes'; +import type { ClockFormat, LinuxWaylandTextRenderProfile, LoggingMode } from '../../store/authStoreTypes'; import { IS_LINUX } from '../../utils/platform'; import { showToast } from '../../utils/ui/toast'; import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol'; @@ -21,6 +22,14 @@ export function SystemTab() { const { t } = useTranslation(); const navigate = useNavigate(); const auth = useAuthStore(); + const [waylandTextRenderAvailable, setWaylandTextRenderAvailable] = useState(false); + + useEffect(() => { + if (!IS_LINUX) return; + invoke('linux_wayland_text_render_settings_available') + .then(setWaylandTextRenderAvailable) + .catch(() => {}); + }, []); const exportRuntimeLogs = async () => { const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; @@ -110,6 +119,27 @@ export function SystemTab() {
+ {waylandTextRenderAvailable && ( + <> +
+
+
{t('settings.linuxWaylandTextRender')}
+
+ {t('settings.linuxWaylandTextRenderDesc')} +
+ auth.setLinuxWaylandTextRenderProfile(v as LinuxWaylandTextRenderProfile)} + options={[ + { value: 'balanced', label: t('settings.linuxWaylandTextRenderBalanced') }, + { value: 'sharp', label: t('settings.linuxWaylandTextRenderSharp') }, + { value: 'gpu', label: t('settings.linuxWaylandTextRenderGpu') }, + { value: 'minimal', label: t('settings.linuxWaylandTextRenderMinimal') }, + ]} + /> +
+ + )} )}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index d99c99fc..7f3af979 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -118,6 +118,7 @@ const CONTRIBUTOR_ENTRIES = [ 'HTTP stream buffering — seekbar/timer at zero and cover overlay until playback arms (PR #737)', 'M4A playback: fix AtomIterator overread in patched isomp4 demuxer — probe gate for ranged tail prefetch, zero-hole fallback detection (PR #757)', 'Multi-server: Lucky Mix and Now Playing keep browsed server; queue metadata via apiForServer (PR #768)', + 'Linux: session GDK/WebKit mitigations, in-page library scroll, Wayland text presets (PR #731)', ], }, { diff --git a/src/constants/appScroll.ts b/src/constants/appScroll.ts index 88bf0b5c..81b8e427 100644 --- a/src/constants/appScroll.ts +++ b/src/constants/appScroll.ts @@ -1,2 +1,18 @@ /** Main scroll element wrapping `` in App (overlay scrollbar). */ export const APP_MAIN_SCROLL_VIEWPORT_ID = 'app-main-scroll-viewport'; + +/** In-page list/grid viewports when the main route scroll is locked (see AppShell). */ +export const ARTISTS_INPAGE_SCROLL_VIEWPORT_ID = 'artists-inpage-scroll-viewport'; +export const ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'albums-inpage-scroll-viewport'; +export const NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID = 'new-releases-inpage-scroll-viewport'; +export const LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID = 'lossless-albums-inpage-scroll-viewport'; +export const COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID = 'composers-inpage-scroll-viewport'; + +/** Route pathname → in-page overlay viewport id (must match `viewportId` on each page). */ +export const MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH: Readonly> = { + '/artists': ARTISTS_INPAGE_SCROLL_VIEWPORT_ID, + '/albums': ALBUMS_INPAGE_SCROLL_VIEWPORT_ID, + '/new-releases': NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID, + '/lossless-albums': LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID, + '/composers': COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID, +}; diff --git a/src/hooks/useArtistsInfiniteScroll.ts b/src/hooks/useArtistsInfiniteScroll.ts index eb9c472a..0c6e0088 100644 --- a/src/hooks/useArtistsInfiniteScroll.ts +++ b/src/hooks/useArtistsInfiniteScroll.ts @@ -4,6 +4,8 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; interface UseArtistsInfiniteScrollArgs { pageSize: number; resetDeps: ReadonlyArray; + /** IntersectionObserver root (e.g. Artists in-page overlay viewport). */ + getScrollRoot?: () => HTMLElement | null; } interface UseArtistsInfiniteScrollResult { @@ -32,6 +34,7 @@ interface UseArtistsInfiniteScrollResult { export function useArtistsInfiniteScroll({ pageSize, resetDeps, + getScrollRoot, }: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult { const [visibleCount, setVisibleCount] = useState(pageSize); const [loadingMore, setLoadingMore] = useState(false); @@ -58,7 +61,7 @@ export function useArtistsInfiniteScroll({ observerInst.current = null; if (!node) return; - const rootEl = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + const rootEl = getScrollRoot?.() ?? document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const observer = new IntersectionObserver( entries => { if (entries[0]?.isIntersecting) loadMoreRef.current(); @@ -70,7 +73,7 @@ export function useArtistsInfiniteScroll({ ); observer.observe(node); observerInst.current = observer; - }, []); + }, [getScrollRoot]); useEffect(() => () => { observerInst.current?.disconnect(); diff --git a/src/hooks/useMainScrollingIndicator.ts b/src/hooks/useMainScrollingIndicator.ts index 6b1b6ba7..887e26b2 100644 --- a/src/hooks/useMainScrollingIndicator.ts +++ b/src/hooks/useMainScrollingIndicator.ts @@ -1,17 +1,20 @@ import { useEffect, useState } from 'react'; -import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { + APP_MAIN_SCROLL_VIEWPORT_ID, + MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH, +} from '../constants/appScroll'; const SCROLL_IDLE_MS = 180; /** - * `true` while the main route viewport or the Now Playing viewport is - * actively scrolling, falling back to `false` after `SCROLL_IDLE_MS` of - * silence. Used to fade out the queue handle (and similar floating - * controls) while the user is scrolling, so they don't sit on top of the - * overlay scrollbar thumb. + * `true` while a tracked viewport is actively scrolling, then `false` after + * `SCROLL_IDLE_MS` of silence. Used to fade out the queue handle (and similar + * floating controls) while the user is scrolling, so they don't sit on top of + * the overlay scrollbar thumb. * - * Re-binds on `pathname` change because Now Playing's viewport mounts - * lazily and isn't in the DOM on every route. + * Tracks `#app-main-scroll-viewport`, and on browse routes with a locked main + * scroll also the matching in-page overlay viewport. Re-binds on `pathname` + * because Now Playing's viewport mounts lazily. */ export function useMainScrollingIndicator(pathname: string): boolean { const [isMainScrolling, setIsMainScrolling] = useState(false); @@ -22,6 +25,11 @@ export function useMainScrollingIndicator(pathname: string): boolean { if (appViewport) viewports.add(appViewport); const nowPlayingViewport = document.querySelector('.np-main__viewport'); if (nowPlayingViewport) viewports.add(nowPlayingViewport); + const inpageId = MAIN_ROUTE_INPAGE_SCROLL_VIEWPORT_ID_BY_PATH[pathname]; + if (inpageId) { + const inpageVp = document.getElementById(inpageId); + if (inpageVp) viewports.add(inpageVp); + } if (viewports.size === 0) return; let scrollHideTimer: number | null = null; diff --git a/src/hooks/useMainstageInpageHeaderTight.ts b/src/hooks/useMainstageInpageHeaderTight.ts new file mode 100644 index 00000000..c4ba0e35 --- /dev/null +++ b/src/hooks/useMainstageInpageHeaderTight.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; + +const TIGHT_AFTER_PX = 10; +const LOOSE_BELOW_PX = 2; + +/** + * Compact the browse toolbar when the in-page overlay viewport scrolls down, + * same thresholds as Artists (`> 10` tight, `< 2` loose). + */ +export function useMainstageInpageHeaderTight( + scrollBodyEl: HTMLElement | null, + resetDeps: ReadonlyArray, +): boolean { + const [tight, setTight] = useState(false); + + useEffect(() => { + if (!scrollBodyEl) return; + const el = scrollBodyEl; + const onScroll = () => { + const y = el.scrollTop; + setTight(prev => { + if (y > TIGHT_AFTER_PX) return true; + if (y < LOOSE_BELOW_PX) return false; + return prev; + }); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + return () => el.removeEventListener('scroll', onScroll); + }, [scrollBodyEl]); + + useEffect(() => { + setTight(false); + // Spread values so deps track filter keys, not a new array identity each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...resetDeps]); + + return tight; +} diff --git a/src/hooks/usePlatformShellSetup.ts b/src/hooks/usePlatformShellSetup.ts index 90d30139..417e4577 100644 --- a/src/hooks/usePlatformShellSetup.ts +++ b/src/hooks/usePlatformShellSetup.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; +import type { LinuxWaylandTextRenderProfile } from '../store/authStoreTypes'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; /** @@ -12,8 +13,10 @@ import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; */ export function usePlatformShellSetup(): { isTilingWm: boolean } { const [isTilingWm, setIsTilingWm] = useState(false); + const [waylandTextUi, setWaylandTextUi] = useState(false); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll); + const linuxWaylandTextRenderProfile = useAuthStore(s => s.linuxWaylandTextRenderProfile); const loggingMode = useAuthStore(s => s.loggingMode); useEffect(() => { @@ -28,11 +31,54 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } { }).catch(() => {}); }, []); + useEffect(() => { + if (!IS_LINUX) return; + invoke('linux_wayland_text_render_settings_available') + .then(av => { + setWaylandTextUi(av); + if (av) { + document.documentElement.setAttribute('data-linux-session', 'wayland'); + } else { + document.documentElement.removeAttribute('data-linux-session'); + document.documentElement.removeAttribute('data-wayland-text-profile'); + } + }) + .catch(() => {}); + }, []); + useEffect(() => { const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown'; document.documentElement.setAttribute('data-platform', platform); }, []); + // Wayland text profile: CSS on updates live; Rust persists for next launch / new mini webview + // (WebKitGTK can hang when hardware-acceleration-policy is toggled repeatedly at runtime). + useEffect(() => { + if (!IS_LINUX || !waylandTextUi) { + document.documentElement.removeAttribute('data-wayland-text-profile'); + return; + } + + let cancelHydration: (() => void) | undefined; + + const apply = (profile: LinuxWaylandTextRenderProfile) => { + document.documentElement.setAttribute('data-wayland-text-profile', profile); + invoke('set_linux_wayland_text_render_profile', { profile }).catch(() => {}); + }; + + apply(linuxWaylandTextRenderProfile); + + if (!useAuthStore.persist.hasHydrated()) { + cancelHydration = useAuthStore.persist.onFinishHydration(() => { + apply(useAuthStore.getState().linuxWaylandTextRenderProfile); + }); + } + + return () => { + cancelHydration?.(); + }; + }, [IS_LINUX, waylandTextUi, linuxWaylandTextRenderProfile]); + // Sync custom titlebar preference with native decorations on Linux. // On tiling WMs decorations are always off (no native title bar to replace). useEffect(() => { @@ -46,6 +92,23 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } { invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {}); }, [linuxWebkitKineticScroll]); + // Persist rehydrates after first paint — default store has kinetic scroll ON until localStorage merges. + // Re-apply OS WebKit prefs after hydrate (same pattern as useMiniWindowSetup) so OFF stays OFF. + useEffect(() => { + if (!IS_LINUX) return; + const applySmoothFromStore = () => { + invoke('set_linux_webkit_smooth_scrolling', { + enabled: useAuthStore.getState().linuxWebkitKineticScroll, + }).catch(() => {}); + }; + if (useAuthStore.persist.hasHydrated()) { + applySmoothFromStore(); + } + return useAuthStore.persist.onFinishHydration(() => { + applySmoothFromStore(); + }); + }, []); + useEffect(() => { invoke('set_logging_mode', { mode: loggingMode }).catch(() => {}); }, [loggingMode]); diff --git a/src/hooks/useResizeClientHeight.ts b/src/hooks/useResizeClientHeight.ts index 19a0d557..16d2a8eb 100644 --- a/src/hooks/useResizeClientHeight.ts +++ b/src/hooks/useResizeClientHeight.ts @@ -37,3 +37,23 @@ export function useRefElementClientHeight( }, [ref, fallback]); return h; } + +/** ResizeObserver on a concrete element (e.g. callback-ref state for in-page scrollers). */ +export function useElementClientHeightForElement( + element: HTMLElement | null, + fallback = 600, +): number { + const [h, setH] = useState(fallback); + useLayoutEffect(() => { + if (!element) { + setH(fallback); + return; + } + const update = () => setH(element.clientHeight); + const ro = new ResizeObserver(update); + ro.observe(element); + update(); + return () => ro.disconnect(); + }, [element, fallback]); + return h; +} diff --git a/src/locales/de/help.ts b/src/locales/de/help.ts index ca175e1b..43f4d709 100644 --- a/src/locales/de/help.ts +++ b/src/locales/de/help.ts @@ -111,5 +111,5 @@ export const help = { q44: 'Cover und Künstlerbilder laden langsam.', a44: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern; danach sind sie sofort da. Der Hot Cache hilft auch bei Tracks, der Bild-Cache ist davon unabhängig.', q45: 'Linux-Probleme — schwarzer Bildschirm oder kein Ton?', - a45: 'Schwarzer Bildschirm ist meist ein GPU- / EGL-Treiberproblem in WebKitGTK — mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten (die AUR- / .deb- / .rpm-Installer setzen das automatisch). Für Audio sicherstellen, dass PipeWire oder PulseAudio läuft. Tonaussetzer nach Sleep / Wake werden mittlerweile vom Post-Sleep-Recovery-Hook automatisch behandelt.', + a45: 'Ein schwarzer Bildschirm unter Linux betrifft meist Grafik oder die WebView-Anzeige. Versuchen Sie eine X11-Sitzung oder setzen Sie vor dem Start GDK_BACKEND=x11 und EGL_PLATFORM=x11 (unter Wayland). Für Ton: PipeWire oder PulseAudio. Verschwindet der Ton nach Standby, beenden Sie Psysonic vollständig und starten Sie neu.', }; diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 065fa501..23aedcfc 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -185,6 +185,13 @@ export const settings = { useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.', linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)', linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.', + linuxWaylandTextRender: 'Wayland-Textdarstellung (Linux)', + linuxWaylandTextRenderDesc: + 'Kantenglättung in der Oberfläche wirkt sofort. Die WebKit-Beschleunigungsrichtlinie (scharf/GPU) wird gespeichert und beim nächsten App-Start angewendet — ein Live-Umschalten kann WebKitGTK einfrieren.', + linuxWaylandTextRenderBalanced: 'Ausgewogen', + linuxWaylandTextRenderSharp: 'Scharf (CPU-freundlich)', + linuxWaylandTextRenderGpu: 'GPU zuerst', + linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)', discordCoverSource: 'Cover-Quelle', discordCoverSourceDesc: 'Woher das Album-Cover für dein Discord-Profil geladen wird.', discordCoverNone: 'Keine (nur App-Symbol)', diff --git a/src/locales/en/help.ts b/src/locales/en/help.ts index d64bf21c..c75e9014 100644 --- a/src/locales/en/help.ts +++ b/src/locales/en/help.ts @@ -111,5 +111,5 @@ export const help = { q44: 'Cover art and artist images load slowly.', a44: 'Images are fetched from your server on first view and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page can take a moment; subsequent visits are instant. The Hot Cache also helps for tracks but image cache is independent.', q45: 'Linux issues — black screen or no audio?', - a45: 'Black screen is usually a GPU / EGL driver issue in WebKitGTK — launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (the AUR / .deb / .rpm installers set these automatically). For audio, make sure PipeWire or PulseAudio is running. If audio drops out after sleep / wake, this is now handled automatically by the post-sleep recovery hook.', + a45: 'A black screen on Linux is usually a graphics or WebView display issue. Try an X11 session, or set GDK_BACKEND=x11 and EGL_PLATFORM=x11 before launching if you use Wayland. For audio, make sure PipeWire or PulseAudio is running. If sound stops after sleep or wake, quit Psysonic completely and open it again.', }; diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 813dc4b6..751e076b 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -188,6 +188,13 @@ export const settings = { useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.', linuxWebkitSmoothScroll: 'Smooth wheel (Linux)', linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.', + linuxWaylandTextRender: 'Wayland text rendering (Linux)', + linuxWaylandTextRenderDesc: + 'Font smoothing in the UI updates immediately. WebKit hardware acceleration (sharp / GPU presets) is saved and applied on the next app start — changing it live can freeze WebKitGTK on some setups.', + linuxWaylandTextRenderBalanced: 'Balanced', + linuxWaylandTextRenderSharp: 'Sharp (CPU-friendly)', + linuxWaylandTextRenderGpu: 'GPU-first', + linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)', discordCoverSource: 'Cover art source', discordCoverSourceDesc: 'Where to fetch album artwork shown on your Discord profile.', discordCoverNone: 'None (app icon only)', diff --git a/src/locales/es/help.ts b/src/locales/es/help.ts index baf2d8aa..b383f9c9 100644 --- a/src/locales/es/help.ts +++ b/src/locales/es/help.ts @@ -101,5 +101,5 @@ export const help = { q44: 'Las portadas e imágenes de artista cargan lentamente.', a44: 'Las imágenes se obtienen del servidor en la primera vista y luego se almacenan localmente durante 30 días. Si el almacenamiento de tu servidor es lento, la primera visita a una página puede tardar un momento; las visitas posteriores son instantáneas. Hot Cache también ayuda con pistas pero el caché de imágenes es independiente.', q45: 'Problemas en Linux — ¿pantalla negra o sin sonido?', - a45: 'La pantalla negra es generalmente un problema de driver GPU / EGL en WebKitGTK — lanza con GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (los instaladores AUR / .deb / .rpm los configuran automáticamente). Para audio, asegúrate de que PipeWire o PulseAudio esté ejecutándose. Los cortes de audio tras suspensión / despertar ahora son manejados automáticamente por el hook de recuperación post-sleep.', + a45: 'La pantalla negra en Linux suele ser un problema de gráficos o de la ventana WebView. Prueba una sesión X11 o, antes de abrir la app, exporta GDK_BACKEND=x11 y EGL_PLATFORM=x11 si usas Wayland. Audio: PipeWire o PulseAudio. Si pierdes el sonido tras suspender, cierra por completo Psysonic y vuelve a abrirlo.', }; diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 6496ffc6..c03dcdd1 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -185,6 +185,13 @@ export const settings = { useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.', linuxWebkitSmoothScroll: 'Rueda suave (Linux)', linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).', + linuxWaylandTextRender: 'Renderizado de texto Wayland (Linux)', + linuxWaylandTextRenderDesc: + 'El suavizado en la interfaz se aplica al instante. La aceleración de hardware de WebKit (nítido/GPU) se guarda y aplica al reiniciar la app; cambiarla en vivo puede congelar WebKitGTK.', + linuxWaylandTextRenderBalanced: 'Equilibrado', + linuxWaylandTextRenderSharp: 'Nítido (más CPU)', + linuxWaylandTextRenderGpu: 'Prioridad GPU', + linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)', discordCoverSource: 'Fuente de portada', discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.', discordCoverNone: 'Ninguna (solo icono de la app)', diff --git a/src/locales/fr/help.ts b/src/locales/fr/help.ts index 8962b8ec..251d33e1 100644 --- a/src/locales/fr/help.ts +++ b/src/locales/fr/help.ts @@ -101,5 +101,5 @@ export const help = { q44: 'Les pochettes et images d\'artiste se chargent lentement.', a44: 'Les images sont récupérées du serveur à la première vue puis mises en cache localement pour 30 jours. Si le stockage du serveur est lent, la première visite d\'une page peut prendre un instant ; les visites suivantes sont instantanées. Le Hot Cache aide aussi pour les pistes mais le cache d\'images est indépendant.', q45: 'Problèmes Linux — écran noir ou pas de son ?', - a45: 'L\'écran noir est généralement un souci de pilote GPU / EGL dans WebKitGTK — lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (les installeurs AUR / .deb / .rpm le font automatiquement). Pour le son, vérifiez que PipeWire ou PulseAudio tourne. Les coupures audio après mise en veille sont désormais gérées automatiquement par le hook de récupération post-sleep.', + a45: 'L\'écran noir sous Linux indique souvent un souci d\'affichage WebView ou graphique. Essayez une session X11, ou définissez GDK_BACKEND=x11 et EGL_PLATFORM=x11 avant le lancement sous Wayland. Audio : PipeWire ou PulseAudio. Si le son disparaît après une mise en veille, quittez entièrement Psysonic puis rouvrez-le.', }; diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index eeaf816d..7d98ab54 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -181,6 +181,13 @@ export const settings = { preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.', linuxWebkitSmoothScroll: 'Molette fluide (Linux)', linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.', + linuxWaylandTextRender: 'Rendu du texte Wayland (Linux)', + linuxWaylandTextRenderDesc: + 'Le lissage dans l’interface est immédiat. L’accélération matérielle WebKit (net / GPU) est enregistrée et appliquée au prochain lancement — la modifier en direct peut figer WebKitGTK.', + linuxWaylandTextRenderBalanced: 'Équilibré', + linuxWaylandTextRenderSharp: 'Net (favorise le CPU)', + linuxWaylandTextRenderGpu: 'GPU prioritaire', + linuxWaylandTextRenderMinimal: 'Minimal (lissage CSS par défaut)', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', discordCoverSource: 'Source de pochette', diff --git a/src/locales/nb/help.ts b/src/locales/nb/help.ts index 494a84f9..be1713f1 100644 --- a/src/locales/nb/help.ts +++ b/src/locales/nb/help.ts @@ -101,5 +101,5 @@ export const help = { q44: 'Cover-art og artistbilder lastes sakte.', a44: 'Bilder hentes fra serveren ved første visning og caches deretter lokalt i 30 dager. Hvis serverens lagring er treg, kan første besøk på en side ta et øyeblikk; påfølgende besøk er umiddelbare. Hot Cache hjelper også for spor, men bildecachen er uavhengig.', q45: 'Linux-problemer — svart skjerm eller ingen lyd?', - a45: 'Svart skjerm er vanligvis et GPU- / EGL-driverproblem i WebKitGTK — start med GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (AUR / .deb / .rpm-installerne setter disse automatisk). For lyd, sørg for at PipeWire eller PulseAudio kjører. Lydutfall etter dvale / oppvåkning håndteres nå automatisk av post-sleep recovery hook.', + a45: 'Svart skjerm på Linux tyder vanligvis på grafikk eller WebView-visning. Prøv X11-økt, eller sett GDK_BACKEND=x11 og EGL_PLATFORM=x11 før start ved Wayland. Lyd: PipeWire eller PulseAudio. Forsvinner lyd etter dvale: lukk Psysonic helt og åpne igjen.', }; diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index f637fdf5..d9cf7096 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -180,6 +180,13 @@ export const settings = { preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.', linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)', linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.', + linuxWaylandTextRender: 'Wayland-tekstgjengivelse (Linux)', + linuxWaylandTextRenderDesc: + 'Utjevning i grensesnittet skjer med én gang. WebKit-maskinvareakselerasjon (skarp/GPU) lagres og brukes ved neste appstart — live bytte kan fryse WebKitGTK.', + linuxWaylandTextRenderBalanced: 'Balansert', + linuxWaylandTextRenderSharp: 'Skarpt (CPU-vennlig)', + linuxWaylandTextRenderGpu: 'GPU først', + linuxWaylandTextRenderMinimal: 'Minimum (standard CSS-utjevning)', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.', discordCoverSource: 'Coverkilde', diff --git a/src/locales/nl/help.ts b/src/locales/nl/help.ts index e5f0de6f..6be3367a 100644 --- a/src/locales/nl/help.ts +++ b/src/locales/nl/help.ts @@ -101,5 +101,5 @@ export const help = { q44: 'Hoezen en artiestafbeeldingen laden traag.', a44: 'Afbeeldingen worden bij de eerste weergave van uw server gehaald en vervolgens 30 dagen lokaal gecached. Als de opslag van uw server traag is, kan het eerste bezoek aan een pagina even duren; volgende bezoeken zijn onmiddellijk. De Hot Cache helpt ook voor nummers, maar de afbeeldingscache is onafhankelijk.', q45: 'Linux-problemen — zwart scherm of geen geluid?', - a45: 'Zwart scherm is meestal een GPU / EGL-stuurprogrammakwestie in WebKitGTK — start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (de AUR / .deb / .rpm-installers stellen deze automatisch in). Voor audio, zorg ervoor dat PipeWire of PulseAudio draait. Audio-uitval na slaap / wakker worden wordt nu automatisch afgehandeld door de post-sleep recovery hook.', + a45: 'Een zwart scherm op Linux wijst meestal op een grafisch of WebView-weergaveprobleem. Probeer een X11-sessie of stel vóór starten GDK_BACKEND=x11 en EGL_PLATFORM=x11 in bij Wayland. Audio: PipeWire of PulseAudio. Verdwijnt geluid na slaapstand: sluit Psysonic volledig af en start opnieuw.', }; diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index de1a32fd..08ec08ed 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -181,6 +181,13 @@ export const settings = { preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.', linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)', linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.', + linuxWaylandTextRender: 'Wayland-tekstweergave (Linux)', + linuxWaylandTextRenderDesc: + 'Vloeiendheid in de UI werkt meteen. WebKit-hardwareversnelling (scherp/GPU) wordt bewaard en toegepast bij de volgende app-start — live schakelen kan WebKitGTK laten vastlopen.', + linuxWaylandTextRenderBalanced: 'Gebalanceerd', + linuxWaylandTextRenderSharp: 'Scherp (CPU-vriendelijk)', + linuxWaylandTextRenderGpu: 'GPU eerst', + linuxWaylandTextRenderMinimal: 'Minimaal (standaard CSS-verzachting)', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', discordCoverSource: 'Hoesbron', diff --git a/src/locales/ro/help.ts b/src/locales/ro/help.ts index 31c9d09d..a3424be1 100644 --- a/src/locales/ro/help.ts +++ b/src/locales/ro/help.ts @@ -111,5 +111,5 @@ export const help = { q44: 'Arta copertei și imaginile artiștilor se încarcă încet.', a44: 'Imaginile sunt preluate de pe serverul tău la prima vedere și apoi stocate în cache pentru 30 de zile. Dacă stocarea serverului tău este înceată, prima vizită către o pagină poate dura un moment; vizitele consecutive sunt instantanee. Hot Cache de asemenea ajută pentru piese dar nu pentru cache-ul imaginii.', q45: 'Probleme pe Linux - ecran negru sau niciun audio?', - a45: 'Aceasta este de obicei o problemă de driver GPU/EGL în WebKitGTK. Pornește cu GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Pachetul AUR și installer-ele oficiale .deb/.rpm fac asta automat.', + a45: 'Ecranul negru pe Linux este de obicei o problemă de grafică sau de afișare WebView. Încearcă o sesiune X11 sau setează înainte de lansare GDK_BACKEND=x11 și EGL_PLATFORM=x11 dacă folosești Wayland. Pentru audio: PipeWire sau PulseAudio. Dacă sunetul dispare după somn, închide complet Psysonic și redeschide-l.', }; diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 45060fc7..79be6a49 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -188,6 +188,13 @@ export const settings = { useCustomTitlebarDesc: 'Înlocuiește bara de titlu a sistemului cu una care corespunde cu tema aplicației. Dezactivează pentru a folosi bara de titlu nativ GNOME/GTK.', linuxWebkitSmoothScroll: 'Rotiță lină (Linux)', linuxWebkitSmoothScrollDesc: 'Pornit: scroll inert. Oprit: linie cu linie, în stil GTK.', + linuxWaylandTextRender: 'Randare text Wayland (Linux)', + linuxWaylandTextRenderDesc: + 'Netezirea în interfață se aplică imediat. Accelerația hardware WebKit (ascuțit/GPU) este salvată și aplicată la următoarea pornire a aplicației — comutarea în timpul rulării poate îngheța WebKitGTK.', + linuxWaylandTextRenderBalanced: 'Echilibrat', + linuxWaylandTextRenderSharp: 'Ascuțit (prietenos cu CPU)', + linuxWaylandTextRenderGpu: 'Prioritate GPU', + linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)', discordCoverSource: 'Sursa artei de copertă', discordCoverSourceDesc: 'De unde să fie preluată arta de album afișată pe profilul tău de Discord.', discordCoverNone: 'Niciuna (doar iconița aplicației)', diff --git a/src/locales/ru/help.ts b/src/locales/ru/help.ts index 28db0e4c..eb6fc58c 100644 --- a/src/locales/ru/help.ts +++ b/src/locales/ru/help.ts @@ -101,5 +101,5 @@ export const help = { q44: 'Обложки и изображения исполнителей загружаются медленно.', a44: 'Изображения подгружаются с сервера при первом просмотре, затем кэшируются локально на 30 дней. Если хранилище сервера медленное, первый визит на страницу может занять момент; последующие визиты мгновенные. Hot Cache также помогает с треками, но кэш изображений независим.', q45: 'Проблемы Linux — чёрный экран или нет звука?', - a45: 'Чёрный экран обычно проблема GPU / EGL драйвера в WebKitGTK — запускайте с GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (AUR / .deb / .rpm установщики устанавливают это автоматически). Для аудио убедитесь, что PipeWire или PulseAudio запущены. Пропадание звука после спящего режима / пробуждения теперь обрабатывается автоматически post-sleep recovery hook.', + a45: 'Чёрный экран на Linux чаще всего связан с отображением окна приложения или графическим стеком. Попробуйте сеанс на X11 или перед запуском задайте GDK_BACKEND=x11 и EGL_PLATFORM=x11, если используете Wayland. Для звука нужны PipeWire или PulseAudio. Если после сна пропал звук — полностью закройте Psysonic и откройте снова.', }; diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 29daaae0..3610102a 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -190,6 +190,13 @@ export const settings = { 'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.', linuxWebkitSmoothScroll: 'Плавное колесо (Linux)', linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.', + linuxWaylandTextRender: 'Рендеринг текста Wayland (Linux)', + linuxWaylandTextRenderDesc: + 'Сглаживание в интерфейсе меняется сразу. Политику ускорения WebKit (чёткий / GPU) сохраняем и применяем при следующем запуске — переключение на лету на части сборок зависает в WebKitGTK.', + linuxWaylandTextRenderBalanced: 'Сбалансированный', + linuxWaylandTextRenderSharp: 'Чёткий (меньше GPU)', + linuxWaylandTextRenderGpu: 'С приоритетом GPU', + linuxWaylandTextRenderMinimal: 'Минимальный (сглаживание по умолчанию)', discordRichPresence: 'Статус в Discord', discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', diff --git a/src/locales/zh/help.ts b/src/locales/zh/help.ts index eb6e92e7..f8391080 100644 --- a/src/locales/zh/help.ts +++ b/src/locales/zh/help.ts @@ -101,5 +101,5 @@ export const help = { q44: '封面和艺术家图像加载缓慢。', a44: '图像在首次查看时从服务器获取,然后本地缓存 30 天。如果您服务器的存储速度慢,第一次访问页面可能需要片刻;后续访问是即时的。Hot Cache 也对曲目有帮助,但图像缓存是独立的。', q45: 'Linux 问题 — 黑屏或无声音?', - a45: '黑屏通常是 WebKitGTK 中的 GPU / EGL 驱动程序问题 — 使用 GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 启动(AUR / .deb / .rpm 安装程序自动设置这些)。对于音频,确保 PipeWire 或 PulseAudio 正在运行。睡眠/唤醒后的音频丢失现在由 post-sleep 恢复钩子自动处理。', + a45: 'Linux 黑屏多半是图形或应用 WebView 显示层面的问题。可改用 X11 会话,或在 Wayland 下启动前设置 GDK_BACKEND=x11、EGL_PLATFORM=x11。声音请确认 PipeWire 或 PulseAudio 在运行;若睡眠唤醒后无声,完全退出 Psysonic 再重新打开。', }; diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 779a6b2e..58a7b992 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -181,6 +181,12 @@ export const settings = { preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。', linuxWebkitSmoothScroll: '滚轮平滑(Linux)', linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。', + linuxWaylandTextRender: 'Wayland 文本渲染(Linux)', + linuxWaylandTextRenderDesc: '界面字体平滑立即生效。WebKit 硬件加速策略(锐利 / GPU)会保存并在下次启动应用时应用;运行中反复切换可能导致 WebKitGTK 卡死。', + linuxWaylandTextRenderBalanced: '平衡', + linuxWaylandTextRenderSharp: '锐利(偏 CPU)', + linuxWaylandTextRenderGpu: 'GPU 优先', + linuxWaylandTextRenderMinimal: '最小(默认 CSS 平滑)', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', discordCoverSource: '封面来源', diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index a2f1a559..df7edcf6 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -4,7 +4,7 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { dedupeById } from '../utils/dedupeById'; -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import YearFilterButton from '../components/YearFilterButton'; @@ -19,10 +19,13 @@ import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/ui/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; -import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react'; +import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { useRangeSelection } from '../hooks/useRangeSelection'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import OverlayScrollArea from '../components/OverlayScrollArea'; +import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type CompFilter = 'all' | 'only' | 'hide'; @@ -58,6 +61,12 @@ export default function Albums() { const [compFilter, setCompFilter] = useState('all'); const [starredOnly, setStarredOnly] = useState(false); const observerTarget = useRef(null); + const scrollBodyRef = useRef(null); + const [scrollBodyEl, setScrollBodyEl] = useState(null); + const bindAlbumsScrollBody = useCallback((el: HTMLDivElement | null) => { + scrollBodyRef.current = el; + setScrollBodyEl(el); + }, []); // ── Multi-selection ────────────────────────────────────────────────────── // selectedIds + toggleSelect come from useRangeSelection (declared after @@ -88,7 +97,6 @@ export default function Albums() { }; const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id)); - const openContextMenu = usePlayerStore(state => state.openContextMenu); const enqueue = usePlayerStore(state => state.enqueue); const handleEnqueueSelected = async () => { @@ -155,6 +163,18 @@ export default function Albums() { const toNum = parseInt(yearTo, 10); const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1; + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + sort, + genreFiltered, + yearActive, + yearFrom, + yearTo, + compFilter, + starredOnly, + selectionMode, + selectedGenres, + ]); + const load = useCallback(async ( sortType: SortType, offset: number, @@ -210,15 +230,19 @@ export default function Albums() { }, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]); useEffect(() => { + const node = observerTarget.current; + if (!node) return; + const root = scrollBodyRef.current; const observer = new IntersectionObserver( - entries => { if (entries[0].isIntersecting) loadMore(); }, - // Prefetch ~1.5 screens ahead so the user never visibly waits at the - // bottom of the grid. 200px caught the user at the very edge. - { rootMargin: '1500px' } + entries => { if (entries[0]?.isIntersecting) loadMore(); }, + { + root: root instanceof HTMLElement ? root : null, + rootMargin: '1500px', + }, ); - if (observerTarget.current) observer.observe(observerTarget.current); + observer.observe(node); return () => observer.disconnect(); - }, [loadMore]); + }, [loadMore, scrollBodyEl]); const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, @@ -226,122 +250,142 @@ export default function Albums() { ]; return ( -
+
{!perfFlags.disableMainstageStickyHeader && ( -
-

- {selectionMode && selectedIds.size > 0 - ? t('albums.selectionCount', { count: selectedIds.size }) - : t('albums.title')} -

-
- {selectionMode && selectedIds.size > 0 ? ( - <> - - - - - ) : ( - <> - {!yearActive && ( - +
+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('albums.title')} +

+
+ {selectionMode && selectedIds.size > 0 ? ( + <> + + + + + ) : ( + <> + {!yearActive && ( + + )} + + { setYearFrom(from); setYearTo(to); }} /> - )} - { setYearFrom(from); setYearTo(to); }} - /> + - + - + + + )} - - - )} - - + +
)} - {loading && albums.length === 0 ? ( -
-
-
- ) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? ( -
- {t('common.libraryEmpty')} -
- ) : ( - <> - {!perfFlags.disableMainstageGridCards && ( - a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={visibleAlbums.length} - renderItem={a => ( - - )} - /> - )} - {!genreFiltered && ( -
- {loading && hasMore &&
} -
- )} - - )} - + + {loading && albums.length === 0 ? ( +
+
+
+ ) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !starredOnly && compFilter === 'all' ? ( +
+ {t('common.libraryEmpty')} +
+ ) : ( + <> + {!perfFlags.disableMainstageGridCards && ( + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={visibleAlbums.length} + scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} + renderItem={a => ( + + )} + /> + )} + {!genreFiltered && ( +
+ {loading && hasMore &&
} +
+ )} + + )} +
); } diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index e92256a7..f2e0ec46 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -4,12 +4,13 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react'; import StarFilterButton from '../components/StarFilterButton'; +import OverlayScrollArea from '../components/OverlayScrollArea'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; -import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; +import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; import { useCardGridMetrics } from '../hooks/useCardGridMetrics'; import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; @@ -22,6 +23,7 @@ import { ARTIST_LIST_ROW_EST, } from '../utils/componentHelpers/artistsHelpers'; import { useArtistsFiltering } from '../hooks/useArtistsFiltering'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll'; import { ArtistsGridView } from '../components/artists/ArtistsGridView'; import { ArtistsListView } from '../components/artists/ArtistsListView'; @@ -36,6 +38,14 @@ export default function Artists() { const [starredOnly, setStarredOnly] = useState(false); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const artistsScrollBodyRef = useRef(null); + const [artistsScrollBodyEl, setArtistsScrollBodyEl] = useState(null); + const bindArtistsScrollBody = useCallback((el: HTMLDivElement | null) => { + artistsScrollBodyRef.current = el; + setArtistsScrollBodyEl(el); + }, []); + const getArtistsScrollRoot = useCallback(() => artistsScrollBodyRef.current, []); + const showArtistImages = useAuthStore(s => s.showArtistImages); const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O const { @@ -45,6 +55,7 @@ export default function Artists() { } = useArtistsInfiniteScroll({ pageSize: PAGE_SIZE, resetDeps: [filter, letterFilter, starredOnly, viewMode], + getScrollRoot: getArtistsScrollRoot, }); const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); @@ -68,11 +79,6 @@ export default function Artists() { }); }, []); - const clearSelection = () => { - setSelectionMode(false); - setSelectedIds(new Set()); - }; - const selectedArtists = artists.filter(a => selectedIds.has(a.id)); useEffect(() => { @@ -83,7 +89,25 @@ export default function Artists() { filtered, visible, hasMore, groups, letters, artistListFlatRows, } = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode }); + const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [ + filter, + letterFilter, + starredOnly, + viewMode, + ]); + const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + const artistsInpageScrollHeight = useElementClientHeightForElement( + artistsScrollBodyEl, + mainScrollViewportHeight, + ); + + const getInpageScrollElement = useCallback( + () => + artistsScrollBodyRef.current + ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null), + [], + ); const artistGridMeasureRef = useRef(null); const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics( @@ -97,7 +121,7 @@ export default function Artists() { const artistGridOverscan = Math.max( 2, - Math.ceil(mainScrollViewportHeight / Math.max(1, artistGridRowHeightEst)), + Math.ceil(artistsInpageScrollHeight / Math.max(1, artistGridRowHeightEst)), ); const artistGridScrollMargin = useVirtualizerScrollMargin( @@ -114,7 +138,7 @@ export default function Artists() { perfFlags.disableMainstageVirtualLists || viewMode !== 'grid' ? 0 : artistVirtualRowCount, - getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getScrollElement: getInpageScrollElement, estimateSize: () => artistGridRowHeightEst, overscan: artistGridOverscan, scrollMargin: artistGridScrollMargin, @@ -127,10 +151,9 @@ export default function Artists() { virtualRowCount: artistVirtualRowCount, }); - /** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */ const artistListOverscan = Math.max( 12, - Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST), + Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST), ); const artistListWrapRef = useRef(null); @@ -146,14 +169,13 @@ export default function Artists() { const artistListVirtualizer = useVirtualizer({ count: perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, - getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getScrollElement: getInpageScrollElement, estimateSize: index => { const row = artistListFlatRows[index]; if (!row) return ARTIST_LIST_ROW_EST; if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST; return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST; }, - /** Stable keys — avoids row DOM reuse glitches when the filtered slice changes. */ getItemKey: index => { const row = artistListFlatRows[index]; if (!row) return index; @@ -165,135 +187,156 @@ export default function Artists() { }); return ( -
-
-
-
-

- {selectionMode && selectedIds.size > 0 - ? t('artists.selectionCount', { count: selectedIds.size }) - : t('artists.title')} -

- setFilter(e.target.value)} - id="artist-filter-input" - /> +
+
+
+
+
+

+ {selectionMode && selectedIds.size > 0 + ? t('artists.selectionCount', { count: selectedIds.size }) + : t('artists.title')} +

+ setFilter(e.target.value)} + id="artist-filter-input" + /> +
+ +
+ {!(selectionMode && selectedIds.size > 0) && (<> + + + + + + )} + +
-
- {!(selectionMode && selectedIds.size > 0) && (<> - - - - - - )} - +
+ {ALPHABET.map(l => ( + + ))}
- -
- {ALPHABET.map(l => ( - - ))} -
- {loading &&
} + + {loading &&
} - {!loading && viewMode === 'grid' && ( - - )} + {!loading && viewMode === 'grid' && ( + + )} - {!loading && viewMode === 'list' && ( - - )} + {!loading && viewMode === 'list' && ( + + )} - {!loading && hasMore && ( -
- {loadingMore &&
} -
- )} + {!loading && hasMore && ( +
+ {loadingMore &&
} +
+ )} - {!loading && filtered.length === 0 && ( -
- {t('artists.notFound')} -
- )} + {!loading && filtered.length === 0 && ( +
+ {t('artists.notFound')} +
+ )} +
); } diff --git a/src/pages/Composers.tsx b/src/pages/Composers.tsx index b9d7f90f..30684672 100644 --- a/src/pages/Composers.tsx +++ b/src/pages/Composers.tsx @@ -1,5 +1,5 @@ import type { SubsonicArtist } from '../api/subsonicTypes'; -import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ndListArtistsByRole } from '../api/navidromeBrowse'; import { LayoutGrid, List } from 'lucide-react'; @@ -8,10 +8,12 @@ import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; -import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; +import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import OverlayScrollArea from '../components/OverlayScrollArea'; import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin'; const ALL_SENTINEL = 'ALL'; @@ -79,6 +81,12 @@ export default function Composers() { const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [loadingMore, setLoadingMore] = useState(false); const observerTarget = useRef(null); + const scrollBodyRef = useRef(null); + const [scrollBodyEl, setScrollBodyEl] = useState(null); + const bindComposersScrollBody = useCallback((el: HTMLDivElement | null) => { + scrollBodyRef.current = el; + setScrollBodyEl(el); + }, []); const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); @@ -147,13 +155,19 @@ export default function Composers() { const hasMore = visibleCount < filtered.length; useEffect(() => { + const node = observerTarget.current; + if (!node) return; + const root = scrollBodyRef.current; const observer = new IntersectionObserver( - entries => { if (entries[0].isIntersecting) loadMore(); }, - { rootMargin: '200px' } + entries => { if (entries[0]?.isIntersecting) loadMore(); }, + { + root: root instanceof HTMLElement ? root : null, + rootMargin: '200px', + }, ); - if (observerTarget.current) observer.observe(observerTarget.current); + observer.observe(node); return () => observer.disconnect(); - }, [loadMore, hasMore]); + }, [loadMore, hasMore, scrollBodyEl]); const { groups, letters } = useMemo(() => { if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] }; @@ -181,9 +195,21 @@ export default function Composers() { }, [viewMode, letters, groups]); const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + const composersInpageScrollHeight = useElementClientHeightForElement( + scrollBodyEl, + mainScrollViewportHeight, + ); + + const getInpageScrollElement = useCallback( + () => + scrollBodyRef.current + ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null), + [], + ); + const composerListOverscan = Math.max( 12, - Math.ceil(mainScrollViewportHeight / COMPOSER_LIST_ROW_EST), + Math.ceil(composersInpageScrollHeight / COMPOSER_LIST_ROW_EST), ); const composerListWrapRef = useRef(null); @@ -199,7 +225,7 @@ export default function Composers() { const composerListVirtualizer = useVirtualizer({ count: perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length, - getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getScrollElement: getInpageScrollElement, estimateSize: index => { const row = composerListFlatRows[index]; if (!row) return COMPOSER_LIST_ROW_EST; @@ -216,6 +242,13 @@ export default function Composers() { scrollMargin: composerListScrollMargin, }); + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + filter, + letterFilter, + starredOnly, + viewMode, + ]); + if (loadError) { return (
@@ -237,130 +270,164 @@ export default function Composers() { } return ( -
-
-
-
-

{t('composers.title')}

- setFilter(e.target.value)} - id="composer-filter-input" - /> +
+
+
+
+
+

{t('composers.title')}

+ setFilter(e.target.value)} + id="composer-filter-input" + /> +
+ +
+ + + +
-
- - - +
+ {ALPHABET.map(l => ( + + ))}
- -
- {ALPHABET.map(l => ( - - ))} -
- {loading &&
} + + {loading &&
} - {!loading && viewMode === 'grid' && ( - a.id} - rowVariant="composer" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={visible.length} - wrapClassName="composer-grid-wrap" - gridGap="var(--space-2)" - renderItem={artist => ( -
navigate(`/composer/${artist.id}`)} - onContextMenu={(e) => { - e.preventDefault(); - openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); - }} - > -
{artist.name}
- {artist.albumCount != null && ( -
- {t('composers.involvedIn', { count: artist.albumCount })} -
- )} -
- )} - /> - )} - - {!loading && viewMode === 'list' && ( - perfFlags.disableMainstageVirtualLists ? ( - <> - {letters.map(letter => ( -
-

{letter}

-
- {groups[letter].map(artist => ( - - ))} -
+ {!loading && viewMode === 'grid' && ( + a.id} + rowVariant="composer" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={visible.length} + wrapClassName="composer-grid-wrap" + gridGap="var(--space-2)" + scrollRootId={COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID} + renderItem={artist => ( +
navigate(`/composer/${artist.id}`)} + onContextMenu={(e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); + }} + > +
{artist.name}
+ {artist.albumCount != null && ( +
+ {t('composers.involvedIn', { count: artist.albumCount })} +
+ )}
- ))} - - ) : ( -
-
- {composerListVirtualizer.getVirtualItems().map(vi => { - const row = composerListFlatRows[vi.index]; - if (!row) return null; - if (row.kind === 'letter') { + )} + /> + )} + + {!loading && viewMode === 'list' && ( + perfFlags.disableMainstageVirtualLists ? ( + <> + {letters.map(letter => ( +
+

{letter}

+
+ {groups[letter].map(artist => ( + + ))} +
+
+ ))} + + ) : ( +
+
+ {composerListVirtualizer.getVirtualItems().map(vi => { + const row = composerListFlatRows[vi.index]; + if (!row) return null; + if (row.kind === 'letter') { + return ( +
+

{row.letter}

+
+ ); + } + const artist = row.artist; return (
-

{row.letter}

+
); - } - const artist = row.artist; - return ( -
- -
- ); - })} + })} +
+ ) + )} + + {!loading && hasMore && ( +
+ {loadingMore &&
}
- ) - )} + )} - {!loading && hasMore && ( -
- {loadingMore &&
} -
- )} - - {!loading && filtered.length === 0 && ( -
- {t('composers.notFound')} -
- )} + {!loading && filtered.length === 0 && ( +
+ {t('composers.notFound')} +
+ )} +
); } diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index 43329a5d..d8840a6e 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -2,7 +2,7 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import AlbumCard from '../components/AlbumCard'; import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse'; import { useTranslation } from 'react-i18next'; @@ -12,12 +12,15 @@ import { useDownloadModalStore } from '../store/downloadModalStore'; import { usePlayerStore } from '../store/playerStore'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useRangeSelection } from '../hooks/useRangeSelection'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { showToast } from '../utils/ui/toast'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import OverlayScrollArea from '../components/OverlayScrollArea'; +import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; /** Per-loadMore budget — tuned for snappy initial paint over completeness. * 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/ @@ -68,6 +71,18 @@ export default function LosslessAlbums() { * reference. */ const inFlight = useRef(false); const observerTarget = useRef(null); + const scrollBodyRef = useRef(null); + const [scrollBodyEl, setScrollBodyEl] = useState(null); + const bindLosslessScrollBody = useCallback((el: HTMLDivElement | null) => { + scrollBodyRef.current = el; + setScrollBodyEl(el); + }, []); + + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + unsupported, + selectionMode, + activeServerId, + ]); const loadMore = useCallback(async () => { if (inFlight.current) return; @@ -140,13 +155,17 @@ export default function LosslessAlbums() { if (!hasMore) return; const node = observerTarget.current; if (!node) return; + const root = scrollBodyRef.current; const obs = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore(); }, - { rootMargin: '200px' }, + { + root: root instanceof HTMLElement ? root : null, + rootMargin: '200px', + }, ); obs.observe(node); return () => obs.disconnect(); - }, [hasMore, loadMore, loading, albums.length]); + }, [hasMore, loadMore, loading, albums.length, scrollBodyEl]); const handleEnqueueSelected = async () => { if (selectedAlbums.length === 0) return; @@ -202,87 +221,107 @@ export default function LosslessAlbums() { }; return ( -
+
{!perfFlags.disableMainstageStickyHeader && ( -
-
-

- {selectionMode && selectedIds.size > 0 - ? t('albums.selectionCount', { count: selectedIds.size }) - : t('home.losslessAlbums')} -

- {!(selectionMode && selectedIds.size > 0) && ( -

- {t('losslessAlbums.slowFetchHint')} -

- )} -
-
- {selectionMode && selectedIds.size > 0 && ( - <> - - - - - )} - +
+
+
+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('home.losslessAlbums')} +

+ {!(selectionMode && selectedIds.size > 0) && ( +

+ {t('losslessAlbums.slowFetchHint')} +

+ )} +
+
+ {selectionMode && selectedIds.size > 0 && ( + <> + + + + + )} + +
)} - {unsupported ? ( -
- {t('losslessAlbums.unsupported')} -
- ) : loading && albums.length === 0 ? ( -
-
-
- ) : albums.length === 0 ? ( -
- {t('losslessAlbums.empty')} -
- ) : ( - <> - a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={albums.length} - renderItem={a => ( - - )} - /> -
- {loading && hasMore &&
} + + {unsupported ? ( +
+ {t('losslessAlbums.unsupported')}
- - )} + ) : loading && albums.length === 0 ? ( +
+
+
+ ) : albums.length === 0 ? ( +
+ {t('losslessAlbums.empty')} +
+ ) : ( + <> + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} + renderItem={a => ( + + )} + /> +
+ {loading && hasMore &&
} +
+ + )} +
); } diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index 7bd00de1..5ce477c3 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -3,22 +3,24 @@ import { getAlbumsByGenre } from '../api/subsonicGenres'; import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { dedupeById } from '../utils/dedupeById'; -import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; -import { usePlayerStore } from '../store/playerStore'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/ui/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useRangeSelection } from '../hooks/useRangeSelection'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; +import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import OverlayScrollArea from '../components/OverlayScrollArea'; +import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; const PAGE_SIZE = 30; @@ -46,15 +48,26 @@ export default function NewReleases() { const [hasMore, setHasMore] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); const observerTarget = useRef(null); + const scrollBodyRef = useRef(null); + const [scrollBodyEl, setScrollBodyEl] = useState(null); + const bindNewReleasesScrollBody = useCallback((el: HTMLDivElement | null) => { + scrollBodyRef.current = el; + setScrollBodyEl(el); + }, []); + const [selectionMode, setSelectionMode] = useState(false); const filtered = selectedGenres.length > 0; - const [selectionMode, setSelectionMode] = useState(false); + const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ + filtered, + selectionMode, + selectedGenres, + ]); + const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums); const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const clearSelection = () => { setSelectionMode(false); resetSelection(); }; const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); - const openContextMenu = usePlayerStore(state => state.openContextMenu); const handleDownloadZips = async () => { if (selectedAlbums.length === 0) return; @@ -130,83 +143,108 @@ export default function NewReleases() { }, [loading, hasMore, page, load, filtered]); useEffect(() => { + const node = observerTarget.current; + if (!node) return; + const root = scrollBodyRef.current; const observer = new IntersectionObserver( - entries => { if (entries[0].isIntersecting) loadMore(); }, - { rootMargin: '200px' } + entries => { if (entries[0]?.isIntersecting) loadMore(); }, + { + root: root instanceof HTMLElement ? root : null, + rootMargin: '200px', + }, ); - if (observerTarget.current) observer.observe(observerTarget.current); + observer.observe(node); return () => observer.disconnect(); - }, [loadMore]); + }, [loadMore, scrollBodyEl]); return ( -
-
-

- {selectionMode && selectedIds.size > 0 - ? t('albums.selectionCount', { count: selectedIds.size }) - : t('sidebar.newReleases')} -

-
- {selectionMode && selectedIds.size > 0 ? ( - <> - - - - ) : ( - - )} - +
+
+
+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('sidebar.newReleases')} +

+
+ {selectionMode && selectedIds.size > 0 ? ( + <> + + + + ) : ( + + )} + +
- {loading && albums.length === 0 ? ( -
-
-
- ) : !loading && albums.length === 0 && !filtered ? ( -
- {t('common.libraryEmpty')} -
- ) : ( - <> - a.id} - rowVariant="album" - disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={albums.length} - renderItem={a => ( - + + {loading && albums.length === 0 ? ( +
+
+
+ ) : !loading && albums.length === 0 && !filtered ? ( +
+ {t('common.libraryEmpty')} +
+ ) : ( + <> + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID} + renderItem={a => ( + + )} + /> + {!filtered && ( +
+ {loading && hasMore &&
} +
)} - /> - {!filtered && ( -
- {loading && hasMore &&
} -
- )} - - )} + + )} +
); } diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index b968731d..2219b416 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -61,6 +61,7 @@ describe('trivial pass-through setters', () => { ['setUseCustomTitlebar', 'useCustomTitlebar', true], ['setPreloadMiniPlayer', 'preloadMiniPlayer', true], ['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false], + ['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'], ['setNowPlayingEnabled', 'nowPlayingEnabled', true], ['setShowFullscreenLyrics', 'showFullscreenLyrics', false], ['setLyricsStaticOnly', 'lyricsStaticOnly', true], diff --git a/src/store/authStore.ts b/src/store/authStore.ts index bf0dbc50..eadac5ca 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -73,6 +73,7 @@ export const useAuthStore = create()( useCustomTitlebar: false, preloadMiniPlayer: false, linuxWebkitKineticScroll: true, + linuxWaylandTextRenderProfile: 'sharp', loggingMode: 'normal', nowPlayingEnabled: false, lyricsServerFirst: true, diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index e85884e5..2ba29aa5 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -90,6 +90,12 @@ export function computeAuthStoreRehydration(state: AuthState): Partial(['balanced', 'sharp', 'gpu', 'minimal']); + const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile; + const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string) + ? {} + : { linuxWaylandTextRenderProfile: 'sharp' as const }; + // The `animationMode` 3-state setting was removed; users on `'reduced'` // or `'static'` collapse onto the former `'full'` path automatically as // soon as the field is gone from the store. Strip the persisted field @@ -142,6 +148,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial void; setPreloadMiniPlayer: (v: boolean) => void; setLinuxWebkitKineticScroll: (v: boolean) => void; + setLinuxWaylandTextRenderProfile: (v: LinuxWaylandTextRenderProfile) => void; setLoggingMode: (v: LoggingMode) => void; setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index 96408a73..0b72c3a5 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -21,6 +21,7 @@ export function createUiAppearanceActions(set: SetState): Pick< | 'setUseCustomTitlebar' | 'setPreloadMiniPlayer' | 'setLinuxWebkitKineticScroll' + | 'setLinuxWaylandTextRenderProfile' | 'setSeekbarStyle' | 'setQueueNowPlayingCollapsed' | 'setQueueDurationDisplayMode' @@ -43,6 +44,7 @@ export function createUiAppearanceActions(set: SetState): Pick< setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }), setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }), setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }), + setLinuxWaylandTextRenderProfile: (v) => set({ linuxWaylandTextRenderProfile: v }), setSeekbarStyle: (v) => set({ seekbarStyle: v }), setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }), setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }), diff --git a/src/styles/components/album-card.css b/src/styles/components/album-card.css index 62d44b70..82096e31 100644 --- a/src/styles/components/album-card.css +++ b/src/styles/components/album-card.css @@ -6,7 +6,8 @@ border-radius: var(--radius-lg); overflow: hidden; box-shadow: inset 0 0 0 1px var(--border-subtle); - transition: transform var(--transition-base), box-shadow var(--transition-base); + /* Instant shadow change — same WebKitGTK jitter as animated box-shadow on .artist-card. */ + transform: translateZ(0); } .album-card:hover { @@ -18,6 +19,9 @@ aspect-ratio: 1; overflow: hidden; background: var(--bg-hover); + contain: paint; + isolation: isolate; + transform: translateZ(0); } .album-card-cover img { @@ -25,12 +29,12 @@ height: 100%; object-fit: cover; transform-origin: center center; - transform: scale(1); + transform: translateZ(0) scale(1); transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1); } .album-card:hover .album-card-cover img { - transform: scale(1.02); + transform: translateZ(0) scale(1.02); } .album-card-cover-placeholder { @@ -42,3 +46,6 @@ color: var(--text-muted); } +.album-card-info { + transform: translateZ(0); +} diff --git a/src/styles/components/artist-card-grid-view.css b/src/styles/components/artist-card-grid-view.css index 7a48d9d7..043c631a 100644 --- a/src/styles/components/artist-card-grid-view.css +++ b/src/styles/components/artist-card-grid-view.css @@ -7,7 +7,8 @@ border: 1px solid var(--border-subtle); cursor: pointer; overflow: hidden; - transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); + /* Instant border/shadow: animated box-shadow jitters on WebKitGTK/Wayland; cover zoom stays smooth below. */ + transform: translateZ(0); } .artist-card:hover { @@ -24,6 +25,9 @@ align-items: center; justify-content: center; color: var(--text-muted); + contain: paint; + isolation: isolate; + transform: translateZ(0); } .artist-card-avatar img { @@ -31,12 +35,12 @@ height: 100%; object-fit: cover; transform-origin: center center; - transform: scale(1); + transform: translateZ(0) scale(1); transition: opacity 0.15s ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1); } .artist-card:hover .artist-card-avatar img { - transform: scale(1.02); + transform: translateZ(0) scale(1.02); } .artist-card-avatar-initial { @@ -64,6 +68,11 @@ display: flex; flex-direction: column; gap: 2px; + transform: translateZ(0); +} + +.artist-card-info--center { + text-align: center; } .artist-card-name { diff --git a/src/styles/components/artists-page.css b/src/styles/components/artists-page.css index 53e2dbe9..b669d524 100644 --- a/src/styles/components/artists-page.css +++ b/src/styles/components/artists-page.css @@ -1,4 +1,5 @@ /* ─ Artists Page ─ */ + .letter-heading { font-size: 13px; font-weight: 700; @@ -23,6 +24,7 @@ padding: var(--space-3); border-radius: var(--radius-md); transition: background var(--transition-fast); + transform: translateZ(0); width: 100%; text-align: left; } @@ -67,4 +69,3 @@ color: var(--text-muted); margin-top: 2px; } - diff --git a/src/styles/layout/main-content.css b/src/styles/layout/main-content.css index fc0fc3a8..09c9079b 100644 --- a/src/styles/layout/main-content.css +++ b/src/styles/layout/main-content.css @@ -104,6 +104,87 @@ contain: paint; } +/* Browse pages: in-page overlay scroller — main route viewport does not scroll. */ +.app-shell-route-scroll__viewport--inpage-split { + overflow: hidden !important; +} + +/* Sticky toolbar + `OverlayScrollArea` body; shared by Artists, Albums, Composers, etc. */ +.content-body.mainstage-inpage-split { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + box-sizing: border-box; + padding-right: 0; + transition: padding-top 0.2s ease; +} + +.mainstage-inpage-split .mainstage-inpage-toolbar { + flex-shrink: 0; +} + +.content-body.mainstage-inpage-split .page-sticky-header { + position: relative; + top: auto; + z-index: auto; + margin-right: 0; + transition: padding 0.2s ease; +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight { + padding-top: 0; +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .page-sticky-header { + padding-top: var(--space-2); + padding-bottom: var(--space-2); +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .page-sticky-header .page-title { + font-size: 1rem; +} + +/* Toolbar rows: use these class names instead of inline gap so tight mode can tighten. */ +.mainstage-inpage-toolbar-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .mainstage-inpage-toolbar-row { + gap: 0.5rem; +} + +.mainstage-inpage-toolbar-alpha-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: var(--space-4); +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .mainstage-inpage-toolbar-alpha-row { + margin-top: var(--space-2); +} + +.content-body.mainstage-inpage-split.mainstage-inpage--header-tight .artists-alpha-btn { + padding: 0.15rem 0.4rem; + font-size: 11px; +} + +.content-body.mainstage-inpage-split > .mainstage-inpage-scroll.overlay-scroll { + flex: 1; + min-height: 0; +} + +.mainstage-inpage-scroll__viewport { + padding-bottom: var(--space-6); + padding-right: var(--space-6); +} + /* Sticky page header: keeps page title + filter/search bar visible while the body scrolls. Negative horizontal margins (+ matching padding) make the background flush with the scroll container edges so the sticky block looks diff --git a/src/styles/layout/sidebar.css b/src/styles/layout/sidebar.css index fec6e23b..eef5e4e6 100644 --- a/src/styles/layout/sidebar.css +++ b/src/styles/layout/sidebar.css @@ -281,7 +281,7 @@ color: var(--text-secondary); font-size: 14px; font-weight: 500; - transition: all var(--transition-fast); + transition: background var(--transition-fast), color var(--transition-fast); cursor: pointer; text-decoration: none; position: relative; @@ -313,6 +313,7 @@ flex-shrink: 0; opacity: 0.7; transition: opacity var(--transition-fast); + transform: translateZ(0); } .nav-link.active svg, diff --git a/src/styles/themes/reset.css b/src/styles/themes/reset.css index fcf214a0..021fba76 100644 --- a/src/styles/themes/reset.css +++ b/src/styles/themes/reset.css @@ -59,3 +59,33 @@ ol { list-style: none; } +/* Linux Wayland + GPU compositing (WebKitGTK): grayscale body AA can look washed + when surfaces are GL-composited; prefer LCD subpixel while compositing stays on. */ +html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing) { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing):is( + [data-wayland-text-profile="balanced"], + [data-wayland-text-profile="gpu"] +) body, +html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing):is( + [data-wayland-text-profile="balanced"], + [data-wayland-text-profile="gpu"] +) #root { + -webkit-font-smoothing: subpixel-antialiased; + -moz-osx-font-smoothing: auto; + text-rendering: geometricPrecision; +} + +/* "Sharp" live preview: greyscale AA reads crisper on some GL-composited stacks (WebKit Never is applied on restart). */ +html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing)[data-wayland-text-profile="sharp"] + body, +html[data-platform="linux"][data-linux-session="wayland"]:not(.no-compositing)[data-wayland-text-profile="sharp"] + #root { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + diff --git a/src/styles/tracks/song-card.css b/src/styles/tracks/song-card.css index 7e691f59..7fa54714 100644 --- a/src/styles/tracks/song-card.css +++ b/src/styles/tracks/song-card.css @@ -9,7 +9,6 @@ border-radius: var(--radius-lg); overflow: hidden; box-shadow: inset 0 0 0 1px var(--border-subtle); - transition: box-shadow var(--transition-base); } .song-card:hover { diff --git a/src/utils/ui/overlayScrollbarMetrics.ts b/src/utils/ui/overlayScrollbarMetrics.ts index a320bec8..f1c40cff 100644 --- a/src/utils/ui/overlayScrollbarMetrics.ts +++ b/src/utils/ui/overlayScrollbarMetrics.ts @@ -9,11 +9,19 @@ export type OverlayScrollbarThumbMeta = { * When shorter than the viewport, thumb size/position must use this or the * thumb’s bottom extends past the visible rail at max scroll. */ +function overflowYAllowsUserScroll(overflowY: string): boolean { + return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; +} + export function computeOverlayScrollbarThumbMeta( el: HTMLElement | null, trackHeight?: number, ): OverlayScrollbarThumbMeta { if (!el) return { thumbH: 0, thumbT: 0, visible: false }; + const { overflowY } = getComputedStyle(el); + if (!overflowYAllowsUserScroll(overflowY)) { + return { thumbH: 0, thumbT: 0, visible: false }; + } const { scrollTop, scrollHeight, clientHeight } = el; if (scrollHeight <= clientHeight + 1) { return { thumbH: 0, thumbT: 0, visible: false };