Add navigation bar

This commit is contained in:
Philip Peterson 2026-08-25 02:50:10 -08:00
parent 6fc857720f
commit 4a91f98ba3
7 changed files with 209 additions and 34 deletions

View file

@ -10,7 +10,8 @@ On launch the app:
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar 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 process**, pointed at the OS app-data/music directories, and waits for it to
become ready. 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 4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
(falls back to a hard kill after 3s). (falls back to a hard kill after 3s).
@ -44,9 +45,9 @@ To build a standalone `.app` instead:
``` ```
desktop/ desktop/
├── src/ # Splash screen shown while Navidrome boots ├── src/ # toolbar.html (nav bar) + index.html (loading splash)
├── src-tauri/ ├── src-tauri/
│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle │ ├── src/ # Rust: sidecar spawn, health-check, lifecycle, toolbar
│ ├── binaries/ # navidrome-<target-triple> sidecar (built, not committed) │ ├── binaries/ # navidrome-<target-triple> sidecar (built, not committed)
│ ├── icons/ # App icons │ ├── icons/ # App icons
│ ├── tauri.conf.json │ ├── tauri.conf.json

View file

@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["unstable"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
serde_json = "1" serde_json = "1"
thiserror = "1" thiserror = "1"

View file

@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Default capability for the Quintodrome desktop window", "description": "Default capability for the Quintodrome desktop window",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "core:event:default"]
} }

View file

@ -1,13 +1,75 @@
mod server; mod server;
use server::{Navidrome, ServerState}; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(ServerState::default()) .manage(ServerState::default())
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
@ -19,17 +81,53 @@ pub fn run() {
.unwrap_or(server::DEFAULT_PORT); .unwrap_or(server::DEFAULT_PORT);
let navidrome = Navidrome::new(host, 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 // The primary webview ("main") is the toolbar; the Navidrome content
// window at it once it is ready. // 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 || { std::thread::spawn(move || {
if let Err(err) = server::ensure_running(&handle, &navidrome) { if let Err(err) = server::ensure_running(&handle, &navidrome) {
eprintln!("quintodrome: {err}"); eprintln!("quintodrome: {err}");
if let Some(content) = handle.get_webview("content") {
let message = serde_json::to_string(&err.to_string()).unwrap(); let message = serde_json::to_string(&err.to_string()).unwrap();
if let Some(window) = handle.get_webview_window("main") { let _ = content.eval(&format!(
let _ = window.eval(&format!( "document.getElementById('spinner').hidden = true;\
"document.getElementById('status').hidden = true;\ document.getElementById('status').hidden = true;\
var e = document.getElementById('error');\ var e = document.getElementById('error');\
e.hidden = false;\ e.hidden = false;\
e.textContent = 'Failed to start Navidrome: ' + {message};" e.textContent = 'Failed to start Navidrome: ' + {message};"
@ -38,8 +136,10 @@ pub fn run() {
return; return;
} }
if let Some(window) = handle.get_webview_window("main") { if let Ok(url) = Url::parse(&content_url) {
let _ = window.eval(&format!("window.location.replace('{url}')")); if let Some(content) = handle.get_webview("content") {
let _ = content.navigate(url);
}
} }
}); });

View file

@ -7,6 +7,7 @@
"frontendDist": "../src" "frontendDist": "../src"
}, },
"app": { "app": {
"withGlobalTauri": true,
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
@ -18,7 +19,7 @@
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"center": true, "center": true,
"url": "index.html" "url": "toolbar.html"
} }
], ],
"security": { "security": {

View file

@ -27,12 +27,6 @@
.container { .container {
max-width: 420px; max-width: 420px;
} }
h1 {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: 0.02em;
margin-bottom: 24px;
}
.spinner { .spinner {
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -65,21 +59,9 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>Quintodrome</h1>
<div class="spinner" id="spinner"></div> <div class="spinner" id="spinner"></div>
<p id="status">Starting Navidrome…</p> <p id="status">Starting Navidrome…</p>
<p id="error" hidden></p> <p id="error" hidden></p>
</div> </div>
<script>
const params = new URLSearchParams(location.search);
const error = params.get("error");
if (error) {
document.getElementById("spinner").hidden = true;
document.getElementById("status").hidden = true;
const el = document.getElementById("error");
el.hidden = false;
el.textContent = "Failed to start Navidrome: " + error;
}
</script>
</body> </body>
</html> </html>

91
desktop/src/toolbar.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quintodrome</title>
<style>
:root {
color-scheme: dark;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
background: #232329;
border-bottom: 1px solid #34343c;
font-family: system-ui, -apple-system, sans-serif;
-webkit-user-select: none;
user-select: none;
overflow: hidden;
}
button {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: #d4d4d8;
font-size: 18px;
line-height: 1;
cursor: pointer;
flex: none;
}
button:hover {
background: #34343c;
}
button:active {
background: #3f3f46;
}
#url {
flex: 1;
height: 32px;
padding: 0 12px;
border: 1px solid #34343c;
border-radius: 16px;
background: #1b1b1f;
color: #e4e4e7;
font-size: 13px;
outline: none;
min-width: 0;
}
#url:focus {
border-color: #a855f7;
}
</style>
</head>
<body>
<button id="back" title="Back"></button>
<button id="forward" title="Forward"></button>
<button id="reload" title="Reload"></button>
<input id="url" type="text" spellcheck="false" placeholder="http://127.0.0.1:4533" />
<script>
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
const urlInput = document.getElementById("url");
document.getElementById("back").addEventListener("click", () => invoke("back"));
document.getElementById("forward").addEventListener("click", () => invoke("forward"));
document.getElementById("reload").addEventListener("click", () => invoke("reload"));
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
invoke("navigate", { url: urlInput.value.trim() });
urlInput.blur();
}
});
listen("url-changed", (event) => {
urlInput.value = event.payload;
});
</script>
</body>
</html>