From 9c47b1998de7b528fe8a9d4db482b5a2618d5e0e Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 26 May 2026 13:21:21 +0200 Subject: [PATCH] Add container filesystem folder browser to empty voice state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/browse-dirs endpoint lists subdirectories at any container path - 'πŸ“ Browse' button next to the path input toggles an inline dir browser - Breadcrumb navigation lets users click up/down through the filesystem - 'Use this folder' confirms the selection back into the path input - Existing 'Set & reload' flow unchanged Co-Authored-By: Claude Sonnet 4.6 --- server.py | 18 ++++++++++++++ static/app.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 18 +++++++++++++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index 34c5f83..b83ae96 100644 --- a/server.py +++ b/server.py @@ -2721,6 +2721,24 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list: return [] +# ── Server-side directory browser ──────────────────────────────────────────── + +@app.get("/api/browse-dirs") +async def browse_dirs(path: str = "/"): + p = Path(path).resolve() + if not p.is_dir(): + raise HTTPException(404, "Not a directory") + try: + entries = sorted( + [d.name for d in p.iterdir() if d.is_dir() and not d.name.startswith(".")], + key=str.lower, + ) + except PermissionError: + raise HTTPException(403, "Permission denied") + parent = str(p.parent) if p.parent != p else None + return {"path": str(p), "parent": parent, "dirs": entries} + + # ── ElevenLabs shared voice library proxy ──────────────────────────────────── @app.get("/api/elevenlabs/voices") diff --git a/static/app.js b/static/app.js index a61138f..cd77cfd 100644 --- a/static/app.js +++ b/static/app.js @@ -4135,8 +4135,17 @@ function renderVoiceList() {

Set the path where your .wav voice files live inside the container.

+
+

Map a host folder via docker-compose:
- /your/host/path:/voices:rw
or set VOICE_HOST_DIR=/your/host/path in the stack env.

@@ -4160,6 +4169,61 @@ function renderVoiceList() { if (inp) inp.value = s.voices_scan_dir || '/voices'; }).catch(() => {}); + // ── Directory browser ────────────────────────────────────────────────── + let _vefCurrentPath = '/'; + const vefBrowser = document.getElementById('vef-dir-browser'); + const vefDirList = document.getElementById('vef-dir-list'); + const vefCrumb = document.getElementById('vef-breadcrumb'); + const vefSelPath = document.getElementById('vef-selected-path'); + + async function vefNavigate(path) { + _vefCurrentPath = path; + vefDirList.innerHTML = 'Loading…'; + if (vefSelPath) vefSelPath.textContent = path; + try { + const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json()); + // Breadcrumb + const parts = data.path.split('/').filter(Boolean); + const crumbs = [{ label: '/', path: '/' }]; + parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') })); + vefCrumb.innerHTML = crumbs.map((c, i) => + i < crumbs.length - 1 + ? `/` + : `${escHtml(c.label)}` + ).join(''); + vefCrumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); + // Directory list + if (!data.dirs.length) { + vefDirList.innerHTML = 'No subdirectories here.'; + } else { + vefDirList.innerHTML = data.dirs.map(d => + `` + ).join(''); + vefDirList.querySelectorAll('.vef-dir-item').forEach(b => b.addEventListener('click', () => vefNavigate(b.dataset.path))); + } + if (vefSelPath) vefSelPath.textContent = data.path; + _vefCurrentPath = data.path; + } catch(e) { + vefDirList.innerHTML = `Error: ${escHtml(e.message)}`; + } + } + + document.getElementById('voices-empty-browse-btn')?.addEventListener('click', () => { + const open = vefBrowser.hidden; + vefBrowser.hidden = !open; + if (open) { + const cur = document.getElementById('voices-empty-scan-dir')?.value?.trim() || '/voices'; + vefNavigate(cur); + } + }); + + document.getElementById('vef-select-btn')?.addEventListener('click', () => { + const inp = document.getElementById('voices-empty-scan-dir'); + if (inp) inp.value = _vefCurrentPath; + if (vefBrowser) vefBrowser.hidden = true; + }); + // ────────────────────────────────────────────────────────────────────── + document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => { const inp = document.getElementById('voices-empty-scan-dir'); const dir = inp?.value?.trim(); diff --git a/static/style.css b/static/style.css index 6508c67..9e36e3a 100644 --- a/static/style.css +++ b/static/style.css @@ -767,6 +767,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami } .voices-folder-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); } .voices-empty-hint { font-size: 14.5px; color: var(--subtext); opacity: .75; line-height: 1.7; margin-top: 2px; } + +/* Directory browser */ +.vef-dir-browser { margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); overflow: hidden; } +.vef-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 0; padding: 6px 10px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; } +.vef-crumb-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 1px 3px; font-family: monospace; } +.vef-crumb-btn:hover { text-decoration: underline; } +.vef-crumb-cur { font-family: monospace; font-size: 12px; font-weight: 700; color: var(--text); padding: 1px 3px; } +.vef-sep { color: var(--subtext); font-size: 12px; padding: 0 1px; } +.vef-dir-list { max-height: 160px; overflow-y: auto; padding: 4px 0; } +.vef-dir-item { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; padding: 5px 12px; font-size: 12px; font-family: monospace; background: none; border: none; color: var(--text); cursor: pointer; transition: background .1s; } +.vef-dir-item:hover { background: var(--panel); color: var(--accent); } +.vef-loading, .vef-empty, .vef-error { display: block; padding: 12px 12px; font-size: 12px; color: var(--subtext); } +.vef-error { color: #e53e3e; } +.vef-browser-actions { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--border); background: var(--panel); } +.vef-selected-path { flex: 1; font-family: monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--accent); } +.vef-browser-actions .btn-primary { font-size: 12px; padding: 5px 12px; flex-shrink: 0; } .voices-empty-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } .voices-empty-action-btn { width: 100%; } @@ -904,7 +920,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .vl-preview-lang-btn .fi { width: 22px; height: 16px; background-size: cover; background-position: center; border-radius: 2px; display: block; } .vl-preview-input { flex: 1; min-width: 0; display: block; box-sizing: border-box; - font-size: 13px; line-height: 1.5; + font-size: 16px; line-height: 1.5; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 5px 7px; font-family: var(--font); resize: vertical; min-height: 42px;