From 5497697741d99c1601d60cbc9842b2108abc2ef9 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 09:20:33 -0700 Subject: [PATCH] feat: Enhance voice selection and settings UI with default voice option and improved layout --- abogen/web/routes.py | 24 ++++- abogen/web/static/dashboard.js | 46 ++++++++-- abogen/web/static/styles.css | 139 ++++++++++++++++++++++++++--- abogen/web/templates/index.html | 16 ++-- abogen/web/templates/settings.html | 38 ++++++-- 5 files changed, 226 insertions(+), 37 deletions(-) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 513e318..70ebe32 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -92,6 +92,15 @@ def _build_voice_catalog() -> List[Dict[str, str]]: def _template_options() -> Dict[str, Any]: profiles = serialize_profiles() ordered_profiles = sorted(profiles.items()) + profile_options = [] + for name, entry in ordered_profiles: + profile_options.append( + { + "name": name, + "language": (entry or {}).get("language", ""), + "formula": _formula_from_profile(entry or {}) or "", + } + ) return { "languages": LANGUAGE_DESCRIPTIONS, "voices": VOICES_INTERNAL, @@ -99,6 +108,7 @@ def _template_options() -> Dict[str, Any]: "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "output_formats": SUPPORTED_SOUND_FORMATS, "voice_profiles": ordered_profiles, + "voice_profile_options": profile_options, "separate_formats": ["wav", "flac", "mp3", "opus"], "voice_catalog": _build_voice_catalog(), "sample_voice_texts": SAMPLE_VOICE_TEXTS, @@ -136,6 +146,7 @@ def _settings_defaults() -> Dict[str, Any]: "output_format": "wav", "subtitle_format": "srt", "save_mode": "default_output" if _has_output_override() else "save_next_to_input", + "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", "replace_single_newlines": False, "use_gpu": True, "save_chapters_separately": False, @@ -201,6 +212,10 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> if normalized in {"wav", "flac", "mp3", "opus"}: return normalized return defaults[key] + if key == "default_voice": + if isinstance(value, str) and value in VOICES_INTERNAL: + return value + return defaults[key] return value if value is not None else defaults.get(key) @@ -339,7 +354,11 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: @web_bp.get("/") def index() -> str: - return render_template("index.html", options=_template_options()) + return render_template( + "index.html", + options=_template_options(), + settings=_load_settings(), + ) @web_bp.get("/queue") @@ -367,6 +386,9 @@ def settings_page() -> Response | str: updated["save_mode"] = _normalize_setting_value( "save_mode", form.get("save_mode"), defaults ) + updated["default_voice"] = _normalize_setting_value( + "default_voice", form.get("default_voice"), defaults + ) for key in sorted(BOOLEAN_SETTINGS): updated[key] = _coerce_bool(form.get(key), False) updated["separate_chapters_format"] = _normalize_setting_value( diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index a9fce26..f08b92a 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -4,6 +4,7 @@ const initDashboard = () => { const voiceSelect = document.querySelector('[data-role="voice-select"]'); const formulaField = document.querySelector('[data-role="formula-field"]'); const formulaInput = document.querySelector('[data-role="voice-formula"]'); + const languageSelect = document.getElementById("language"); const sourceText = document.querySelector('[data-role="source-text"]'); const previewEl = document.querySelector('[data-role="text-preview"]'); @@ -11,20 +12,45 @@ const initDashboard = () => { const charCountEl = document.querySelector('[data-role="char-count"]'); const wordCountEl = document.querySelector('[data-role="word-count"]'); + const hydrateDefaultVoice = () => { + if (!voiceSelect) return; + const defaultVoice = voiceSelect.dataset.default; + if (!defaultVoice) return; + const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`); + if (option) { + voiceSelect.value = defaultVoice; + } + }; + const updateVoiceControls = () => { if (!profileSelect) { return; } const value = profileSelect.value; - const showVoice = !value || value === "__standard"; - const showFormula = value === "__formula"; + const isStandard = !value || value === "__standard"; + const isFormula = value === "__formula"; + const isSavedProfile = Boolean(value && !isStandard && !isFormula); if (voiceField) { - voiceField.hidden = !showVoice; - voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true"); + voiceField.hidden = false; + voiceField.setAttribute("aria-hidden", "false"); } if (voiceSelect) { - voiceSelect.disabled = !showVoice; + voiceSelect.disabled = !isStandard; + voiceSelect.dataset.state = isStandard ? "editable" : "locked"; + } + + let showFormula = isFormula || isSavedProfile; + let presetFormula = ""; + if (isSavedProfile) { + const option = profileSelect.selectedOptions[0]; + if (option) { + presetFormula = option.dataset.formula || ""; + const profileLang = option.dataset.language || ""; + if (profileLang && languageSelect) { + languageSelect.value = profileLang; + } + } } if (formulaField) { @@ -33,9 +59,15 @@ const initDashboard = () => { } if (formulaInput) { formulaInput.disabled = !showFormula; - if (!showFormula) { + if (showFormula) { + if (presetFormula) { + formulaInput.value = presetFormula; + } + } else { formulaInput.value = formulaInput.value.trim(); } + formulaInput.dataset.state = isSavedProfile ? "locked" : "editable"; + formulaInput.readOnly = isSavedProfile; } }; @@ -74,6 +106,8 @@ const initDashboard = () => { updateVoiceControls(); } + hydrateDefaultVoice(); + if (sourceText) { sourceText.addEventListener("input", updatePreview); updatePreview(); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index c7c9ce9..8e67269 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -138,6 +138,14 @@ body { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.form-grid { + align-items: start; +} + +.form-grid > .grid { + align-items: start; +} + .button { appearance: none; border: none; @@ -175,6 +183,20 @@ body { gap: 0.4rem; } +.field--full { + max-width: none; +} + +.field--full textarea { + min-height: 260px; +} + +.field--actions { + display: flex; + justify-content: flex-end; + align-items: center; +} + .field label { font-weight: 500; color: var(--muted); @@ -194,6 +216,18 @@ body { transition: border 0.2s ease, box-shadow 0.2s ease; } +.field input[type="text"], +.field input[type="file"], +.field input[type="number"], +.field select { + max-width: 420px; + width: min(100%, 420px); +} + +.field input[type="number"] { + max-width: 200px; +} + .field input:focus, .field select:focus, .field textarea:focus { @@ -376,19 +410,70 @@ body { border-radius: 18px; padding: 1.25rem 1.4rem; display: grid; - gap: 1rem; + gap: 1.1rem 1.4rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } .settings__section legend { font-weight: 600; padding: 0 0.5rem; color: var(--muted); + grid-column: 1 / -1; } -.field--inline { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; +.field--choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.toggle-pill { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.65rem; + cursor: pointer; +} + +.toggle-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.toggle-pill span { + display: inline-flex; + align-items: center; + justify-content: flex-start; + width: 100%; + padding: 0.65rem 0.95rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.45); + color: var(--muted); + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.toggle-pill:hover span { + border-color: var(--accent); + color: var(--accent); +} + +.toggle-pill input:focus-visible + span { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.toggle-pill input:checked + span { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: var(--accent); +} + +.hint { + margin: 0; + font-size: 0.8rem; + color: var(--muted); } .settings__actions { @@ -600,23 +685,21 @@ progress.progress::-moz-progress-bar { } .voice-editor__identity { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 0.75rem 1rem; - align-items: flex-start; + align-items: end; } .voice-editor__name-field { - flex: 1 1 220px; min-width: 200px; } .voice-editor__toolbar { display: inline-flex; align-items: center; - gap: 0.5rem; - flex: 0 0 auto; - margin-left: auto; + justify-content: flex-end; + gap: 0.65rem; min-height: 2.6rem; } @@ -1033,6 +1116,17 @@ progress.progress::-moz-progress-bar { pointer-events: none; } +[data-state="locked"] { + cursor: not-allowed; +} + +select[data-state="locked"], +input[data-state="locked"] { + background: rgba(15, 23, 42, 0.35); + color: rgba(148, 163, 184, 0.8); + border-color: rgba(148, 163, 184, 0.2); +} + .icon-button { appearance: none; border-radius: 16px; @@ -1047,9 +1141,8 @@ progress.progress::-moz-progress-bar { transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; } -.voice-editor__identity > .icon-button, .voice-editor__identity > .voice-editor__toolbar { - align-self: flex-end; + align-self: end; } .icon-button svg { @@ -1178,6 +1271,24 @@ progress.progress::-moz-progress-bar { } } +@media (max-width: 720px) { + .voice-editor__identity { + grid-template-columns: 1fr; + } + + .voice-editor__toolbar { + justify-content: flex-start; + } + + .settings__section { + grid-template-columns: 1fr; + } + + .field--choices { + grid-template-columns: 1fr; + } +} + @keyframes spin { 0% { transform: rotate(0deg); diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 5ff4372..54c45cc 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -5,7 +5,7 @@ {% block content %}

Create a new audiobook

-
+
@@ -21,9 +21,9 @@
- {% for voice in options.voices %} - + {% endfor %}
@@ -32,10 +32,10 @@
@@ -79,7 +79,7 @@
Paste text to see a live preview and character count.
-
+
diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 703099f..c9c584d 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -30,6 +30,15 @@ {% endfor %} +
+ + +

Used when “Standard voice” is selected on the dashboard.

+
Replace single newlines - -
-
- - - +
+ + + + +