fix(mini-player): unhang Windows by pre-creating the mini webview

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) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-19 15:31:45 +02:00
parent d33c7042b6
commit 71fbc717f6
2 changed files with 92 additions and 57 deletions
+88 -62
View File
@@ -2705,16 +2705,83 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPositi
)) ))
} }
/// Open (or toggle) the mini player window. Creates it on first call; on /// Build the mini player webview window. Caller decides `visible` so the
/// subsequent calls, hides it if visible, shows + focuses it if hidden. /// same code path serves both pre-creation (Windows, hidden at app start)
/// Opening the mini player minimizes the main window; hiding the mini player /// and lazy creation (other platforms, shown on demand).
/// restores the main window. fn build_mini_player_window(
app: &tauri::AppHandle,
visible: bool,
) -> Result<tauri::WebviewWindow, String> {
let use_always_on_top = {
#[cfg(target_os = "linux")]
{ !is_tiling_wm() }
#[cfg(not(target_os = "linux"))]
{ true }
};
// 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)
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
.or_else(|| default_mini_position(app));
let scale = app
.primary_monitor()
.ok()
.flatten()
.map(|m| m.scale_factor())
.unwrap_or(1.0);
// 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,
"mini",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 180.0)
.min_inner_size(320.0, 180.0)
.resizable(true)
.decorations(use_decorations)
.always_on_top(use_always_on_top)
.skip_taskbar(false)
.visible(visible);
if let Some(pos) = target_physical {
builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale);
}
// Suppress Moved-event echo for the initial show — Linux WMs sometimes
// fire stray Moved events with default coords during the first paint.
mark_mini_pos_programmatic();
builder
.build()
.map_err(|e| format!("failed to build mini player window: {e}"))
}
/// 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] #[tauri::command]
fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") { 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); let visible = win.is_visible().unwrap_or(false);
if visible { if visible {
win.hide().map_err(|e| e.to_string())?; win.hide().map_err(|e| e.to_string())?;
#[cfg(not(target_os = "windows"))]
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize(); let _ = main.unminimize();
let _ = main.show(); let _ = main.show();
@@ -2733,67 +2800,11 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(p) = target { if let Some(p) = target {
let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y)); 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") { if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize(); let _ = main.minimize();
} }
} }
return Ok(());
}
let use_always_on_top = {
#[cfg(target_os = "linux")]
{ !is_tiling_wm() }
#[cfg(not(target_os = "linux"))]
{ true }
};
// 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)
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
.or_else(|| default_mini_position(&app));
let scale = app
.primary_monitor()
.ok()
.flatten()
.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");
let mut builder = tauri::WebviewWindowBuilder::new(
&app,
"mini",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 180.0)
.min_inner_size(320.0, 180.0)
.resizable(true)
.decorations(use_decorations)
.always_on_top(use_always_on_top)
.skip_taskbar(false);
if let Some(pos) = target_physical {
builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale);
}
// Suppress Moved-event echo for the initial show — Linux WMs sometimes
// fire stray Moved events with default coords during the first paint.
mark_mini_pos_programmatic();
let win = builder
.build()
.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();
}
Ok(()) Ok(())
} }
@@ -3049,6 +3060,21 @@ pub fn run() {
audio::start_device_watcher(&engine, app.handle().clone()); 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. // Cold start with `--player …`: defer emit so the webview can register listeners.
crate::cli::spawn_deferred_cli_argv_handler(app.handle()); crate::cli::spawn_deferred_cli_argv_handler(app.handle());
+18 -9
View File
@@ -8,7 +8,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { IS_MACOS } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu'; import MiniContextMenu from './MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; 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. // 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(() => { useEffect(() => {
emit('mini:ready', {}).catch(() => {}); 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 // Restore the expanded window size on initial mount when the queue was
@@ -327,16 +335,17 @@ export default function MiniPlayer() {
return ( return (
<div className="mini-player-shell"> <div className="mini-player-shell">
<div <div
className={`mini-player__titlebar${IS_MACOS ? ' mini-player__titlebar--mac' : ''}`} className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
{...(IS_MACOS ? {} : { 'data-tauri-drag-region': true })} {...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
> >
{!IS_MACOS ? ( {IS_LINUX ? (
<span className="mini-player__titlebar-title" data-tauri-drag-region> <span className="mini-player__titlebar-title" data-tauri-drag-region>
{track?.title ?? 'Psysonic Mini'} {track?.title ?? 'Psysonic Mini'}
</span> </span>
) : ( ) : (
// macOS already shows the track title in the native titlebar; we // macOS/Windows already render a native titlebar with the window
// just need a flexible spacer so the action buttons sit right. // title + close button; we just need a flexible spacer so the
// action buttons sit right.
<span className="mini-player__titlebar-spacer" /> <span className="mini-player__titlebar-spacer" />
)} )}
<button <button
@@ -369,9 +378,9 @@ export default function MiniPlayer() {
> >
<Maximize2 size={13} /> <Maximize2 size={13} />
</button> </button>
{/* macOS already provides Close via the red traffic light skip {/* macOS + Windows already provide Close via the native titlebar
the duplicate so the in-app titlebar stays minimal. */} skip the duplicate so the in-app titlebar stays minimal. */}
{!IS_MACOS && ( {IS_LINUX && (
<button <button
type="button" type="button"
className="mini-player__titlebar-btn mini-player__titlebar-btn--close" className="mini-player__titlebar-btn mini-player__titlebar-btn--close"