refactor(tauri): decompose lib command surface into domain modules

Split the large lib command module into nested app_api, cache, sync, and ui submodules while preserving command signatures and behavior. This keeps command responsibilities isolated and makes further extraction safer.
This commit is contained in:
Maxim Isaev
2026-05-02 04:37:54 +03:00
committed by cucadmuh
parent c917ee1071
commit cfeec22c82
19 changed files with 4037 additions and 3982 deletions
+413
View File
@@ -0,0 +1,413 @@
use super::*;
use super::*;
// ── Mini Player window ──────────────────────────────────────────────────────
// Secondary always-on-top window with minimal playback controls. Uses the
// same frontend bundle as the main window; disambiguated by window label
// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so
// we fall back to a regular window there.
/// Persisted geometry for the mini player. Stored in
/// `<app_config_dir>/mini_player_pos.json` and rewritten (throttled) on
/// every `WindowEvent::Moved` so the window reopens where the user last
/// left it. Coordinates are physical pixels — that's what `set_position`
/// and the move event both report, so we don't need to round-trip through
/// scale factors that may differ across monitors.
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)]
pub(crate) struct MiniPlayerPosition {
x: i32,
y: i32,
}
pub(crate) fn mini_pos_file(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json"))
}
pub(crate) fn read_mini_pos(app: &tauri::AppHandle) -> Option<MiniPlayerPosition> {
let path = mini_pos_file(app)?;
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
pub(crate) fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) {
let Some(path) = mini_pos_file(app) else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(&pos) {
let _ = std::fs::write(path, json);
}
}
/// Tracks when we last set the mini player position programmatically.
/// `WindowEvent::Moved` fires for both user drags AND our own `show()` /
/// `set_position` calls — without this guard the WM's "centre on show"
/// behaviour would silently overwrite the user's saved position.
pub(crate) fn last_programmatic_pos_set() -> &'static Mutex<std::time::Instant> {
static LAST: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
LAST.get_or_init(|| Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)))
}
pub(crate) fn mark_mini_pos_programmatic() {
*last_programmatic_pos_set().lock().unwrap() = std::time::Instant::now();
}
pub(crate) fn is_mini_pos_programmatic() -> bool {
last_programmatic_pos_set().lock().unwrap().elapsed()
< std::time::Duration::from_millis(1000)
}
/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on
/// every pointer step. 250 ms keeps the file fresh enough that any close
/// or release lands a recent position, without hammering the disk.
/// Programmatic moves (during `show()` / `set_position`) are skipped so
/// WM re-centring on re-show doesn't clobber the saved position.
pub(crate) fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) {
if is_mini_pos_programmatic() {
return;
}
static LAST_WRITE: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
let mu = LAST_WRITE.get_or_init(|| {
Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10))
});
{
let mut last = mu.lock().unwrap();
if last.elapsed() < std::time::Duration::from_millis(250) {
return;
}
*last = std::time::Instant::now();
}
write_mini_pos(app, MiniPlayerPosition { x, y });
}
/// Returns true when the saved top-left lands inside an available monitor
/// with enough room (≥ 80 px in each axis) to leave a draggable corner of
/// the window on-screen. Used to drop persisted positions that point at a
/// monitor that is no longer enumerated (unplugged, hot-plug detection
/// race during early boot, resolution change, monitor reorder).
pub(crate) fn mini_position_visible(app: &tauri::AppHandle, x: i32, y: i32) -> bool {
const MIN_VISIBLE: i32 = 80;
let monitors = match app.available_monitors() {
Ok(m) if !m.is_empty() => m,
_ => return false,
};
monitors.iter().any(|m| {
let mp = m.position();
let ms = m.size();
x >= mp.x
&& y >= mp.y
&& x + MIN_VISIBLE <= mp.x + ms.width as i32
&& y + MIN_VISIBLE <= mp.y + ms.height as i32
})
}
/// Default position when nothing is persisted: bottom-right of the monitor
/// the main window sits on (falls back to primary). A 24 px logical margin
/// keeps it off the screen edge; +56 px on the bottom margin avoids most
/// taskbars/docks since Tauri does not expose work-area rects.
pub(crate) fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPosition<i32>> {
let monitor = app
.get_webview_window("main")
.and_then(|w| w.current_monitor().ok().flatten())
.or_else(|| app.primary_monitor().ok().flatten())?;
let scale = monitor.scale_factor();
let m_pos = monitor.position();
let m_size = monitor.size();
let win_w = (340.0 * scale).round() as i32;
let win_h = (260.0 * scale).round() as i32;
let margin_x = (24.0 * scale).round() as i32;
let margin_y = (56.0 * scale).round() as i32;
Some(tauri::PhysicalPosition::new(
m_pos.x + (m_size.width as i32) - win_w - margin_x,
m_pos.y + (m_size.height as i32) - win_h - margin_y,
))
}
/// JS snippet to inject into a hidden webview to reduce compositor work while
/// the host window is invisible.
///
/// WebView2 on Windows can keep a GPU-backed compositor active even when the
/// native window is hidden. This script does **not** stop arbitrary JS timers
/// or every `requestAnimationFrame` loop — it sets a flag the app reads, zeros
/// `--psy-anim-speed` (for CSS that opts into it), and pauses **@keyframes**
/// animations via `animation-play-state` (not CSS transitions).
///
/// Also sets `data-psy-native-hidden` on `<html>` so global CSS can pause every
/// animation including `::before`/`::after` and portal content under `<body>`
/// when `document.hidden` stays false on some WebView2 builds after `win.hide()`.
pub(crate) const PAUSE_RENDERING_JS: &str = r#"
window.__psyHidden = true;
document.documentElement.setAttribute('data-psy-native-hidden', 'true');
document.documentElement.style.setProperty('--psy-anim-speed', '0');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = 'paused';
});
})();
"#;
/// JS snippet to resume rendering when the window becomes visible again.
pub(crate) const RESUME_RENDERING_JS: &str = r#"
window.__psyHidden = false;
document.documentElement.removeAttribute('data-psy-native-hidden');
document.documentElement.style.removeProperty('--psy-anim-speed');
(function () {
const root = document.getElementById('root');
if (!root) return;
root.querySelectorAll('*').forEach(function (el) {
el.style.animationPlayState = '';
});
})();
"#;
/// 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).
pub(crate) 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 }
};
// Tiling WMs manage window sizes themselves — enforcing a max width
// there fights the compositor. Everywhere else we cap the width so
// a horizontal drag can't stretch the layout across a whole monitor.
let cap_width = {
#[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.
// Drop the persisted position if it would land on a monitor that is
// no longer enumerated (unplugged second monitor, hot-plug race during
// early boot, resolution change) — fall back to the default placement.
let target_physical = read_mini_pos(app)
.filter(|p| mini_position_visible(app, p.x, p.y))
.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, 260.0)
.min_inner_size(320.0, 240.0)
.resizable(true)
.decorations(use_decorations)
.always_on_top(use_always_on_top)
.skip_taskbar(false)
.visible(visible);
// Cap width so horizontal drag can't stretch the layout across a whole
// monitor. Height is intentionally left effectively unlimited so users
// can grow the queue list as tall as they want. Skipped on tiling WMs
// since those manage window sizing themselves.
if cap_width {
builder = builder.max_inner_size(400.0, 4096.0);
}
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}"))?;
// 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.
if !visible {
let _ = win.eval(PAUSE_RENDERING_JS);
}
Ok(win)
}
/// Pre-build the mini player window hidden, so the first `open_mini_player`
/// call becomes a pure show/hide and the user sees content instantly. On
/// Windows this already happens unconditionally in `.setup()` as a hang
/// workaround; this command is used by Linux/macOS when the user opts into
/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists.
#[tauri::command]
pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if app.get_webview_window("mini").is_some() {
return Ok(());
}
build_mini_player_window(&app, false).map(|_| ())
}
/// 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.
#[tauri::command]
pub(crate) 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 {
// Pause before hide so `__psyHidden` is set while the webview is still
// guaranteed schedulable (mirrors tray / main close ordering).
let _ = win.eval(PAUSE_RENDERING_JS);
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 {
// Resume rendering before showing — the window needs to be ready
// to paint as soon as it becomes visible.
let _ = win.eval(RESUME_RENDERING_JS);
// 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.
// Drop the persisted position if its monitor is gone and fall
// back to the default placement so we don't open off-screen.
let target = read_mini_pos(&app)
.filter(|p| mini_position_visible(&app, p.x, p.y))
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
.or_else(|| default_mini_position(&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(p);
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize();
}
}
Ok(())
}
/// Hide the mini player window if it exists and restore the main window.
/// Does not destroy the mini window so its state is preserved for next open.
#[tauri::command]
pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
let _ = win.eval(PAUSE_RENDERING_JS);
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();
}
Ok(())
}
/// Unminimize + show + focus the main window. Called from the mini player's
/// "expand" button. Can't rely on a JS event bridge here because the main
/// window's JS is paused while minimized on WebKitGTK. Also hides the mini
/// window so the two don't sit on screen at the same time.
#[tauri::command]
pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.eval(PAUSE_RENDERING_JS);
let _ = mini.hide();
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.eval(RESUME_RENDERING_JS);
main.unminimize().map_err(|e| e.to_string())?;
main.show().map_err(|e| e.to_string())?;
main.set_focus().map_err(|e| e.to_string())?;
}
Ok(())
}
/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`).
#[tauri::command]
pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string())
}
/// Resume rendering work in the current webview. Called when the window
/// becomes visible again.
#[tauri::command]
pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())
}
/// Toggle always-on-top on the mini player window.
///
/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland)
/// silently ignore `set_always_on_top(true)` when the internal flag is
/// already `true` — which happens whenever the window was hidden and
/// re-shown, or focus was lost and the WM dropped the constraint. We
/// always force a `false → true` cycle so the WM re-evaluates the layer.
#[tauri::command]
pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
if on_top {
let _ = win.set_always_on_top(false);
}
win.set_always_on_top(on_top).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Resize the mini player window (logical pixels). Used when toggling the
/// queue panel to expand/collapse without a capability dance. Optional
/// `minWidth` / `minHeight` adjust the window's resize floor so the user
/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when
/// the queue panel is open).
#[tauri::command]
pub(crate) fn resize_mini_player(
app: tauri::AppHandle,
width: f64,
height: f64,
min_width: Option<f64>,
min_height: Option<f64>,
) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
// Lower the floor first; otherwise set_size to a value below the
// existing min would silently clamp.
if let (Some(mw), Some(mh)) = (min_width, min_height) {
win.set_min_size(Some(tauri::LogicalSize::new(mw, mh)))
.map_err(|e| e.to_string())?;
}
win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
}
Ok(())
}