- Add cross-project voice/name library (library.py + webUI library tabs) - chapter_builder: embedding-mode generation + auto-load TTS base model in preflight - qwen_tts: vmatrix capture, embedding/voice-prompt refinements - registry: vmatrix_path column + migration for approved voices - webUI: speakers list, chapter audio detail, proper names updates - docs: update voice generation and tts_server README
618 lines
26 KiB
HTML
618 lines
26 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
|
|
<div>
|
|
<h2 style="margin:0">{{ chapter_title }}</h2>
|
|
<p style="color:#94a3b8; margin:0.25rem 0">{{ total_utterances }} utterances</p>
|
|
</div>
|
|
<a href="/chapters-audio" class="btn" style="background:#374151; color:#9ca3af; text-decoration:none">
|
|
← Back to Chapters
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin-bottom:1rem">
|
|
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap">
|
|
<button class="btn btn-generate" onclick="playAllUtterances()">
|
|
▶ Play All
|
|
</button>
|
|
<button class="btn" style="background:#374151; color:#9ca3af" onclick="stopAllUtterances()">
|
|
■ Stop
|
|
</button>
|
|
<button class="btn" style="background:#7c3aed; color:#fff" onclick="buildChapter()" id="buildBtn">
|
|
📦 Build Chapter
|
|
</button>
|
|
<button class="btn" style="background:#059669; color:#fff{% if not has_chapter_audio %}; display:none{% endif %}" id="playChapterBtn" onclick="playChapterAudio()">
|
|
▶ Play Chapter
|
|
</button>
|
|
<button class="btn" style="background:#dc2626; color:#fff; display:none" id="stopChapterBtn" onclick="stopChapterAudio()">
|
|
■ Stop Chapter
|
|
</button>
|
|
<a href="/api/chapter/{{ chapter_id }}/download-wav"
|
|
class="btn" style="background:#2563eb; color:#fff; text-decoration:none{% if not has_chapter_audio %}; display:none{% endif %}"
|
|
id="downloadWavBtn" download>
|
|
⬇ WAV
|
|
</a>
|
|
<a href="/api/chapter/{{ chapter_id }}/download-mp3"
|
|
class="btn" style="background:#7c3aed; color:#fff; text-decoration:none{% if not has_chapter_audio %}; display:none{% endif %}"
|
|
id="downloadMp3Btn" download>
|
|
⬇ MP3
|
|
</a>
|
|
<div id="buildStatus" style="display:none; background: rgba(0,191,255,0.1); border: 1px solid rgba(0,191,255,0.3); border-radius: 6px; padding: 8px 15px; font-size: 0.9em;">
|
|
<span id="buildStatusText">Building...</span>
|
|
<span id="buildProgress" style="color: #00bfff; margin-left: 8px;"></span>
|
|
</div>
|
|
<span style="color:#64748b; font-size:0.9rem" id="playback-status">Ready</span>
|
|
</div>
|
|
</div>
|
|
|
|
{% if chapter_id|string == '1' %}
|
|
<div class="card" style="margin-bottom:1rem; border-left:3px solid #f59e0b">
|
|
<h3 style="color:#f59e0b; margin-bottom:0.5rem">Personalization</h3>
|
|
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:0.75rem">
|
|
Add a personalization watermark to the title page. This line is spoken at the end for tracing.
|
|
</p>
|
|
<div style="display:flex; gap:0.5rem; align-items:center">
|
|
<input type="text" id="personalization-text"
|
|
value="This audiobook was generated for a special listener."
|
|
style="flex:1; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
|
|
<button class="btn btn-generate" onclick="savePersonalization()">Save</button>
|
|
</div>
|
|
<p style="color:#64748b; font-size:0.75rem; margin-top:0.5rem" id="personalization-status"></p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Speaker</th>
|
|
<th style="min-width:200px">Variation</th>
|
|
<th>Text</th>
|
|
<th>Audio</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for audio in audio_files %}
|
|
<tr id="utterance-{{ audio.index }}">
|
|
<td>
|
|
<small style="color:#64748b">{{ audio.index }}</small>
|
|
</td>
|
|
<td>
|
|
<select id="speaker-{{ audio.index }}" onchange="updateSpeaker({{ audio.utt_id }}, {{ audio.index }})"
|
|
style="padding:0.25rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.8rem; max-width:180px">
|
|
{% for name in speaker_names %}
|
|
<option value="{{ name }}" {% if name == audio.speaker %}selected{% endif %}>{{ name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<div style="display:flex; flex-direction:column; gap:0.3rem">
|
|
<div style="display:flex; gap:0.3rem; align-items:center">
|
|
<select id="emotion-{{ audio.index }}"
|
|
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; flex:1">
|
|
{% for e in ["neutral","angry","sad","happy","fearful","surprised","whisper","pleading","sarcastic","authoritative","thoughtful","excited","anxious","tired","romantic","disgusted","amused","nostalgic"] %}
|
|
<option value="{{ e }}" {% if e == audio.emotion %}selected{% endif %}>{{ e }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="pace-{{ audio.index }}"
|
|
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; width:90px">
|
|
{% for p in ["slow","moderate","fast"] %}
|
|
<option value="{{ p }}" {% if p == audio.pace %}selected{% endif %}>{{ p }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<input type="text" id="instruct-{{ audio.index }}"
|
|
value="{{ audio.instruct }}"
|
|
placeholder="Custom instruction (optional)..."
|
|
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; width:100%">
|
|
<button id="save-btn-{{ audio.index }}" class="btn btn-approve" style="font-size:0.65rem; padding:0.1rem 0.4rem; width:fit-content"
|
|
onclick="saveVariation({{ audio.utt_id }}, {{ audio.index }})">
|
|
Save Variation
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit">
|
|
<small style="color:#94a3b8">{{ audio.text }}</small>
|
|
</div>
|
|
<div id="text-edit-{{ audio.index }}" style="display:none">
|
|
<textarea id="text-input-{{ audio.index }}" rows="2"
|
|
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.full_text }}</textarea>
|
|
<div style="margin-top:0.25rem; display:flex; gap:0.25rem">
|
|
<button class="btn btn-approve" style="font-size:0.7rem; padding:0.15rem 0.4rem" onclick="saveText({{ audio.utt_id }}, {{ audio.index }})">Save</button>
|
|
<button class="btn" style="font-size:0.7rem; padding:0.15rem 0.4rem; background:#374151; color:#9ca3af" onclick="cancelEditText({{ audio.index }})">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td style="white-space:nowrap">
|
|
{% if audio.filename %}
|
|
<button class="btn btn-generate" style="font-size:0.8rem; padding:0.3rem 0.6rem"
|
|
onclick="playUtterance('{{ chapter_dir }}', '{{ audio.filename }}', {{ audio.index }})">
|
|
▶
|
|
</button>
|
|
<audio id="audio-{{ audio.index }}" style="height:32px; width:150px; margin-left:0.3rem">
|
|
<source src="/chapter-audio-file/{{ chapter_dir }}/{{ audio.filename }}" type="audio/wav">
|
|
</audio>
|
|
{% else %}
|
|
<span style="color:#64748b; font-size:0.75rem" id="audio-status-{{ audio.index }}">No audio</span>
|
|
{% endif %}
|
|
<button class="btn btn-generate" style="font-size:0.7rem; padding:0.2rem 0.5rem; margin-left:0.3rem"
|
|
id="gen-btn-{{ audio.index }}"
|
|
onclick="generateUtterance({{ audio.utt_id }}, {{ audio.index }})">
|
|
{% if audio.filename %}⟳{% else %}⚡{% endif %}
|
|
</button>
|
|
<button class="btn btn-reject" style="font-size:0.7rem; padding:0.2rem 0.5rem; margin-left:0.3rem"
|
|
onclick="deleteUtterance({{ audio.utt_id }}, {{ audio.index }})">
|
|
✕
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="card" style="margin-top:1rem">
|
|
<h3 style="margin-bottom:0.5rem">Add Utterance</h3>
|
|
<div style="display:flex; gap:0.5rem; align-items:flex-end">
|
|
<div style="flex:1">
|
|
<label style="color:#94a3b8; font-size:0.8rem; display:block; margin-bottom:0.25rem">Speaker</label>
|
|
<select id="new-utterance-speaker"
|
|
style="width:100%; padding:0.4rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem">
|
|
{% for name in speaker_names %}
|
|
<option value="{{ name }}" {% if name == 'Narrator' %}selected{% endif %}>{{ name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div style="flex:3">
|
|
<label style="color:#94a3b8; font-size:0.8rem; display:block; margin-bottom:0.25rem">Text</label>
|
|
<input type="text" id="new-utterance-text" placeholder="Enter utterance text..."
|
|
style="width:100%; padding:0.4rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem">
|
|
</div>
|
|
<button class="btn btn-generate" onclick="addUtterance()" style="white-space:nowrap">Add</button>
|
|
</div>
|
|
<p style="color:#64748b; font-size:0.75rem; margin-top:0.5rem" id="add-utterance-status"></p>
|
|
</div>
|
|
|
|
<script>
|
|
let currentAudio = null;
|
|
let playAllStopped = false;
|
|
let playAllIndex = -1;
|
|
|
|
function updateSpeaker(uttId, index) {
|
|
const select = document.getElementById('speaker-' + index);
|
|
const newSpeaker = select.value;
|
|
fetch('/api/utterance/' + uttId + '/speaker', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({speaker_name: newSpeaker})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) { location.reload(); }
|
|
else { alert('Failed to update speaker'); }
|
|
});
|
|
}
|
|
|
|
function saveVariation(uttId, index) {
|
|
const emotion = document.getElementById('emotion-' + index).value;
|
|
const pace = document.getElementById('pace-' + index).value;
|
|
const instruct = document.getElementById('instruct-' + index).value;
|
|
const btn = document.getElementById('save-btn-' + index);
|
|
|
|
if (btn) {
|
|
btn.textContent = '...';
|
|
btn.disabled = true;
|
|
}
|
|
|
|
fetch('/api/utterance/' + uttId + '/variation', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({emotion: emotion, pace: pace, instruct: instruct})
|
|
}).then(r => r.json()).then(data => {
|
|
if (btn) {
|
|
if (data.ok) {
|
|
btn.textContent = '✓ Saved';
|
|
btn.style.background = '#10b981';
|
|
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 1500);
|
|
} else {
|
|
btn.textContent = '✗ Failed';
|
|
btn.style.background = '#ef4444';
|
|
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 2000);
|
|
}
|
|
}
|
|
}).catch(() => {
|
|
if (btn) {
|
|
btn.textContent = '✗ Error';
|
|
btn.style.background = '#ef4444';
|
|
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 2000);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showGenPopup(message) {
|
|
let el = document.getElementById('gen-popup');
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = 'gen-popup';
|
|
el.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
|
|
el.innerHTML = '<div style="background:#1a1a2e;border:1px solid #7c3aed;border-radius:8px;padding:2rem;text-align:center;color:#e0e0e0;min-width:260px">'
|
|
+ '<div id="gen-popup-spinner" style="border:4px solid #333;border-top:4px solid #7c3aed;border-radius:50%;width:40px;height:40px;margin:0 auto 1rem;animation:spin 1s linear infinite"></div>'
|
|
+ '<p id="gen-popup-text" style="margin:0;font-size:1rem;color:#fff"></p>'
|
|
+ '<small style="color:#94a3b8;display:block;margin-top:0.5rem">You can listen once this closes</small>'
|
|
+ '</div>';
|
|
if (!document.getElementById('gen-popup-style')) {
|
|
const st = document.createElement('style');
|
|
st.id = 'gen-popup-style';
|
|
st.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
|
|
document.head.appendChild(st);
|
|
}
|
|
document.body.appendChild(el);
|
|
}
|
|
document.getElementById('gen-popup-text').textContent = message || 'Regenerating...';
|
|
el.style.display = 'flex';
|
|
}
|
|
|
|
function hideGenPopup() {
|
|
const el = document.getElementById('gen-popup');
|
|
if (el) { el.style.display = 'none'; }
|
|
}
|
|
|
|
function generateUtterance(uttId, index) {
|
|
const btn = document.getElementById('gen-btn-' + index);
|
|
const origText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = '...';
|
|
showGenPopup('Regenerating...');
|
|
|
|
// Persist any pending text edit before generating so the wording change
|
|
// is actually used. If the edit box is open, its value may differ from
|
|
// what's stored in the DB — flush it first.
|
|
const textEdit = document.getElementById('text-edit-' + index);
|
|
const textInput = document.getElementById('text-input-' + index);
|
|
|
|
const maybeSaveText = () => {
|
|
if (textEdit && textEdit.style.display !== 'none' && textInput) {
|
|
const newText = textInput.value;
|
|
return fetch('/api/utterance/' + uttId + '/text', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({text: newText})
|
|
}).then(r => r.json()).then(data => {
|
|
if (!data.ok) { throw new Error('Failed to save text: ' + (data.error || 'unknown')); }
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
};
|
|
|
|
maybeSaveText().then(() => {
|
|
return fetch('/api/utterance/' + uttId + '/generate', { method: 'POST' });
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) {
|
|
btn.textContent = '⏳';
|
|
btn.title = 'Generating...';
|
|
pollGeneration(uttId, index, data.filename);
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.textContent = origText;
|
|
hideGenPopup();
|
|
alert('Failed: ' + (data.error || 'unknown'));
|
|
}
|
|
}).catch((err) => {
|
|
btn.disabled = false;
|
|
btn.textContent = origText;
|
|
hideGenPopup();
|
|
alert('Error: ' + (err.message || 'unknown'));
|
|
});
|
|
}
|
|
|
|
function pollGeneration(uttId, index, filename) {
|
|
const btn = document.getElementById('gen-btn-' + index);
|
|
let elapsed = 0;
|
|
const checkInterval = setInterval(() => {
|
|
elapsed += 2000;
|
|
fetch('/chapter-audio-file/{{ chapter_dir }}/' + filename, { method: 'HEAD' })
|
|
.then(r => {
|
|
if (r.ok) {
|
|
clearInterval(checkInterval);
|
|
hideGenPopup();
|
|
location.reload();
|
|
}
|
|
}).catch(() => {});
|
|
}, 2000);
|
|
|
|
setTimeout(() => {
|
|
clearInterval(checkInterval);
|
|
if (btn.textContent.includes('⏳')) {
|
|
btn.disabled = false;
|
|
btn.textContent = '⟳';
|
|
btn.title = 'Generation may still be in progress';
|
|
hideGenPopup();
|
|
}
|
|
}, 180000);
|
|
}
|
|
|
|
function editText(index) {
|
|
document.getElementById('text-display-' + index).style.display = 'none';
|
|
document.getElementById('text-edit-' + index).style.display = 'block';
|
|
document.getElementById('text-input-' + index).focus();
|
|
}
|
|
|
|
function cancelEditText(index) {
|
|
document.getElementById('text-display-' + index).style.display = 'block';
|
|
document.getElementById('text-edit-' + index).style.display = 'none';
|
|
}
|
|
|
|
function saveText(uttId, index) {
|
|
const newText = document.getElementById('text-input-' + index).value;
|
|
fetch('/api/utterance/' + uttId + '/text', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({text: newText})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) { location.reload(); }
|
|
else { alert('Failed to update text'); }
|
|
});
|
|
}
|
|
|
|
function deleteUtterance(uttId, index) {
|
|
if (!confirm('Delete this utterance? This will also delete its audio and regenerate the chapter.')) return;
|
|
fetch('/api/utterance/' + uttId, { method: 'DELETE' })
|
|
.then(r => r.json()).then(data => {
|
|
if (data.ok) { location.reload(); }
|
|
else { alert('Failed to delete'); }
|
|
});
|
|
}
|
|
|
|
function addUtterance() {
|
|
const speaker = document.getElementById('new-utterance-speaker').value;
|
|
const text = document.getElementById('new-utterance-text').value.trim();
|
|
if (!text) { alert('Enter text first'); return; }
|
|
const status = document.getElementById('add-utterance-status');
|
|
status.textContent = 'Adding...';
|
|
status.style.color = '#94a3b8';
|
|
fetch('/api/chapter/{{ chapter_id }}/utterance', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({text: text, speaker_name: speaker})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) {
|
|
status.textContent = 'Added! Reloading...';
|
|
status.style.color = '#10b981';
|
|
location.reload();
|
|
} else {
|
|
status.textContent = 'Failed: ' + (data.error || 'unknown');
|
|
status.style.color = '#ef4444';
|
|
}
|
|
});
|
|
}
|
|
|
|
{% if chapter_id|string == '1' %}
|
|
function savePersonalization() {
|
|
const text = document.getElementById('personalization-text').value;
|
|
const status = document.getElementById('personalization-status');
|
|
status.textContent = 'Adding...';
|
|
status.style.color = '#94a3b8';
|
|
|
|
fetch('/api/chapter/1/utterance', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({text: text, speaker_name: 'Narrator'})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) {
|
|
status.textContent = 'Added! Reloading...';
|
|
status.style.color = '#10b981';
|
|
location.reload();
|
|
} else {
|
|
status.textContent = 'Failed: ' + (data.error || 'unknown');
|
|
status.style.color = '#ef4444';
|
|
}
|
|
});
|
|
}
|
|
{% endif %}
|
|
|
|
function playUtterance(chapterDir, filename, index) {
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
}
|
|
playAllStopped = true;
|
|
|
|
const audio = document.getElementById('audio-' + index);
|
|
if (audio) {
|
|
currentAudio = audio;
|
|
audio.play();
|
|
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
|
|
document.getElementById('utterance-' + index).style.background = '#1e293b';
|
|
}
|
|
}
|
|
|
|
function playAllUtterances() {
|
|
playAllStopped = false;
|
|
const rows = Array.from(document.querySelectorAll('tbody tr'));
|
|
if (rows.length === 0) return;
|
|
|
|
playAllIndex = 0;
|
|
document.getElementById('playback-status').textContent = 'Playing...';
|
|
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
|
|
playNext(rows);
|
|
}
|
|
|
|
function playNext(rows) {
|
|
if (playAllStopped || playAllIndex >= rows.length) {
|
|
if (!playAllStopped) {
|
|
document.getElementById('playback-status').textContent = 'Complete';
|
|
}
|
|
return;
|
|
}
|
|
|
|
const row = rows[playAllIndex];
|
|
const index = row.id.replace('utterance-', '');
|
|
const audio = document.getElementById('audio-' + index);
|
|
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
}
|
|
|
|
if (audio) {
|
|
currentAudio = audio;
|
|
row.style.background = '#1e293b';
|
|
document.getElementById('playback-status').textContent = 'Playing ' + (playAllIndex + 1) + '/' + rows.length + '...';
|
|
|
|
audio.onended = () => {
|
|
row.style.background = '';
|
|
playAllIndex++;
|
|
playNext(rows);
|
|
};
|
|
|
|
audio.play();
|
|
} else {
|
|
playAllIndex++;
|
|
playNext(rows);
|
|
}
|
|
}
|
|
|
|
function stopAllUtterances() {
|
|
playAllStopped = true;
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
}
|
|
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
|
|
document.getElementById('playback-status').textContent = 'Stopped';
|
|
}
|
|
|
|
function playChapterAudio() {
|
|
if (chapterAudio) {
|
|
chapterAudio.pause();
|
|
chapterAudio.currentTime = 0;
|
|
}
|
|
chapterAudio = new Audio('/api/chapter/{{ chapter_id }}/full-audio');
|
|
document.getElementById('playback-status').textContent = 'Playing chapter...';
|
|
document.getElementById('playChapterBtn').style.display = 'none';
|
|
document.getElementById('stopChapterBtn').style.display = 'inline-block';
|
|
chapterAudio.onended = () => {
|
|
document.getElementById('playback-status').textContent = 'Chapter complete';
|
|
document.getElementById('playChapterBtn').style.display = 'inline-block';
|
|
document.getElementById('stopChapterBtn').style.display = 'none';
|
|
};
|
|
chapterAudio.onerror = () => {
|
|
document.getElementById('playback-status').textContent = 'Error playing chapter';
|
|
document.getElementById('playChapterBtn').style.display = 'inline-block';
|
|
document.getElementById('stopChapterBtn').style.display = 'none';
|
|
};
|
|
chapterAudio.play();
|
|
}
|
|
|
|
function stopChapterAudio() {
|
|
if (chapterAudio) {
|
|
chapterAudio.pause();
|
|
chapterAudio.currentTime = 0;
|
|
chapterAudio = null;
|
|
}
|
|
document.getElementById('playback-status').textContent = 'Stopped';
|
|
document.getElementById('playChapterBtn').style.display = 'inline-block';
|
|
document.getElementById('stopChapterBtn').style.display = 'none';
|
|
}
|
|
|
|
let buildPollTimer = null;
|
|
let chapterAudio = null;
|
|
|
|
function showChapterAudioButtons(show) {
|
|
const display = show ? 'inline-block' : 'none';
|
|
const playBtn = document.getElementById('playChapterBtn');
|
|
const wavBtn = document.getElementById('downloadWavBtn');
|
|
const mp3Btn = document.getElementById('downloadMp3Btn');
|
|
if (playBtn) playBtn.style.display = display;
|
|
if (wavBtn) wavBtn.style.display = display;
|
|
if (mp3Btn) mp3Btn.style.display = display;
|
|
}
|
|
|
|
function buildChapter() {
|
|
const btn = document.getElementById('buildBtn');
|
|
const statusDiv = document.getElementById('buildStatus');
|
|
const statusText = document.getElementById('buildStatusText');
|
|
const progressText = document.getElementById('buildProgress');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Building...';
|
|
statusDiv.style.display = 'inline-block';
|
|
statusText.textContent = 'Starting build...';
|
|
progressText.textContent = '';
|
|
|
|
fetch('/api/chapter/{{ chapter_id }}/build', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.ok) {
|
|
pollBuildStatus();
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.textContent = '📦 Build Chapter';
|
|
statusText.textContent = 'Build failed: ' + (data.error || 'unknown');
|
|
}
|
|
}).catch(() => {
|
|
btn.disabled = false;
|
|
btn.textContent = '📦 Build Chapter';
|
|
statusText.textContent = 'Request failed';
|
|
});
|
|
}
|
|
|
|
function pollBuildStatus() {
|
|
if (buildPollTimer) clearInterval(buildPollTimer);
|
|
buildPollTimer = setInterval(() => {
|
|
fetch('/api/chapter/{{ chapter_id }}/build-status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const statusText = document.getElementById('buildStatusText');
|
|
const progressText = document.getElementById('buildProgress');
|
|
const btn = document.getElementById('buildBtn');
|
|
|
|
const phase = data.phase || data.status || 'idle';
|
|
const detail = data.detail || '';
|
|
const elapsed = data.elapsed || '';
|
|
const failed = data.failed || [];
|
|
const errors = data.errors || [];
|
|
|
|
let label = detail || phase;
|
|
if (elapsed) label += ' (' + elapsed + ')';
|
|
if (failed.length > 0) label += ' [' + failed.length + ' failed]';
|
|
statusText.textContent = label;
|
|
|
|
if (data.progress > 0) {
|
|
progressText.textContent = data.progress + '%';
|
|
}
|
|
|
|
if (phase === 'complete' || phase === 'warning') {
|
|
clearInterval(buildPollTimer);
|
|
buildPollTimer = null;
|
|
btn.disabled = false;
|
|
btn.textContent = '📦 Build Chapter';
|
|
progressText.textContent = phase === 'complete' ? '✓ Complete' : '⚠ Warnings';
|
|
showChapterAudioButtons(true);
|
|
} else if (phase === 'error') {
|
|
clearInterval(buildPollTimer);
|
|
buildPollTimer = null;
|
|
btn.disabled = false;
|
|
btn.textContent = '📦 Build Chapter';
|
|
progressText.textContent = '✗ Failed';
|
|
if (errors.length > 0) {
|
|
statusText.textContent += '\n' + errors.join('; ');
|
|
}
|
|
}
|
|
}).catch(() => {});
|
|
}, 2000);
|
|
|
|
setTimeout(() => {
|
|
if (buildPollTimer) {
|
|
clearInterval(buildPollTimer);
|
|
buildPollTimer = null;
|
|
const btn = document.getElementById('buildBtn');
|
|
btn.disabled = false;
|
|
btn.textContent = '📦 Build Chapter';
|
|
document.getElementById('buildStatusText').textContent = 'Timed out';
|
|
}
|
|
}, 300000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|