diff --git a/.gitignore b/.gitignore index 507be5c3..d86469d3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ htmlcov/ # Runtime job artifacts and local build scratch data/ jobs/ +settings.json .run/ .build @@ -77,3 +78,6 @@ Thumbs.db # Tool versions .python-version + +# Imported design references (kept local, not shipped) +design/ diff --git a/app/api/jobs.py b/app/api/jobs.py index 17dc365c..db17a2cb 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -12,14 +12,7 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, field_validator -from app.core.config import ( - JOB_ID_RE, - JOBS_DIR, - MAX_DURATION_SEC, - MAX_PENDING_JOBS, - STEM_NAMES, - ffprobe_executable, -) +from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_JOBS, STEM_NAMES, ffprobe_executable from app.core.models import Job from app.core.registry import all_jobs as registry_all_jobs from app.core.registry import get as registry_get @@ -27,6 +20,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove +from app.core.settings import get_max_duration_sec from app.pipeline import run_local_pipeline, run_pipeline from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url @@ -213,12 +207,11 @@ async def _create_local_job(request: Request) -> dict[str, str]: except Exception as e: raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e - if duration > MAX_DURATION_SEC: + max_duration = get_max_duration_sec() + if duration > max_duration: raise HTTPException( status_code=422, - detail=( - f"File is {int(duration // 60)} min — limit is {MAX_DURATION_SEC // 60} min" - ), + detail=(f"File is {int(duration // 60)} min — limit is {max_duration // 60} min"), ) except HTTPException: shutil.rmtree(job_dir, ignore_errors=True) diff --git a/app/api/stems.py b/app/api/stems.py index 5447ddb3..396baecd 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -6,6 +6,7 @@ import re import subprocess import tempfile +import uuid import zipfile from pathlib import Path @@ -99,6 +100,50 @@ async def _stream_ffmpeg(cmd: list[str]): await proc.wait() +async def _ensure_cached_mp3(src: Path) -> Path: + """Transcode `src` (a stem WAV) to a sibling `.mp3`, cached on disk. + Re-encoding a full song on every request is the slow part of loading a track + on mobile (≈3s/stem × 6 in parallel); caching makes repeat loads instant. + Written atomically (temp + rename) so concurrent fetches can't serve a + partial file.""" + dest = src.with_suffix(".mp3") + if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime: + return dest + tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp") + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-q:a", + "2", # VBR ~190 kbps + "-f", + "mp3", + str(tmp), + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) + try: + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG) + except (TimeoutError, asyncio.TimeoutError): + proc.kill() + await proc.wait() + tmp.unlink(missing_ok=True) + raise HTTPException(status_code=504, detail="mp3 transcode timed out") from None + if proc.returncode != 0: + tmp.unlink(missing_ok=True) + logger.warning( + "mp3 transcode failed for %s: %s", src.name, (stderr or b"").decode("utf-8", "replace") + ) + raise HTTPException(status_code=500, detail="mp3 transcode failed") + os.replace(tmp, dest) + return dest + + @router.get("/jobs/{job_id}/stems/peaks.json") async def get_stem_peaks(job_id: str) -> Response: """Return pre-computed waveform peaks for all stems.""" @@ -166,8 +211,9 @@ async def get_stem_mp3( name: str, start: float | None = Query(default=None, ge=0, description="Trim start in seconds"), end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), -) -> StreamingResponse: - """Stream a stem as MP3 (VBR ~190 kbps). Optional ?start=&end= trims to a time region.""" +) -> Response: + """Stem as MP3 (VBR ~190 kbps). Full stems are cached to disk; ?start=&end= + streams a freshly-trimmed region (uncached).""" path = _validate_stem_path(job_id, name) if (start is None) != (end is None) or (start is not None and start >= end): @@ -176,6 +222,21 @@ async def get_stem_mp3( detail="start and end are both required and start must be less than end", ) + # Full-stem requests (no trim) are cached to disk so repeat loads — the + # common case for the mobile player — are instant instead of re-encoding. + if start is None: + cached = await _ensure_cached_mp3(path) + return FileResponse( + cached, + media_type="audio/mpeg", + headers={ + "Content-Disposition": f'attachment; filename="{name}.mp3"', + # Stems are immutable once a job is done — let the phone cache + # them so a re-load is instant and offline-friendly. + "Cache-Control": "public, max-age=31536000, immutable", + }, + ) + pre_seek = ["-ss", str(start)] if start is not None else [] post_seek = ["-t", str(end - start)] if start is not None else [] diff --git a/app/core/settings.py b/app/core/settings.py new file mode 100644 index 00000000..68e7fae9 --- /dev/null +++ b/app/core/settings.py @@ -0,0 +1,118 @@ +"""Runtime, user-toggleable settings (persisted to disk). + +These are read live (unlike the env-var constants in config.py, which are fixed +at startup), so the Settings UI can change them without a restart: + +- `allow_network` — whether StemDeck answers requests from other devices. +- `max_duration_sec` — longest track accepted for processing. +- `video_max_height` — max video resolution for MP4 export / YouTube pulls. + +Defaults fall back to the config.py constants (which honor their env vars), so +nothing changes until the user overrides a value. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading + +from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT + +_log = logging.getLogger("stemdeck.settings") + +_SETTINGS_PATH = DATA_DIR / "settings.json" +_LOCK = threading.RLock() +_state: dict | None = None # whole settings dict, loaded lazily + +# 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 + + +def _default_allow_network() -> bool: + # Off by default everywhere — the user explicitly opts other devices in. + # STEMDECK_ALLOW_NETWORK=1 can pre-enable it (e.g. headless/Docker deploys). + env = os.environ.get("STEMDECK_ALLOW_NETWORK") + if env is not None: + return env.strip() == "1" + return False + + +def _load() -> dict: + try: + data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except FileNotFoundError: + pass # no settings file yet — first run; use defaults + except Exception: + # Corrupt/unreadable file: fall back to defaults rather than crash. + _log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True) + return {} + + +def _ensure() -> dict: + global _state + if _state is None: + _state = _load() + return _state + + +def _save() -> None: + try: + _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") + except Exception: + # Persistence is best-effort (read-only FS, permissions): the in-memory + # value still applies for this session, so don't fail the request. + _log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True) + + +def _num(v: object) -> int | None: + return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None + + +# ── allow_network ── +def get_allow_network() -> bool: + with _LOCK: + v = _ensure().get("allow_network") + return v if isinstance(v, bool) else _default_allow_network() + + +def set_allow_network(value: bool) -> bool: + with _LOCK: + _ensure()["allow_network"] = bool(value) + _save() + return bool(value) + + +# ── max_duration_sec ── +def get_max_duration_sec() -> int: + with _LOCK: + v = _num(_ensure().get("max_duration_sec")) + return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC + + +def set_max_duration_sec(value: int) -> int: + with _LOCK: + clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value))) + _ensure()["max_duration_sec"] = clamped + _save() + return clamped + + +# ── video_max_height ── +def get_video_max_height() -> int: + with _LOCK: + v = _num(_ensure().get("video_max_height")) + return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT + + +def set_video_max_height(value: int) -> int: + with _LOCK: + clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value))) + _ensure()["video_max_height"] = clamped + _save() + return clamped diff --git a/app/main.py b/app/main.py index 9ee4c384..4558f918 100644 --- a/app/main.py +++ b/app/main.py @@ -2,15 +2,19 @@ import asyncio import ctypes +import functools import logging import os +import re import signal +import socket from collections.abc import AsyncIterator from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from app.api.router import router @@ -24,6 +28,14 @@ ensure_runtime_dirs, ) from app.core.registry import restore as restore_registry +from app.core.settings import ( + get_allow_network, + get_max_duration_sec, + get_video_max_height, + set_allow_network, + set_max_duration_sec, + set_video_max_height, +) from app.pipeline.collect import sweep_old_jobs # Show our INFO-level logs through uvicorn's root handler. Without this, @@ -137,6 +149,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: yield +# Phones hitting the self-hosted server URL get the mobile UI; everything +# else (desktop browsers, and the Tauri webviews, which all report desktop +# user-agents) gets the DAW. Tablets are intentionally treated as desktop — +# the DAW layout is usable there. "Mobi" is the cross-browser marker for a +# phone form factor (Chrome/Firefox/Safari all include it); the rest cover +# vendors that don't. +_MOBILE_UA_RE = re.compile( + r"Mobi|Android|iPhone|iPod|IEMobile|BlackBerry|Opera Mini", re.IGNORECASE +) + + +def _is_mobile_ua(user_agent: str) -> bool: + return bool(user_agent) and _MOBILE_UA_RE.search(user_agent) is not None + + app = FastAPI( title="StemDeck", description="Paste a YouTube URL or upload an audio file, get audio stems split into a DAW-style player.", @@ -145,6 +172,23 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: ) +@app.get("/", include_in_schema=False) +def index(request: Request) -> FileResponse: + """Serve the mobile shell to phones, the DAW to everyone else. `?ui=mobile` + / `?ui=desktop` forces either one (handy for testing from a desktop). This + route is registered before the StaticFiles mount at "/", so it wins for the + bare path while the mount still serves every other asset.""" + ui = request.query_params.get("ui") + if ui == "mobile": + mobile = True + elif ui == "desktop": + mobile = False + else: + mobile = _is_mobile_ua(request.headers.get("user-agent", "")) + page = "mobile/index.html" if mobile else "index.html" + return FileResponse(STATIC_DIR / page) + + @app.get("/health", include_in_schema=False) def health_root() -> dict[str, object]: return health() @@ -162,6 +206,58 @@ def health() -> dict[str, object]: } +def _is_lan_ipv4(ip: str) -> bool: + """A reachable IPv4 LAN address to show another device: not IPv6 (link-local + needs a zone index and won't work in a browser), not loopback, not the + 169.254.x auto-config range.""" + if ":" in ip: # IPv6 + return False + if _is_loopback(ip) or ip.startswith("169.254."): + return False + parts = ip.split(".") + return len(parts) == 4 and all(p.isdigit() for p in parts) + + +def _settings_payload() -> dict[str, object]: + return { + "allow_network": get_allow_network(), + "max_duration_sec": get_max_duration_sec(), + "video_max_height": get_video_max_height(), + } + + +@app.get("/api/settings", tags=["settings"]) +def get_settings(request: Request) -> dict[str, object]: + # LAN addresses other devices can use — loopback excluded (only works on the + # host). The port is whatever this request came in on. + port = request.url.port or 8000 + addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip)) + return {**_settings_payload(), "lan_addresses": addresses} + + +@app.post("/api/settings", tags=["settings"]) +async def update_settings(request: Request) -> dict[str, object]: + """Update runtime settings. Reachable from the host machine always; from a + LAN device only while network access is currently on (the gate below), so a + phone can't change settings once the owner turned access off.""" + try: + body = await request.json() + except Exception: + body = {} + if "allow_network" in body: + set_allow_network(bool(body["allow_network"])) + for key, setter in ( + ("max_duration_sec", set_max_duration_sec), + ("video_max_height", set_video_max_height), + ): + if key in body: + try: + setter(int(body[key])) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None + return _settings_payload() + + # Content-Security-Policy. Defense-in-depth so an injected string in the webview # can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171. # script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the @@ -200,6 +296,68 @@ async def security_and_cache_headers(request: Request, call_next): return response +def _is_loopback(host: str | None) -> bool: + if not host: + return False + if host.startswith("::ffff:"): # IPv4-mapped IPv6 + host = host[7:] + return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") + + +@functools.lru_cache(maxsize=1) +def _local_ips() -> frozenset[str]: + """The machine's own interface IPs. Used so the host always reaches the app + even via its LAN address (e.g. 192.168.x.x), not just 127.0.0.1 — turning + network access off must never cut the host off from its own server.""" + ips: set[str] = set() + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None): + ips.add(info[4][0]) + except Exception: + # Best-effort: name resolution can fail on odd hostnames/configs; we + # still try the outbound-socket probe below and fall back to loopback. + _log.debug("hostname IP enumeration failed", exc_info=True) + try: # primary outbound IP, robust when the hostname doesn't resolve them all + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ips.add(s.getsockname()[0]) + s.close() + except Exception: + # Best-effort: no default route / offline — just return what we have. + _log.debug("outbound IP probe failed", exc_info=True) + return frozenset(ips) + + +def _is_host_request(host: str | None) -> bool: + """True when the request originates from the machine StemDeck runs on — + whether via loopback or one of its own interface addresses.""" + if _is_loopback(host): + return True + if not host: + return False + h = host[7:] if host.startswith("::ffff:") else host + return h in _local_ips() + + +# Network availability gate (Settings → "Make StemDeck available on your +# network"). Added after the headers middleware so it is the OUTERMOST layer and +# short-circuits before anything else. It NEVER stops the server — it only +# refuses requests from OTHER devices when availability is off. The host machine +# (loopback or its own LAN IP) is always served, so the app keeps working +# locally regardless of this setting. +@app.middleware("http") +async def network_gate(request: Request, call_next): + if not get_allow_network(): + client_host = request.client.host if request.client else None + if not _is_host_request(client_host): + return PlainTextResponse( + "StemDeck is not available on the network. Enable it in Settings on the host machine.", + status_code=403, + ) + return await call_next(request) + + app.include_router(router, prefix="/api") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/app/pipeline/download.py b/app/pipeline/download.py index 4dac6f30..c69d6095 100644 --- a/app/pipeline/download.py +++ b/app/pipeline/download.py @@ -8,8 +8,9 @@ from yt_dlp import YoutubeDL -from app.core.config import FFMPEG_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT +from app.core.config import FFMPEG_DIR from app.core.models import Job, JobCancelled, _set +from app.core.settings import get_max_duration_sec, get_video_max_height logger = logging.getLogger("stemdeck.download") @@ -173,10 +174,11 @@ def vhook(d: dict) -> None: # Prefer H.264 (avc1) so the exported MP4 plays everywhere -- YouTube also # serves AV1/VP9 in mp4 containers, which many players (Safari/iOS, older # devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists. + max_height = get_video_max_height() ydl_opts = { "format": ( - f"bestvideo[height<={VIDEO_MAX_HEIGHT}][vcodec^=avc1]" - f"/bestvideo[height<={VIDEO_MAX_HEIGHT}][ext=mp4]" + f"bestvideo[height<={max_height}][vcodec^=avc1]" + f"/bestvideo[height<={max_height}][ext=mp4]" ), "outtmpl": str(job_dir / "video.%(ext)s"), "quiet": True, @@ -222,8 +224,9 @@ def download(job: Job, url: str, job_dir: Path) -> Path: ) as ydl: meta = ydl.extract_info(url, download=False) or {} duration = meta.get("duration") or 0 - if duration > MAX_DURATION_SEC: - mins = MAX_DURATION_SEC // 60 + max_duration = get_max_duration_sec() + if duration > max_duration: + mins = max_duration // 60 raise RuntimeError(f"Video is {int(duration // 60)} min -- limit is {mins} min") def hook(d: dict) -> None: diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e4916989..93f866ee 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -202,6 +202,7 @@ fn main() { ensure_external_assets, ensure_torch_device, start_backend, + local_ip, open_url, save_audio_file, store_get, @@ -528,6 +529,11 @@ fn start_backend( app_handle: tauri::AppHandle, state: tauri::State, ) -> Result { + // Always bind all interfaces; whether other devices are actually served is + // controlled live by the backend's network gate (Settings → "Make StemDeck + // available on your network"), which defaults off and always allows + // loopback. The WebView itself connects via 127.0.0.1 regardless. + let bind_host = "0.0.0.0"; // Gate concurrent calls: return immediately if already running or starting (#145). { let mut inner = state.inner.lock().map_err(|e| e.to_string())?; @@ -576,7 +582,7 @@ fn start_backend( "uvicorn", "app.main:app", "--host", - "127.0.0.1", + bind_host, "--port", &port.to_string(), ]); @@ -645,6 +651,21 @@ fn start_backend( } } +/// Best-effort primary LAN IPv4, shown in Settings so the user knows the address +/// to open StemDeck from another device. Uses the "connect a UDP socket" trick: +/// no packets are sent — connect() just makes the OS pick the source IP for the +/// default route. Returns None when offline / no route. +#[tauri::command] +fn local_ip() -> Option { + use std::net::UdpSocket; + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + match sock.local_addr().ok()?.ip() { + std::net::IpAddr::V4(v4) if !v4.is_loopback() => Some(v4.to_string()), + _ => None, + } +} + /// Detects GPU hardware, installs CUDA torch if needed, and persists the chosen device. #[tauri::command] fn ensure_torch_device(state: tauri::State) -> Result { diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js index d8c3693c..1ef824bc 100644 --- a/desktop/ui/setup.js +++ b/desktop/ui/setup.js @@ -2,6 +2,12 @@ const { invoke } = window.__TAURI__.core; let _runtimeUnlisten = null; +// The backend always binds all interfaces; network availability is gated live +// by the backend itself (Settings → "Make StemDeck available on your network"). +function startBackend() { + return invoke("start_backend"); +} + const statusEl = document.getElementById("status"); const detailsEl = document.getElementById("details"); const retryBtn = document.getElementById("retry"); @@ -242,7 +248,7 @@ async function runSetup() { } await runStep("backend", async () => { setStatus("Runtime is ready. Starting StemDeck backend..."); - const backend = await invoke("start_backend"); + const backend = await startBackend(); setStatus("Opening StemDeck..."); window.location.replace(backend.url); }); @@ -359,7 +365,7 @@ async function runSetup() { await runStep("backend", async () => { setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting StemDeck backend..."); - const backend = await invoke("start_backend"); + const backend = await startBackend(); setStatus("Opening StemDeck..."); window.location.replace(backend.url); }); diff --git a/static/css/daw.css b/static/css/daw.css index 57f43d79..da6bec24 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -772,6 +772,44 @@ input, textarea { font-family: inherit; } .library-editor-table tr.unavailable .le-name { color: var(--danger); } .le-badge { margin-left: 7px; padding: 1px 6px; border-radius: 5px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(214,90,74,0.16); color: var(--danger); } .library-editor-empty { color: var(--muted); text-align: center; padding: 22px 10px !important; } +/* Settings → tabs */ +.settings-tabs { display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 1px solid var(--border); } +.settings-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 5px 12px 9px; cursor: pointer; margin-bottom: -1px; } +.settings-tab:hover { color: var(--fg-2); } +.settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); } +.settings-pane { display: flex; flex-direction: column; min-height: 0; } +.settings-pane[data-pane="general"] { flex: 1; } +.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 → network access section */ +.settings-section { margin-bottom: 12px; } +.settings-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.settings-row-text { min-width: 0; } +.settings-row-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } +.settings-row-desc { font-size: 10.5px; color: var(--muted); margin-top: 3px; line-height: 1.45; } +.settings-switch { position: relative; flex-shrink: 0; width: 42px; height: 24px; cursor: pointer; } +.settings-switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } +.settings-switch-track { position: absolute; inset: 0; border-radius: 999px; background: rgba(148,163,184,0.22); border: 1px solid var(--border-strong); transition: background 0.15s ease; } +.settings-switch-thumb { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--fg); transition: transform 0.15s ease; } +.settings-switch input:checked + .settings-switch-track { background: rgba(244,183,64,0.55); border-color: rgba(244,183,64,0.5); } +.settings-switch input:checked + .settings-switch-track .settings-switch-thumb { transform: translateX(18px); background: var(--accent); } +.settings-switch.disabled { cursor: default; opacity: 0.7; } +.settings-switch.disabled input { cursor: default; } +.settings-net { margin-top: 10px; font-size: 11px; color: var(--muted); } +.settings-net.hidden { display: none; } +.settings-net-label { margin-bottom: 7px; } +.settings-net-list { display: flex; flex-direction: column; gap: 5px; align-items: flex-start; } +.settings-net-list code { color: var(--accent); background: rgba(244,183,64,0.1); padding: 3px 9px; border-radius: 5px; font-size: 11.5px; } +.settings-net-empty { color: var(--muted); } +.settings-subhead { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; margin: 4px 0 7px; } + .library-editor-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 11px; } .library-editor-status { font-size: 10.5px; color: var(--muted); } .library-editor-status.out-of-sync { color: var(--danger); font-weight: 600; } diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js index 7301b9d9..c444006d 100644 --- a/static/js/audioEngine.js +++ b/static/js/audioEngine.js @@ -19,8 +19,12 @@ const AudioCtx = window.AudioContext || window.webkitAudioContext; * @param {{name:string,url:string}[]} stems Active stems only (caller filters). * @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs */ -export function createAudioEngine(stems, { onTime, onEnded } = {}) { - const ctx = new AudioCtx(); +export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { + // Mobile/iOS only starts audio from a context resumed inside a user gesture. + // Callers can pass a shared, gesture-unlocked `context` (the mobile UI does); + // desktop passes none and we own a fresh one. We only close contexts we own. + const ctx = context || new AudioCtx(); + const ownsCtx = !context; const master = ctx.createGain(); master.connect(ctx.destination); @@ -146,7 +150,7 @@ export function createAudioEngine(stems, { onTime, onEnded } = {}) { stopSources(); if (rafId) { cancelAnimationFrame(rafId); rafId = null; } tracks.clear(); - ctx.close().catch(() => {}); + if (ownsCtx) ctx.close().catch(() => {}); } return { diff --git a/static/js/catalog.js b/static/js/catalog.js index c68d5715..1b4f66e5 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1770,8 +1770,9 @@ function closeLibraryEditor() { function renderLibraryRows(tbody) { tbody.textContent = ""; const trashIds = new Set(getTrashFolder()?.items || []); + // Only out-of-sync (audio missing) tracks — this table sits next to Resync. const entries = Object.entries(tracks) - .filter(([id]) => !trashIds.has(id)) + .filter(([id, t]) => !trashIds.has(id) && t.status === "unavailable") .sort((a, b) => (b[1].createdAt || 0) - (a[1].createdAt || 0)); if (!entries.length) { @@ -1779,7 +1780,7 @@ function renderLibraryRows(tbody) { const td = document.createElement("td"); td.colSpan = 3; td.className = "library-editor-empty"; - td.textContent = "No tracks in your library yet."; + td.textContent = "All tracks are in sync."; tr.appendChild(td); tbody.appendChild(tr); return; @@ -1816,6 +1817,127 @@ function renderLibraryRows(tbody) { } } +// "Make StemDeck available on your network" toggle. The backend always binds +// all interfaces and gates LAN access on a runtime flag (GET/POST /api/settings) +// — so this works live, no restart, identically in the desktop app and the +// self-hosted server. Loopback is always allowed, so the owner can't lock +// themselves out of this control. +function networkSettingsHtml() { + return ` +
+
+
+
Make StemDeck available on your network
+
Let other devices (like your phone) open StemDeck at the address below.
+
+ +
+ +
+ `; +} + +// General settings: max track length (minutes) + MP4 video quality. Read live +// and POSTed on change to /api/settings (same runtime store as the toggle). +async function wireGeneralSettings(overlay) { + const durInput = overlay.querySelector(".set-max-duration"); + const heightSel = overlay.querySelector(".set-video-height"); + if (!durInput && !heightSel) 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); + }; + + try { + const r = await fetch("/api/settings", { cache: "no-store" }); + if (r.ok) apply(await r.json()); + } catch { /* leave blank */ } + + const post = async (patch) => { + try { + const r = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (r.ok) apply(await r.json()); // reflect the server's clamped value + } catch { /* ignore */ } + }; + + durInput?.addEventListener("change", () => { + const mins = Math.max(1, Math.min(20, parseInt(durInput.value, 10) || 20)); + post({ max_duration_sec: mins * 60 }); + }); + heightSel?.addEventListener("change", () => { + post({ video_max_height: parseInt(heightSel.value, 10) }); + }); +} + +async function wireNetworkSetting(overlay) { + const input = overlay.querySelector(".net-access-input"); + const netWrap = overlay.querySelector(".settings-net"); + const list = overlay.querySelector(".settings-net-list"); + if (!input) return; + + let enabled = false; + let addresses = []; + try { + const r = await fetch("/api/settings", { cache: "no-store" }); + if (r.ok) { + const data = await r.json(); + enabled = data.allow_network === true; + addresses = Array.isArray(data.lan_addresses) ? data.lan_addresses : []; + } + } catch { /* leave defaults */ } + + // Build the address list with textContent (URLs are server data, but never + // interpolate untrusted strings into innerHTML). + if (list) { + list.textContent = ""; + if (addresses.length) { + for (const a of addresses) { + const code = document.createElement("code"); + code.textContent = a; + list.appendChild(code); + } + } else { + const span = document.createElement("span"); + span.className = "settings-net-empty"; + span.textContent = "No local network connection detected."; + list.appendChild(span); + } + } + + input.checked = enabled; + const refresh = () => netWrap?.classList.toggle("hidden", !input.checked); + refresh(); + + input.addEventListener("change", async () => { + const want = input.checked; + input.disabled = true; + try { + const r = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allow_network: want }), + }); + input.checked = r.ok ? (await r.json()).allow_network === true : !want; + } catch { + input.checked = !want; // revert on failure + } finally { + input.disabled = false; + refresh(); + } + }); +} + function openLibraryEditor() { closeFolderEditor(); closeLibraryEditor(); @@ -1823,28 +1945,72 @@ function openLibraryEditor() { const overlay = document.createElement("div"); overlay.className = "library-editor-backdrop"; overlay.innerHTML = ` -