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.
+
+
+
+
Loading…
+
+
+
+
+
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;