From 71fbc717f652480744828c22e444831253ee70ed Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 19 Apr 2026 15:31:45 +0200 Subject: [PATCH] fix(mini-player): unhang Windows by pre-creating the mini webview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating the second WebView2 webview lazily from the open_mini_player invoke handler reliably stalled Tauri's event loop on Windows — the mini opened blank white, neither main nor mini could be closed, and the user had to kill the process via Task Manager. The builder-then-minimize-main combo racing with WebView2's first paint seems to be the trigger. Fix: extract a build_mini_player_window helper and call it once in .setup() on Windows so the webview is created (hidden) in the startup context, not from an invoke command. open_mini_player is now a pure show/hide on all platforms. Windows also skips the main.minimize() call since that interacted with the hang; macOS + Linux keep the existing behaviour of minimising main when the mini opens. Other changes bundled in: - Switch Windows from the custom in-page titlebar back to native window decorations (the earlier decorations(false) move on Windows was part of the hang surface — safer to keep the OS chrome there, Linux still uses the custom bar). - Re-emit mini:ready on window focus so every open of the pre-created mini forces a fresh snapshot from main's bridge, even when the mount- time emit raced past the bridge during startup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/lib.rs | 122 +++++++++++++++++++++------------- src/components/MiniPlayer.tsx | 27 +++++--- 2 files changed, 92 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dade6da..6c4a6a08 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2705,41 +2705,13 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option Result<(), String> { - if let Some(win) = app.get_webview_window("mini") { - let visible = win.is_visible().unwrap_or(false); - if visible { - win.hide().map_err(|e| e.to_string())?; - if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); - } - } else { - // Re-applying the saved position after show() — many Linux WMs - // (Mutter, KWin) re-centre hidden windows when they're shown - // again, ignoring any earlier set_position. Mark the move as - // programmatic so the Moved-event handler doesn't echo the - // intermediate centre coords back to disk. - let target = read_mini_pos(&app); - mark_mini_pos_programmatic(); - win.show().map_err(|e| e.to_string())?; - let _ = win.set_focus(); - if let Some(p) = target { - let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y)); - } - if let Some(main) = app.get_webview_window("main") { - let _ = main.minimize(); - } - } - return Ok(()); - } - +/// Build the mini player webview window. Caller decides `visible` so the +/// same code path serves both pre-creation (Windows, hidden at app start) +/// and lazy creation (other platforms, shown on demand). +fn build_mini_player_window( + app: &tauri::AppHandle, + visible: bool, +) -> Result { let use_always_on_top = { #[cfg(target_os = "linux")] { !is_tiling_wm() } @@ -2750,9 +2722,9 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { // Resolve target position BEFORE building so the WM places the window // correctly from creation. Calling `set_position` after `build()` is // unreliable on several Linux WMs which re-centre hidden windows. - let target_physical = read_mini_pos(&app) + let target_physical = read_mini_pos(app) .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) - .or_else(|| default_mini_position(&app)); + .or_else(|| default_mini_position(app)); let scale = app .primary_monitor() .ok() @@ -2760,13 +2732,13 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { .map(|m| m.scale_factor()) .unwrap_or(1.0); - // macOS keeps the native titlebar (traffic lights + system look). - // Windows and Linux use a custom in-page titlebar so the mini fits a - // tighter visual style across all WMs (incl. tiling). - let use_decorations = cfg!(target_os = "macos"); + // macOS + Windows keep the native titlebar (traffic lights / caption + // buttons + system look). Linux uses a custom in-page titlebar so the + // mini fits a tighter visual style across all WMs (incl. tiling). + let use_decorations = !cfg!(target_os = "linux"); let mut builder = tauri::WebviewWindowBuilder::new( - &app, + app, "mini", tauri::WebviewUrl::App("index.html".into()), ) @@ -2776,7 +2748,8 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { .resizable(true) .decorations(use_decorations) .always_on_top(use_always_on_top) - .skip_taskbar(false); + .skip_taskbar(false) + .visible(visible); if let Some(pos) = target_physical { builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale); @@ -2786,13 +2759,51 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { // fire stray Moved events with default coords during the first paint. mark_mini_pos_programmatic(); - let win = builder + builder .build() - .map_err(|e| format!("failed to build mini player window: {e}"))?; + .map_err(|e| format!("failed to build mini player window: {e}")) +} - let _ = win.set_focus(); - if let Some(main) = app.get_webview_window("main") { - let _ = main.minimize(); +/// Open (or toggle) the mini player window. On platforms where the window +/// was pre-created at startup (Windows), this is a pure show/hide. On +/// other platforms the window is created lazily on first call. +/// Opening the mini player minimizes the main window; hiding the mini +/// player restores the main window. Both steps are skipped on Windows +/// because creating + immediately minimizing main stalls WebView2's paint +/// pipeline and locks up the Tauri event loop. +#[tauri::command] +fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { + let win = match app.get_webview_window("mini") { + Some(w) => w, + None => build_mini_player_window(&app, false)?, + }; + + let visible = win.is_visible().unwrap_or(false); + if visible { + win.hide().map_err(|e| e.to_string())?; + #[cfg(not(target_os = "windows"))] + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } + } else { + // Re-applying the saved position after show() — many Linux WMs + // (Mutter, KWin) re-centre hidden windows when they're shown + // again, ignoring any earlier set_position. Mark the move as + // programmatic so the Moved-event handler doesn't echo the + // intermediate centre coords back to disk. + let target = read_mini_pos(&app); + mark_mini_pos_programmatic(); + win.show().map_err(|e| e.to_string())?; + let _ = win.set_focus(); + if let Some(p) = target { + let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y)); + } + #[cfg(not(target_os = "windows"))] + if let Some(main) = app.get_webview_window("main") { + let _ = main.minimize(); + } } Ok(()) } @@ -3049,6 +3060,21 @@ pub fn run() { audio::start_device_watcher(&engine, app.handle().clone()); } + // ── Pre-create mini player window (Windows) ────────────────── + // Creating the second WebView2 webview lazily from an invoke + // handler on Windows reliably stalls the Tauri event loop — + // the mini shows a blank white window, neither main nor mini + // can be closed, and the user has to kill the process via + // Task Manager. Building it at startup (hidden) avoids the + // runtime-creation code path entirely; later `open_mini_player` + // calls are pure show/hide. + #[cfg(target_os = "windows")] + { + if let Err(e) = build_mini_player_window(app.handle(), false) { + eprintln!("[psysonic] Failed to pre-create mini window: {e}"); + } + } + // Cold start with `--player …`: defer emit so the webview can register listeners. crate::cli::spawn_deferred_cli_argv_handler(app.handle()); diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 39050b28..c5ff8b16 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -8,7 +8,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useDragDrop } from '../contexts/DragDropContext'; -import { IS_MACOS } from '../utils/platform'; +import { IS_LINUX } from '../utils/platform'; import MiniContextMenu from './MiniContextMenu'; import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; @@ -145,8 +145,16 @@ export default function MiniPlayer() { }, []); // Announce to main window that we're mounted; it replies with a snapshot. + // Also re-announce on window focus: on Windows the mini is pre-created at + // app startup so the mount-time emit can race past main's bridge before + // it has attached its listener. Re-emitting on focus means every actual + // open of the mini (user clicks the player-bar icon) triggers a fresh + // sync regardless of startup ordering. useEffect(() => { emit('mini:ready', {}).catch(() => {}); + const onFocus = () => { emit('mini:ready', {}).catch(() => {}); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); }, []); // Restore the expanded window size on initial mount when the queue was @@ -327,16 +335,17 @@ export default function MiniPlayer() { return (
- {!IS_MACOS ? ( + {IS_LINUX ? ( {track?.title ?? 'Psysonic Mini'} ) : ( - // macOS already shows the track title in the native titlebar; we - // just need a flexible spacer so the action buttons sit right. + // macOS/Windows already render a native titlebar with the window + // title + close button; we just need a flexible spacer so the + // action buttons sit right. )} - {/* macOS already provides Close via the red traffic light — skip - the duplicate so the in-app titlebar stays minimal. */} - {!IS_MACOS && ( + {/* macOS + Windows already provide Close via the native titlebar — + skip the duplicate so the in-app titlebar stays minimal. */} + {IS_LINUX && (