Add navigation bar
This commit is contained in:
parent
6fc857720f
commit
4a91f98ba3
7 changed files with 209 additions and 34 deletions
|
|
@ -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-<target-triple> sidecar (built, not committed)
|
||||
│ ├── icons/ # App icons
|
||||
│ ├── tauri.conf.json
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
if let Some(content) = handle.get_webview("content") {
|
||||
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;\
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Quintodrome</h1>
|
||||
<div class="spinner" id="spinner"></div>
|
||||
<p id="status">Starting Navidrome…</p>
|
||||
<p id="error" hidden></p>
|
||||
</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>
|
||||
</html>
|
||||
|
|
|
|||
91
desktop/src/toolbar.html
Normal file
91
desktop/src/toolbar.html
Normal 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>
|
||||
Loading…
Reference in a new issue