mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
cfeec22c82
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.
414 lines
17 KiB
Rust
414 lines
17 KiB
Rust
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(())
|
|
}
|
|
|