diff --git a/desktop/README.md b/desktop/README.md index 690e1440..042e6dad 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -10,7 +10,8 @@ On launch the app: 2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar process**, pointed at the OS app-data/music directories, and waits for it to become ready. -3. Loads the Navidrome UI in the webview. +3. Loads the Navidrome UI in the content webview, below a thin toolbar with + back/forward/reload buttons and an address bar. 4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully (falls back to a hard kill after 3s). @@ -44,9 +45,9 @@ To build a standalone `.app` instead: ``` desktop/ -├── src/ # Splash screen shown while Navidrome boots +├── src/ # toolbar.html (nav bar) + index.html (loading splash) ├── src-tauri/ -│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle +│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle, toolbar │ ├── binaries/ # navidrome- sidecar (built, not committed) │ ├── icons/ # App icons │ ├── tauri.conf.json diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5121dc25..a1130ad9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["unstable"] } tauri-plugin-shell = "2" serde_json = "1" thiserror = "1" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index e409d066..0443cb89 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Default capability for the Quintodrome desktop window", "windows": ["main"], - "permissions": ["core:default"] + "permissions": ["core:default", "core:event:default"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b794cb5d..45bfb1ba 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,13 +1,75 @@ mod server; use server::{Navidrome, ServerState}; -use tauri::{Manager, RunEvent}; +use tauri::{ + Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, WebviewBuilder, WebviewUrl, + WindowEvent, +}; + +const TOOLBAR_HEIGHT: f64 = 44.0; + +#[tauri::command] +fn back(app: tauri::AppHandle) { + if let Some(content) = app.get_webview("content") { + let _ = content.eval("history.back()"); + } +} + +#[tauri::command] +fn forward(app: tauri::AppHandle) { + if let Some(content) = app.get_webview("content") { + let _ = content.eval("history.forward()"); + } +} + +#[tauri::command] +fn reload(app: tauri::AppHandle) { + if let Some(content) = app.get_webview("content") { + let _ = content.reload(); + } +} + +#[tauri::command] +fn navigate(app: tauri::AppHandle, url: String) { + if let Some(content) = app.get_webview("content") { + if let Ok(url) = Url::parse(&url) { + let _ = content.navigate(url); + } + } +} + +/// Positions the toolbar strip at the top and the content webview below it. +fn layout(app: &tauri::AppHandle) { + let Some(window) = app.get_window("main") else { + return; + }; + let Ok(size) = window.inner_size() else { + return; + }; + let Ok(scale) = window.scale_factor() else { + return; + }; + let width = size.width as f64 / scale; + let height = size.height as f64 / scale; + + if let Some(toolbar) = app.get_webview("main") { + let _ = toolbar.set_auto_resize(false); + let _ = toolbar.set_position(LogicalPosition::new(0.0, 0.0)); + let _ = toolbar.set_size(LogicalSize::new(width, TOOLBAR_HEIGHT)); + } + if let Some(content) = app.get_webview("content") { + let _ = content.set_auto_resize(false); + let _ = content.set_position(LogicalPosition::new(0.0, TOOLBAR_HEIGHT)); + let _ = content.set_size(LogicalSize::new(width, (height - TOOLBAR_HEIGHT).max(0.0))); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(ServerState::default()) + .invoke_handler(tauri::generate_handler![back, forward, reload, navigate]) .setup(|app| { let handle = app.handle().clone(); @@ -19,17 +81,53 @@ pub fn run() { .unwrap_or(server::DEFAULT_PORT); let navidrome = Navidrome::new(host, port); - let url = navidrome.url.clone(); + let navidrome_url = navidrome.url.clone(); - // Detect/start Navidrome off the main thread, then point the - // window at it once it is ready. + // The primary webview ("main") is the toolbar; the Navidrome content + // lives in a child webview below it. + let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into())) + .on_navigation({ + let handle = handle.clone(); + move |url| { + if url.scheme() == "http" || url.scheme() == "https" { + if let Some(toolbar) = handle.get_webview("main") { + let _ = toolbar.emit("url-changed", url.to_string()); + } + } + true + } + }); + + if let Some(window) = app.get_window("main") { + let _ = window.add_child( + content, + LogicalPosition::new(0.0, TOOLBAR_HEIGHT), + LogicalSize::new(800.0, 600.0), + ); + let handle = handle.clone(); + window.on_window_event(move |event| { + if let WindowEvent::Resized(_) = event { + layout(&handle); + } + }); + } + + layout(app.handle()); + + if let Some(toolbar) = app.get_webview("main") { + let _ = toolbar.emit("url-changed", &navidrome_url); + } + + // Detect/start Navidrome off the main thread, then load it. + let content_url = navidrome_url.clone(); std::thread::spawn(move || { if let Err(err) = server::ensure_running(&handle, &navidrome) { eprintln!("quintodrome: {err}"); - let message = serde_json::to_string(&err.to_string()).unwrap(); - if let Some(window) = handle.get_webview_window("main") { - let _ = window.eval(&format!( - "document.getElementById('status').hidden = true;\ + if let Some(content) = handle.get_webview("content") { + let message = serde_json::to_string(&err.to_string()).unwrap(); + let _ = content.eval(&format!( + "document.getElementById('spinner').hidden = true;\ + document.getElementById('status').hidden = true;\ var e = document.getElementById('error');\ e.hidden = false;\ e.textContent = 'Failed to start Navidrome: ' + {message};" @@ -38,8 +136,10 @@ pub fn run() { return; } - if let Some(window) = handle.get_webview_window("main") { - let _ = window.eval(&format!("window.location.replace('{url}')")); + if let Ok(url) = Url::parse(&content_url) { + if let Some(content) = handle.get_webview("content") { + let _ = content.navigate(url); + } } }); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ad1f46dd..3513537f 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -7,6 +7,7 @@ "frontendDist": "../src" }, "app": { + "withGlobalTauri": true, "windows": [ { "label": "main", @@ -18,7 +19,7 @@ "resizable": true, "fullscreen": false, "center": true, - "url": "index.html" + "url": "toolbar.html" } ], "security": { diff --git a/desktop/src/index.html b/desktop/src/index.html index aad75c81..2d1466b2 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -27,12 +27,6 @@ .container { max-width: 420px; } - h1 { - font-size: 1.6rem; - font-weight: 600; - letter-spacing: 0.02em; - margin-bottom: 24px; - } .spinner { width: 36px; height: 36px; @@ -65,21 +59,9 @@
-

Quintodrome

Starting Navidrome…

- diff --git a/desktop/src/toolbar.html b/desktop/src/toolbar.html new file mode 100644 index 00000000..1b748867 --- /dev/null +++ b/desktop/src/toolbar.html @@ -0,0 +1,91 @@ + + + + + + Quintodrome + + + + + + + + + + +