feat: Add gender filter to voice mixer and enhance UI for better user experience

feat: Implement dynamic settings page with form handling and default values
feat: Create a queue page to display ongoing jobs with auto-refresh
feat: Revamp dashboard with live text preview and character/word count
fix: Update navigation links in base template for active state indication
This commit is contained in:
JB
2025-10-06 06:59:16 -07:00
parent 2e402f6b5b
commit 01209f6878
10 changed files with 624 additions and 119 deletions
+5 -2
View File
@@ -16,8 +16,11 @@
<span class="brand__tagline">Audiobooks for humans, not desktops</span>
</div>
<nav class="top-actions">
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a>
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
<main class="main">
+31 -68
View File
@@ -10,7 +10,6 @@
<div class="field">
<label for="source_file">Source file</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
<p class="tag">You can also paste text below.</p>
</div>
<div class="field">
<label for="language">Language</label>
@@ -20,9 +19,9 @@
{% endfor %}
</select>
</div>
<div class="field">
<div class="field" data-conditional="standard" data-role="voice-field">
<label for="voice">Voice</label>
<select id="voice" name="voice">
<select id="voice" name="voice" data-role="voice-select">
{% for voice in options.voices %}
<option value="{{ voice }}">{{ voice }}</option>
{% endfor %}
@@ -30,18 +29,22 @@
</div>
<div class="field">
<label for="voice_profile">Voice profile</label>
<select id="voice_profile" name="voice_profile">
<option value="">Use selected voice</option>
{% for name, data in options.voice_profiles %}
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
{% endfor %}
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
<option value="__standard" selected>Standard voice</option>
<option value="__formula">Formula</option>
{% if options.voice_profiles %}
<optgroup label="Saved mixes">
{% for name, data in options.voice_profiles %}
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles_page') }}">voice mixer</a>.</p>
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles_page') }}">voice mixer</a>.</p>
</div>
<div class="field">
<div class="field" data-conditional="formula" data-role="formula-field" hidden>
<label for="voice_formula">Custom voice formula</label>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6">
<p class="tag">Overrides the dropdown/profile when provided.</p>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
</div>
<div class="field">
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
@@ -59,63 +62,22 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="output_format">Audio format</label>
<select id="output_format" name="output_format">
{% for fmt in options.output_formats %}
<option value="{{ fmt }}">{{ fmt }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="subtitle_format">Subtitle file format</label>
<select id="subtitle_format" name="subtitle_format">
{% for value, text in options.subtitle_formats %}
<option value="{{ value }}">{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="save_mode">Save location</label>
<select id="save_mode" name="save_mode">
<option>Save next to input file</option>
<option>Save to Desktop</option>
<option>Choose output folder</option>
</select>
</div>
<div class="split">
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true"> Replace single newlines</label>
<label class="tag"><input type="checkbox" name="use_gpu" value="true" checked> Use GPU (when available)</label>
</div>
<details class="field">
<summary>Advanced chapter &amp; project options</summary>
<div class="field inline">
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true"> Save each chapter separately</label>
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" checked> Also create merged audiobook</label>
<label class="tag"><input type="checkbox" name="save_as_project" value="true"> Save in project folder with metadata</label>
</div>
<div class="field">
<label for="separate_chapters_format">Separate chapter format</label>
<select id="separate_chapters_format" name="separate_chapters_format">
{% for fmt in options.separate_formats %}
<option value="{{ fmt }}">{{ fmt|upper }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
<input type="number" step="0.5" min="0" value="2.0" id="silence_between_chapters" name="silence_between_chapters">
</div>
<div class="field">
<label for="max_subtitle_words">Max words per subtitle entry</label>
<input type="number" min="1" max="100" value="50" id="max_subtitle_words" name="max_subtitle_words">
</div>
</details>
</div>
<div class="grid">
<div class="field">
<label for="source_text">Or paste text directly</label>
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..."></textarea>
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..." data-role="source-text"></textarea>
</div>
<div class="text-preview" data-role="text-preview" aria-live="polite">
<div class="text-preview__header">
<h2>Preview</h2>
<div class="text-preview__meta">
<span data-role="char-count">0 characters</span>
<span>·</span>
<span data-role="word-count">0 words</span>
</div>
</div>
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
</div>
<div class="field" style="justify-content: flex-end;">
<button type="submit" class="button">Queue conversion</button>
@@ -123,8 +85,9 @@
</div>
</form>
</section>
{% endblock %}
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
{% include "partials/jobs.html" %}
</section>
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}abogen · Queue{% endblock %}
{% block content %}
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
{% include "partials/jobs.html" %}
</section>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}abogen · Settings{% endblock %}
{% block content %}
<section class="card settings">
<h1 class="card__title">Application settings</h1>
<p class="tag">Settings apply to new jobs you queue from the dashboard.</p>
{% if saved %}
<div class="alert alert--success">Settings saved successfully.</div>
{% endif %}
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
<fieldset class="settings__section">
<legend>Output defaults</legend>
<div class="field">
<label for="output_format">Audio format</label>
<select id="output_format" name="output_format">
{% for fmt in options.output_formats %}
<option value="{{ fmt }}" {% if settings.output_format == fmt %}selected{% endif %}>{{ fmt }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="subtitle_format">Subtitle file format</label>
<select id="subtitle_format" name="subtitle_format">
{% for value, text in options.subtitle_formats %}
<option value="{{ value }}" {% if settings.subtitle_format == value %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="save_mode">Save location</label>
<select id="save_mode" name="save_mode">
{% for location in save_locations %}
<option value="{{ location.value }}" {% if settings.save_mode == location.value %}selected{% endif %}>{{ location.label }}</option>
{% endfor %}
</select>
<p class="tag">Default output: <code>{{ default_output_dir }}</code></p>
</div>
</fieldset>
<fieldset class="settings__section">
<legend>Processing</legend>
<div class="field field--inline">
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}> Replace single newlines</label>
<label class="tag"><input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}> Use GPU (when available)</label>
</div>
<div class="field field--inline">
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}> Save each chapter separately</label>
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}> Also create merged audiobook</label>
<label class="tag"><input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}> Save as project with metadata</label>
</div>
<div class="field">
<label for="separate_chapters_format">Separate chapter format</label>
<select id="separate_chapters_format" name="separate_chapters_format">
{% for fmt in options.separate_formats %}
<option value="{{ fmt }}" {% if settings.separate_chapters_format == fmt %}selected{% endif %}>{{ fmt|upper }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
<input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}">
</div>
<div class="field">
<label for="max_subtitle_words">Max words per subtitle entry</label>
<input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}">
</div>
</fieldset>
<div class="settings__actions">
<button type="submit" class="button">Save settings</button>
</div>
</form>
</section>
{% endblock %}
+13 -7
View File
@@ -55,13 +55,19 @@
<p class="tag">Drag into the mixer or click Add.</p>
</div>
<div class="voice-filter">
<label for="voice-language-filter">Language</label>
<select id="voice-language-filter" data-role="voice-filter">
<option value="">All languages</option>
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
<label class="voice-filter__label" for="voice-language-filter">Language</label>
<div class="voice-filter__controls">
<select id="voice-language-filter" data-role="voice-filter">
<option value="">All languages</option>
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
<div class="voice-gender-filter" data-role="gender-filter" aria-label="Filter by gender">
<button type="button" class="voice-gender-filter__button" data-value="f" aria-pressed="false" title="Show female voices"></button>
<button type="button" class="voice-gender-filter__button" data-value="m" aria-pressed="false" title="Show male voices"></button>
</div>
</div>
</div>
</header>
<div class="voice-available__list" data-role="available-voices"></div>