From d715c81e2fd05340b071770fd39e1604b6d17d1b Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 27 Jun 2026 20:14:38 +0100 Subject: [PATCH] feat: mobile UI polish + configurable port Follow-ups to the mobile UI (#231): - Mixer waveform now fills yellow as playback progresses (the played bars, not just the playhead), and repaints on seek. - Library/Mixer/mini-player show the real YouTube/SoundCloud thumbnail when available (layered over the gradient as a fallback), not just a letter. - Configurable port (Settings -> Advanced): default 8080, persisted, read by the desktop launcher before spawning the backend (falls back to a free port if taken). A stable port means a stable phone URL. Applies on restart. - Settings General tab: number fields are digit-only text inputs (no spinner arrows), length-capped; max track length capped at 20 min with the limit noted in the description; controls aligned. Added a Done button. --- app/core/settings.py | 20 ++++++++++++++++ app/main.py | 4 ++++ desktop/src-tauri/src/main.rs | 31 +++++++++++++++++++++++- static/css/daw.css | 13 ++++++---- static/js/catalog.js | 36 +++++++++++++++++++++++----- static/js/shared/jobs.js | 1 + static/mobile/app.js | 45 +++++++++++++++++++++++++++++++---- tests/test_network_gate.py | 8 +++++++ 8 files changed, 141 insertions(+), 17 deletions(-) diff --git a/app/core/settings.py b/app/core/settings.py index 68e7fae9..a73cf84a 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -29,6 +29,8 @@ # Clamp bounds. Max track length is capped at 20 min (the product ceiling). _DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min _HEIGHT_MIN, _HEIGHT_MAX = 144, 2160 +_PORT_MIN, _PORT_MAX = 1024, 65535 +DEFAULT_PORT = 8080 def _default_allow_network() -> bool: @@ -116,3 +118,21 @@ def set_video_max_height(value: int) -> int: _ensure()["video_max_height"] = clamped _save() return clamped + + +# ── port ── +# The preferred port the server binds on launch. The desktop launcher reads this +# (default 8080) before spawning the backend; a self-hosted server's --port wins. +# Changing it needs a restart — the socket is bound at startup. +def get_port() -> int: + with _LOCK: + v = _num(_ensure().get("port")) + return max(_PORT_MIN, min(_PORT_MAX, v)) if v is not None else DEFAULT_PORT + + +def set_port(value: int) -> int: + with _LOCK: + clamped = max(_PORT_MIN, min(_PORT_MAX, int(value))) + _ensure()["port"] = clamped + _save() + return clamped diff --git a/app/main.py b/app/main.py index 4558f918..1b821490 100644 --- a/app/main.py +++ b/app/main.py @@ -31,9 +31,11 @@ from app.core.settings import ( get_allow_network, get_max_duration_sec, + get_port, get_video_max_height, set_allow_network, set_max_duration_sec, + set_port, set_video_max_height, ) from app.pipeline.collect import sweep_old_jobs @@ -223,6 +225,7 @@ def _settings_payload() -> dict[str, object]: "allow_network": get_allow_network(), "max_duration_sec": get_max_duration_sec(), "video_max_height": get_video_max_height(), + "port": get_port(), } @@ -249,6 +252,7 @@ async def update_settings(request: Request) -> dict[str, object]: for key, setter in ( ("max_duration_sec", set_max_duration_sec), ("video_max_height", set_video_max_height), + ("port", set_port), ): if key in body: try: diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 93f866ee..d79f2c03 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -555,7 +555,7 @@ fn start_backend( "Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string() })?; patch_pyvenv_cfg(&python); - let (port, port_guard) = free_port()?; + let (port, port_guard) = reserve_port(configured_port())?; let url = format!("http://127.0.0.1:{port}"); let log_path = data_dir.join("logs").join("backend.log"); let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| { @@ -1799,6 +1799,35 @@ fn free_port() -> Result<(u16, TcpListener), String> { Ok((port, listener)) } +/// The user's preferred port (Settings -> port), read from the backend's +/// settings.json before launch. Defaults to 8080. +fn configured_port() -> u16 { + const DEFAULT_PORT: u16 = 8080; + let Ok(data_dir) = local_data_dir() else { + return DEFAULT_PORT; + }; + let Ok(text) = fs::read_to_string(data_dir.join("settings.json")) else { + return DEFAULT_PORT; + }; + let Ok(json) = serde_json::from_str::(&text) else { + return DEFAULT_PORT; + }; + match json.get("port").and_then(serde_json::Value::as_u64) { + Some(p) if (1024..=65535).contains(&p) => p as u16, + _ => DEFAULT_PORT, + } +} + +/// Reserve the user's preferred port; fall back to any free port if it's taken, +/// so a port conflict can never block startup. +fn reserve_port(desired: u16) -> Result<(u16, TcpListener), String> { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", desired)) { + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + return Ok((port, listener)); + } + free_port() +} + fn wait_for_health(port: u16, timeout: Duration, log_path: &Path) -> Result<(), String> { let deadline = Instant::now() + timeout; let mut interval = Duration::from_millis(250); diff --git a/static/css/daw.css b/static/css/daw.css index da6bec24..7301191b 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -782,11 +782,14 @@ input, textarea { font-family: inherit; } .settings-pane.hidden { display: none; } .settings-pane[data-pane="advanced"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; } .settings-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 28px 10px; } -.settings-num { display: flex; align-items: center; gap: 7px; flex-shrink: 0; } -.settings-num input { width: 62px; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 8px; text-align: right; } -.settings-num-unit { color: var(--muted); font-size: 11px; } -.settings-select { flex-shrink: 0; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 9px; cursor: pointer; } -.settings-num input:focus, .settings-select:focus { outline: none; border-color: rgba(244,183,64,0.5); } +.settings-foot { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); } +.settings-done { min-height: 32px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.35); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 0 22px; cursor: pointer; } +.settings-done:hover { background: rgba(244,183,64,0.24); } +/* Right-aligned form controls share a fixed width so they line up down the column. */ +.settings-num-input, .settings-select { flex-shrink: 0; width: 84px; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 9px; } +.settings-num-input { text-align: right; } +.settings-select { cursor: pointer; } +.settings-num-input:focus, .settings-select:focus { outline: none; border-color: rgba(244,183,64,0.5); } /* Settings → network access section */ .settings-section { margin-bottom: 12px; } diff --git a/static/js/catalog.js b/static/js/catalog.js index 1b4f66e5..485cca90 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1848,13 +1848,23 @@ function networkSettingsHtml() { async function wireGeneralSettings(overlay) { const durInput = overlay.querySelector(".set-max-duration"); const heightSel = overlay.querySelector(".set-video-height"); - if (!durInput && !heightSel) return; + const portInput = overlay.querySelector(".set-port"); + if (!durInput && !heightSel && !portInput) return; const apply = (d) => { if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60)); if (heightSel && d.video_max_height) heightSel.value = String(d.video_max_height); + if (portInput && d.port) portInput.value = String(d.port); }; + // Keep the text inputs digit-only as the user types (maxlength caps the rest). + const digitsOnly = (input) => input?.addEventListener("input", () => { + const cleaned = input.value.replace(/\D/g, ""); + if (cleaned !== input.value) input.value = cleaned; + }); + digitsOnly(durInput); + digitsOnly(portInput); + try { const r = await fetch("/api/settings", { cache: "no-store" }); if (r.ok) apply(await r.json()); @@ -1878,6 +1888,10 @@ async function wireGeneralSettings(overlay) { heightSel?.addEventListener("change", () => { post({ video_max_height: parseInt(heightSel.value, 10) }); }); + portInput?.addEventListener("change", () => { + const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8080)); + post({ port }); + }); } async function wireNetworkSetting(overlay) { @@ -1961,12 +1975,9 @@ function openLibraryEditor() {
Max track length
-
Longest track accepted for processing.
-
-
- - min +
Longest track accepted for processing, in minutes (max 20).
+
@@ -1986,6 +1997,15 @@ function openLibraryEditor() {