const chunkPendingTextEdits = new Map(); const chunkVoiceOverrides = new Map(); window.customVoiceMap = window.customVoiceMap || {}; let availableVoicesCache = window.availableVoices || null; let customVoiceMapCache = window.customVoiceMap || {}; let availableChatterboxVoicesCache = Array.isArray(window.availableChatterboxVoices) ? window.availableChatterboxVoices : []; const REVIEW_VOICES_EVENT_NAME = window.VOICES_UPDATED_EVENT || 'voices:updated'; window.VOICES_UPDATED_EVENT = REVIEW_VOICES_EVENT_NAME; const REVIEW_CHATTERBOX_EVENT_NAME = window.CHATTERBOX_VOICES_EVENT || 'chatterboxVoices:updated'; window.CHATTERBOX_VOICES_EVENT = REVIEW_CHATTERBOX_EVENT_NAME; // Shared timing helpers — used by both queue job details and library metrics modal function fmtDuration(secs) { if (secs == null) return 'N/A'; const rounded = Math.round(secs); if (rounded < 60) return `${rounded}s`; const m = Math.floor(rounded / 60); const s = rounded % 60; return s > 0 ? `${m}m ${s}s` : `${m}m`; } function buildChunkChart(chunkTimes, chunkCount, totalSeconds) { let times = Array.isArray(chunkTimes) && chunkTimes.length > 0 ? chunkTimes : null; let estimated = false; if (!times && chunkCount > 0 && totalSeconds > 0) { const avg = totalSeconds / chunkCount; times = Array.from({length: chunkCount}, () => avg); estimated = true; } if (!times || times.length === 0) return ''; const MAX_POINTS = 60; const rawN = times.length; const bucketSize = rawN <= MAX_POINTS ? 1 : Math.ceil(rawN / MAX_POINTS); const downsampled = []; for (let b = 0; b < rawN; b += bucketSize) { const slice = times.slice(b, b + bucketSize); const avg = slice.reduce((s, v) => s + v, 0) / slice.length; downsampled.push({ avg, min: Math.min(...slice), max: Math.max(...slice), startChunk: b + 1, endChunk: Math.min(b + bucketSize, rawN), }); } const isDownsampled = bucketSize > 1; const n = downsampled.length; const W = 480, H = 90, padL = 38, padR = 10, padT = 8, padB = 24; function fmtT(t) { return t >= 60 ? `${Math.floor(t/60)}m${Math.round(t%60)>0?Math.round(t%60)+'s':''}` : `${Math.round(t)}s`; } const allMax = downsampled.map(d => d.max); const globalMax = Math.max(...allMax); const yMin = 0; const yMax = globalMax * 1.1 || 1; const range = yMax - yMin; function xPos(i) { return padL + (n === 1 ? (W - padL - padR) / 2 : i * (W - padL - padR) / (n - 1)); } function yPos(t) { return padT + (1 - (t - yMin) / range) * (H - padT - padB); } let rangeBand = ''; if (isDownsampled) { const topPts = downsampled.map((d, i) => `${xPos(i).toFixed(1)},${yPos(d.max).toFixed(1)}`).join(' '); const botPts = downsampled.slice().reverse().map((d, i) => `${xPos(n - 1 - i).toFixed(1)},${yPos(d.min).toFixed(1)}`).join(' '); rangeBand = ``; } const pts = downsampled.map((d, i) => `${xPos(i).toFixed(1)},${yPos(d.avg).toFixed(1)}`).join(' '); const areaD = `M${xPos(0).toFixed(1)},${(H - padB).toFixed(1)} ` + downsampled.map((d, i) => `L${xPos(i).toFixed(1)},${yPos(d.avg).toFixed(1)}`).join(' ') + ` L${xPos(n-1).toFixed(1)},${(H - padB).toFixed(1)} Z`; const dots = !isDownsampled ? downsampled.map((d, i) => `Chunk ${d.startChunk}: ${fmtT(d.avg)}`).join('') : downsampled.map((d, i) => `Chunks ${d.startChunk}–${d.endChunk}: avg ${fmtT(d.avg)}, min ${fmtT(d.min)}, max ${fmtT(d.max)}`).join(''); const MAX_XLABELS = 8; const xLabelStep = Math.max(1, Math.ceil(n / MAX_XLABELS)); const xLabels = downsampled.map((d, i) => { if (i % xLabelStep !== 0 && i !== n - 1) return ''; return `${d.startChunk}`; }).join(''); const yLabelMax = `${fmtT(yMax)}`; const yLabelMid = `${fmtT(yMax / 2)}`; const yLabelMin2 = `0s`; const midY = ((padT + H - padB) / 2).toFixed(1); const grid = ``; const downsampleNote = isDownsampled ? `
averaged over ${bucketSize}-chunk windows · shaded band = min/max range
` : (estimated ? `
* estimated (equal distribution)
` : ''); return `
Chunk Duration Over Time
${grid} ${rangeBand} ${dots} ${xLabels} ${yLabelMax} ${yLabelMid} ${yLabelMin2} ${downsampleNote}
`; } // Job Queue Management const QUEUE_REFRESH_INTERVAL_MS = 3000; let queueRefreshTimer = null; let queueTabButton = null; const openReviewPanels = new Set(); const reviewPanelContentCache = new Map(); const chunkPanelLoadingJobs = new Set(); const completedJobIds = new Set(); const reviewPanelScrollPositions = new Map(); let activeVoiceSelects = 0; const reviewPanelEditorState = new Map(); const activeEditingChunks = new Map(); const jobChunkMaps = new Map(); let activeChunkAudio = null; let activeChunkId = null; let activeChunkButton = null; const chunkRegenWatchers = new Map(); const CHUNK_REFRESH_POLL_INTERVAL_MS = 2000; const CHUNK_REFRESH_MAX_ATTEMPTS = 30; function cacheBustUrl(url, token = '') { if (!url) { return ''; } const trimmedToken = (token || '').toString().trim(); const cacheToken = trimmedToken || Date.now().toString(); const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}cb=${encodeURIComponent(cacheToken)}`; } document.addEventListener('DOMContentLoaded', () => { queueTabButton = document.querySelector('.tab-button[data-tab="queue"]'); initQueue(); ensureJobDetailModalHandlers(); if (queueTabButton) { queueTabButton.addEventListener('click', () => { loadQueue(); startQueueAutoRefresh(); }); } function getChunkTextForJob(jobId, chunkId) { const textarea = document.querySelector(`textarea[data-chunk-text="${chunkId}"]`); if (textarea) { return textarea.value.trim(); } const chunk = getChunkMetadata(jobId, chunkId); return (chunk?.text || '').trim(); } async function regenerateEntireJob(jobId, button) { if (!jobId) return; const entry = jobChunkMaps.get(jobId); if (!entry) { alert('Chunk metadata unavailable. Refresh the panel and try again.'); return; } if (!confirm('Re-render all chunks with current text and voice selections?')) { return; } button.disabled = true; const originalLabel = button.textContent; button.textContent = 'Queuing regen…'; try { const chunkEntries = []; entry.chunks.forEach((chunk, chunkId) => { const textValue = getChunkTextForJob(jobId, chunkId); if (!textValue) { throw new Error(`Chunk ${chunkId} text is empty. Update it before regenerating.`); } const voicePayload = getVoicePayloadForChunk(jobId, chunkId); const entryPayload = { chunk_id: chunkId, text: textValue, }; if (voicePayload && Object.keys(voicePayload).length > 0) { entryPayload.voice = voicePayload; } chunkEntries.push(entryPayload); }); const response = await fetch(`/api/jobs/${jobId}/review/regen-all`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunks: chunkEntries }), }); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to queue job regeneration'); } showReviewToast(`Queued regeneration for ${payload.queued_chunks || chunkEntries.length} chunks.`); renderReviewPanel(jobId, { silent: true }); } catch (error) { alert(error.message || 'Failed to queue job regeneration.'); } finally { button.disabled = false; button.textContent = originalLabel || 'Regenerate entire job'; } } document.querySelectorAll('.tab-button').forEach(btn => { if (btn.dataset.tab !== 'queue') { btn.addEventListener('click', stopQueueAutoRefresh); } }); document.addEventListener('visibilitychange', () => { if (document.hidden) { stopQueueAutoRefresh(); } else if (isQueueTabActive()) { startQueueAutoRefresh(); loadQueue(); } }); if (isQueueTabActive()) { startQueueAutoRefresh(); } }); function cachePendingTextEdits(jobId, remove = false) { if (!jobId) { return; } const panel = document.getElementById(`review-panel-${jobId}`); if (!panel) { return; } panel.querySelectorAll('textarea[data-chunk-text]').forEach(textarea => { const chunkId = textarea.dataset.chunkText; if (!chunkId) return; if (remove) { chunkPendingTextEdits.delete(chunkId); } else { chunkPendingTextEdits.set(chunkId, textarea.value); } }); } document.addEventListener('click', handleQueueActionClick); document.addEventListener('change', handleChunkVoiceSelectChange); document.addEventListener('focusin', handleVoiceSelectFocus, true); document.addEventListener('focusout', handleVoiceSelectBlur, true); window.addEventListener(REVIEW_VOICES_EVENT_NAME, handleGlobalVoicesUpdated, { passive: true }); window.addEventListener(REVIEW_CHATTERBOX_EVENT_NAME, handleGlobalVoicesUpdated, { passive: true }); function handleGlobalVoicesUpdated(event) { if (!event) { return; } if (event.type === REVIEW_VOICES_EVENT_NAME) { const detail = event.detail || {}; if (detail.voices) { availableVoicesCache = detail.voices; } if (detail.customVoiceMap) { customVoiceMapCache = detail.customVoiceMap; } } else if (event.type === REVIEW_CHATTERBOX_EVENT_NAME) { const voices = event.detail?.voices; availableChatterboxVoicesCache = Array.isArray(voices) ? voices : []; } openReviewPanels.forEach(jobId => { renderReviewPanel(jobId, { silent: true }); }); } const TURBO_ENGINES = new Set([ 'chatterbox_turbo_local', 'chatterbox_turbo_replicate', 'voxcpm_local', 'qwen3_clone' ]); const CHATTERBOX_ENGINES = new Set(['chatterbox', ...TURBO_ENGINES]); function normalizeEngineName(engine) { return (engine || '').toLowerCase(); } function isTurboEngine(engine) { return TURBO_ENGINES.has(normalizeEngineName(engine)); } function isChatterboxEngine(engine) { return CHATTERBOX_ENGINES.has(normalizeEngineName(engine)); } function chunkVoiceOverrideKey(jobId, chunkId) { return `${jobId || ''}::${chunkId || ''}`; } function getChunkVoiceOverride(jobId, chunkId) { return chunkVoiceOverrides.get(chunkVoiceOverrideKey(jobId, chunkId)) || null; } function setChunkVoiceOverride(jobId, chunkId, payload) { if (!jobId || !chunkId) { return; } const key = chunkVoiceOverrideKey(jobId, chunkId); if (payload && Object.keys(payload).length > 0) { chunkVoiceOverrides.set(key, payload); } else { chunkVoiceOverrides.delete(key); } } function clearVoiceOverridesForJob(jobId) { const prefix = `${jobId || ''}::`; Array.from(chunkVoiceOverrides.keys()).forEach(key => { if (key.startsWith(prefix)) { chunkVoiceOverrides.delete(key); } }); } function pruneVoiceOverrides(activeJobIds = []) { const jobSet = new Set( Array.isArray(activeJobIds) ? activeJobIds.filter(Boolean) : Array.from(activeJobIds || []).filter(Boolean), ); if (jobSet.size === 0) { chunkVoiceOverrides.clear(); return; } Array.from(chunkVoiceOverrides.keys()).forEach(key => { const jobId = key.split('::', 1)[0]; if (!jobSet.has(jobId)) { chunkVoiceOverrides.delete(key); } }); } function chunkRegenWatcherKey(jobId, chunkId) { return `${jobId || ''}::${chunkId || ''}`; } function clearChunkRegenWatcher(jobId, chunkId) { const key = chunkRegenWatcherKey(jobId, chunkId); const entry = chunkRegenWatchers.get(key); if (entry?.timer) { clearTimeout(entry.timer); } chunkRegenWatchers.delete(key); } function clearChunkRegenWatchersForJob(jobId) { const prefix = `${jobId || ''}::`; Array.from(chunkRegenWatchers.keys()).forEach(key => { if (!jobId || key.startsWith(prefix)) { const entry = chunkRegenWatchers.get(key); if (entry?.timer) { clearTimeout(entry.timer); } chunkRegenWatchers.delete(key); } }); } function syncChunkRegenWatchersFromPayload(jobId, payload = {}) { const regenTasks = payload.regen_tasks || {}; const activeChunkIds = new Set(); Object.entries(regenTasks).forEach(([chunkId, task]) => { const status = (task || {}).status; if (status === 'queued' || status === 'running') { activeChunkIds.add(chunkId); startChunkRegenWatcher(jobId, chunkId); } else { clearChunkRegenWatcher(jobId, chunkId); } }); const prefix = `${jobId || ''}::`; Array.from(chunkRegenWatchers.keys()).forEach(key => { if (!key.startsWith(prefix)) { return; } const chunkId = key.slice(prefix.length); if (chunkId && !activeChunkIds.has(chunkId)) { clearChunkRegenWatcher(jobId, chunkId); } }); } async function fetchJobChunkPayload(jobId) { const response = await fetch(`/api/jobs/${jobId}/chunks`); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to load chunk metadata'); } return payload; } function applyChunkPayloadToPanel(jobId, payload, chunkId = null) { if (!payload) { return; } cachePanelEditorState(jobId); cacheJobChunkMap(jobId, payload); reviewPanelContentCache.set(jobId, payload); replaceReviewPanelHeader(jobId, payload); if (chunkId) { const chunk = (payload.chunks || []).find(item => item?.id === chunkId); if (chunk) { const regenState = (payload.regen_tasks || {})[chunkId]; const engine = (payload.review?.engine || '').toLowerCase(); replaceChunkRow(jobId, chunk, regenState, engine); } } restorePanelEditorState(jobId); syncChunkRegenWatchersFromPayload(jobId, payload); syncActiveChunkControls(); } function replaceReviewPanelHeader(jobId, payload) { const panel = document.getElementById(`review-panel-${jobId}`); if (!panel) { return; } const headerEl = panel.querySelector('.review-panel-header'); if (!headerEl) { return; } const template = document.createElement('div'); template.innerHTML = renderReviewPanelHeader(jobId, payload).trim(); const newHeader = template.firstElementChild; if (newHeader) { headerEl.replaceWith(newHeader); } } function replaceChunkRow(jobId, chunk, regenState, engine) { if (!chunk?.id) { return; } const panel = document.getElementById(`review-panel-${jobId}`); if (!panel) { return; } const row = panel.querySelector(`.chunk-row[data-chunk-id="${chunk.id}"]`); if (!row) { return; } const wrapper = document.createElement('div'); wrapper.innerHTML = renderChunkRow(jobId, chunk, regenState, engine).trim(); const nextRow = wrapper.firstElementChild; if (nextRow) { row.replaceWith(nextRow); } } function startChunkRegenWatcher(jobId, chunkId) { if (!jobId || !chunkId) { return; } if (!openReviewPanels.has(jobId)) { return; } const watcherKey = chunkRegenWatcherKey(jobId, chunkId); // Don't start a new watcher if one already exists if (chunkRegenWatchers.has(watcherKey)) { return; } const entry = { attempts: 0, timer: null }; chunkRegenWatchers.set(watcherKey, entry); pollChunkRegenStatus(jobId, chunkId, entry); } async function pollChunkRegenStatus(jobId, chunkId, entry) { const watcherKey = chunkRegenWatcherKey(jobId, chunkId); if (!chunkRegenWatchers.has(watcherKey)) { return; } if (!openReviewPanels.has(jobId)) { clearChunkRegenWatcher(jobId, chunkId); return; } entry.attempts += 1; try { const payload = await fetchJobChunkPayload(jobId); applyChunkPayloadToPanel(jobId, payload, chunkId); const status = payload?.regen_tasks?.[chunkId]?.status; const isComplete = !status || status === 'completed' || status === 'failed'; if (isComplete || entry.attempts >= CHUNK_REFRESH_MAX_ATTEMPTS) { clearChunkRegenWatcher(jobId, chunkId); return; } } catch (error) { console.error(`Failed to refresh chunk ${chunkId} regen status`, error); if (entry.attempts >= CHUNK_REFRESH_MAX_ATTEMPTS) { clearChunkRegenWatcher(jobId, chunkId); return; } } entry.timer = setTimeout(() => pollChunkRegenStatus(jobId, chunkId, entry), CHUNK_REFRESH_POLL_INTERVAL_MS); } function cloneVoiceAssignment(assignment) { if (!assignment || typeof assignment !== 'object') { return {}; } try { return JSON.parse(JSON.stringify(assignment)); } catch (error) { console.warn('Failed to clone voice assignment', error); return { ...assignment }; } } function cacheJobChunkMap(jobId, payload) { const chunkMap = new Map(); (payload.chunks || []).forEach(chunk => { if (chunk?.id) { chunkMap.set(chunk.id, chunk); } }); jobChunkMaps.set(jobId, { engine: normalizeEngineName(payload.review?.engine), chunks: chunkMap, }); } function getChunkMetadata(jobId, chunkId) { const entry = jobChunkMaps.get(jobId); if (!entry) return null; return entry.chunks.get(chunkId) || null; } function resolveLangCodeForVoice(voiceName) { if (!voiceName) { return 'a'; } const customEntry = customVoiceMapCache?.[voiceName]; if (customEntry?.lang_code) { return customEntry.lang_code; } const voices = availableVoicesCache; if (!voices || typeof voices !== 'object') { return 'a'; } for (const config of Object.values(voices)) { if (!config) continue; if (Array.isArray(config.voices) && config.voices.includes(voiceName)) { return config.lang_code || 'a'; } const customVoices = config.custom_voices || []; const match = customVoices.find(entry => entry?.code === voiceName); if (match?.lang_code) { return match.lang_code; } } return 'a'; } function resolveChatterboxVoiceName(promptPath) { if (!promptPath) return ''; const normalized = promptPath.trim(); if (!normalized) return ''; const entry = availableChatterboxVoicesCache.find(item => { const path = (item?.prompt_path || item?.file_name || '').trim(); return path === normalized; }); if (!entry) { const basename = normalized.split(/[\\/]/).pop(); return basename || normalized; } return entry.name || entry.prompt_path || entry.file_name || normalized; } function escapeHtml(value = '') { const div = document.createElement('div'); div.textContent = value; return div.innerHTML; } function getKokoroVoiceSelection(jobId, chunk) { const override = getChunkVoiceOverride(jobId, chunk.id); if (override?.voice) { return override.voice; } const assignment = chunk.voice_assignment || {}; if (assignment.voice) { return assignment.voice; } return ''; } function getChatterboxPromptSelection(jobId, chunk) { const override = getChunkVoiceOverride(jobId, chunk.id); if (override?.audio_prompt_path) { return override.audio_prompt_path; } const assignment = chunk.voice_assignment || {}; if (assignment.audio_prompt_path) { return assignment.audio_prompt_path; } return ''; } function buildKokoroVoiceOptions(selectedVoice) { if (!availableVoicesCache) { return ''; } let html = ''; Object.entries(availableVoicesCache).forEach(([key, config]) => { if (!config) { return; } const label = config.language || key || 'Voices'; html += ``; (config.voices || []).forEach(voiceName => { if (!voiceName) return; const selected = voiceName === selectedVoice ? 'selected' : ''; html += ``; }); const customVoices = config.custom_voices || []; customVoices.forEach(entry => { if (!entry?.code) return; const selected = entry.code === selectedVoice ? 'selected' : ''; const title = entry.name || entry.code; html += ``; }); html += ''; }); return html; } function buildChatterboxVoiceOptions(selectedPrompt) { if (!availableChatterboxVoicesCache.length) { return ''; } // Sort voices alphabetically by name const sortedVoices = [...availableChatterboxVoicesCache].sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()) ); return sortedVoices .map(entry => { const promptPath = (entry?.prompt_path || entry?.file_name || '').trim(); if (!promptPath) { return ''; } const selected = promptPath === selectedPrompt ? 'selected' : ''; const durationLabel = entry?.duration_seconds ? ` · ${entry.duration_seconds.toFixed(2)}s` : ''; const label = `${entry?.name || promptPath}${durationLabel}`; return ``; }) .join(''); } function getChunkVoiceLabel(jobId, chunk, engine) { const normalizedEngine = normalizeEngineName(engine); if (isChatterboxEngine(normalizedEngine)) { const promptPath = getChatterboxPromptSelection(jobId, chunk); if (promptPath) { return resolveChatterboxVoiceName(promptPath); } const overrideVoice = getChunkVoiceOverride(jobId, chunk.id)?.voice; if (overrideVoice) { return overrideVoice; } const assignmentVoice = chunk.voice_assignment?.voice; if (assignmentVoice) { return assignmentVoice; } } else { const voiceName = getKokoroVoiceSelection(jobId, chunk); if (voiceName) { return voiceName; } } return chunk.voice || 'Job default'; } function renderChunkVoiceLabel(jobId, chunk, engine) { const label = getChunkVoiceLabel(jobId, chunk, engine); if (!label) { return ''; } return `${escapeHtml(label)}`; } function renderKokoroVoiceControl(jobId, chunk, engine) { const selected = getKokoroVoiceSelection(jobId, chunk); const placeholder = chunk.voice ? `Use ${chunk.voice}` : 'Use job default'; const disabled = !availableVoicesCache; const optionMarkup = availableVoicesCache ? buildKokoroVoiceOptions(selected) : ''; return `
`; } function renderChatterboxVoiceControl(jobId, chunk, engine) { const selectedPrompt = getChatterboxPromptSelection(jobId, chunk); const placeholder = selectedPrompt ? `Use ${resolveChatterboxVoiceName(selectedPrompt)}` : 'Use job default prompt'; const hasVoices = availableChatterboxVoicesCache.length > 0; const optionMarkup = hasVoices ? buildChatterboxVoiceOptions(selectedPrompt) : ''; const labelText = isTurboEngine(engine) ? 'Turbo reference prompt' : 'Chatterbox voice prompt'; return `
`; } function renderChunkVoiceControl(jobId, chunk, engine) { const normalizedEngine = normalizeEngineName(engine); if (!chunk?.id || !normalizedEngine) { return ''; } if (isChatterboxEngine(normalizedEngine)) { return renderChatterboxVoiceControl(jobId, chunk, normalizedEngine); } return renderKokoroVoiceControl(jobId, chunk, normalizedEngine); } function updateChunkVoiceLabelDisplay(chunkId, label) { const el = document.querySelector(`[data-chunk-voice-label="${chunkId}"]`); if (el) { el.textContent = label || 'Job default'; } } function handleChunkVoiceSelectChange(event) { const select = event.target.closest('[data-chunk-voice-select="true"]'); if (!select) { return; } const jobId = select.dataset.jobId; const chunkId = select.dataset.chunkId; const engine = normalizeEngineName(select.dataset.engine); if (!jobId || !chunkId) { return; } const payload = buildPayloadFromVoiceSelect(select); setChunkVoiceOverride(jobId, chunkId, payload); const label = payload ? isChatterboxEngine(engine) ? resolveChatterboxVoiceName(payload.audio_prompt_path) : payload.voice : getChunkVoiceLabel(jobId, getChunkMetadata(jobId, chunkId) || {}, engine); updateChunkVoiceLabelDisplay(chunkId, label); stopQueueAutoRefresh(); } function buildPayloadFromVoiceSelect(select) { if (!select) { return null; } const engine = normalizeEngineName(select.dataset.engine); const rawValue = (select.value || '').trim(); if (!rawValue) { return null; } if (isChatterboxEngine(engine)) { return { audio_prompt_path: rawValue }; } return { voice: rawValue, lang_code: resolveLangCodeForVoice(rawValue), }; } function handleVoiceSelectFocus(event) { const select = event.target?.closest?.('[data-chunk-voice-select="true"]'); if (!select) { return; } activeVoiceSelects += 1; stopQueueAutoRefresh(); } function handleVoiceSelectBlur(event) { const select = event.target?.closest?.('[data-chunk-voice-select="true"]'); if (!select) { return; } activeVoiceSelects = Math.max(0, activeVoiceSelects - 1); resumeQueueAutoRefreshIfIdle(); } function resumeQueueAutoRefreshIfIdle() { if (activeEditingChunks.size === 0 && activeVoiceSelects === 0 && isQueueTabActive()) { startQueueAutoRefresh(); } } function initQueue() { const refreshBtn = document.getElementById('refresh-queue-btn'); if (refreshBtn) { refreshBtn.addEventListener('click', loadQueue); } const clearQueueBtn = document.getElementById('clear-queue-btn'); if (clearQueueBtn) { clearQueueBtn.addEventListener('click', async () => { if (!confirm('Clear all jobs from the queue? Jobs that are currently processing will be skipped. This cannot be undone.')) { return; } clearQueueBtn.disabled = true; clearQueueBtn.textContent = 'Clearing…'; try { const response = await fetch('/api/jobs/clear-all', { method: 'DELETE' }); const data = await response.json(); if (data.success) { const parts = [`Removed ${data.removed} job${data.removed !== 1 ? 's' : ''}`]; if (data.skipped > 0) parts.push(`${data.skipped} skipped (processing)`); if (data.errors > 0) parts.push(`${data.errors} failed`); alert(parts.join(', ') + '.'); } else { alert('Failed to clear queue: ' + (data.error || 'Unknown error')); } } catch (err) { alert('Failed to clear queue: ' + (err.message || 'Network error')); } finally { clearQueueBtn.disabled = false; clearQueueBtn.textContent = 'Clear Queue'; loadQueue(); } }); } loadQueue(); } function isQueueTabActive() { const queueTab = document.getElementById('queue-tab'); return queueTab && queueTab.classList.contains('active'); } function startQueueAutoRefresh() { if (activeEditingChunks.size > 0 || activeVoiceSelects > 0) { return; } if (queueRefreshTimer) { return; } queueRefreshTimer = setInterval(loadQueue, QUEUE_REFRESH_INTERVAL_MS); } function stopQueueAutoRefresh() { if (queueRefreshTimer) { clearInterval(queueRefreshTimer); queueRefreshTimer = null; } } async function loadQueue() { try { const response = await fetch('/api/queue'); const data = await response.json(); if (data.success) { displayQueue(data); } else { console.error('Error loading queue:', data.error); } } catch (error) { console.error('Error loading queue:', error); } } function displayQueue(data) { const container = document.getElementById('queue-list'); if (!container) return; if (!data.jobs || data.jobs.length === 0) { container.innerHTML = '

No jobs in queue

'; return; } let hasNewCompletion = false; data.jobs.forEach(job => { if (job.status === 'completed' && !completedJobIds.has(job.job_id)) { completedJobIds.add(job.job_id); hasNewCompletion = true; } }); if (hasNewCompletion) { document.dispatchEvent(new CustomEvent('library:refresh')); } let html = `
Queue Size: ${data.queue_size} pending | Current Job: ${data.current_job || 'None'}
`; data.jobs.forEach(job => { const statusClass = getStatusClass(job.status); const statusIcon = getStatusIcon(job.status); const createdTime = job.created_at ? new Date(job.created_at).toLocaleString() : ''; const isCurrentJob = job.job_id === data.current_job; const canPause = job.status === 'queued' || job.status === 'processing'; const canResume = job.status === 'paused' || job.status === 'interrupted'; const canDelete = job.status !== 'processing' && job.status !== 'pausing'; html += ` `; }); html += `
Status Job ID Progress Text Preview Created Actions
${statusIcon} ${job.status} ${job.job_id.substring(0, 8)} ${renderJobProgress(job)} ${job.text_preview || 'N/A'} ${createdTime} ${canPause ? `` : ''} ${canResume ? `` : ''} ${job.status === 'completed' ? `` : ''} ${(job.status === 'queued' || job.status === 'processing') ? `` : ''} ${canDelete ? `` : ''} ${job.status === 'failed' ? `Failed` : ''}
`; container.innerHTML = html; } function getStatusClass(status) { switch (status) { case 'queued': return 'status-queued'; case 'processing': return 'status-processing'; case 'pausing': return 'status-pausing'; case 'paused': return 'status-paused'; case 'completed': return 'status-completed'; case 'interrupted': return 'status-interrupted'; case 'failed': return 'status-failed'; case 'cancelled': return 'status-cancelled'; default: return ''; } } function getStatusIcon(status) { switch (status) { case 'queued': return ''; case 'processing': return ''; case 'pausing': return ''; case 'paused': return ''; case 'completed': return ''; case 'interrupted': return ''; case 'failed': return ''; case 'cancelled': return ''; default: return ''; } } function ensureJobDetailModalHandlers() { const overlay = document.getElementById('job-detail-modal-overlay'); const closeBtn = document.getElementById('job-detail-modal-close'); const footerBtn = document.getElementById('job-detail-modal-close-btn'); if (overlay && !overlay.dataset.bound) { overlay.addEventListener('click', (event) => { if (event.target === overlay) { closeJobDetailModal(); } }); overlay.dataset.bound = 'true'; } if (closeBtn && !closeBtn.dataset.bound) { closeBtn.addEventListener('click', closeJobDetailModal); closeBtn.dataset.bound = 'true'; } if (footerBtn && !footerBtn.dataset.bound) { footerBtn.addEventListener('click', closeJobDetailModal); footerBtn.dataset.bound = 'true'; } } function closeJobDetailModal() { const overlay = document.getElementById('job-detail-modal-overlay'); const modal = document.getElementById('job-detail-modal'); if (overlay) overlay.classList.add('hidden'); if (modal) modal.classList.add('hidden'); } async function openJobDetailsModal(jobId) { if (!jobId) return; const overlay = document.getElementById('job-detail-modal-overlay'); const modal = document.getElementById('job-detail-modal'); const body = document.getElementById('job-detail-modal-body'); if (overlay) overlay.classList.remove('hidden'); if (modal) modal.classList.remove('hidden'); if (body) body.innerHTML = '
Loading job details...
'; try { const response = await fetch(`/api/jobs/${jobId}/details`); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to load job details'); } const job = payload.job || {}; const speakers = Array.isArray(job.speakers) ? job.speakers : []; const speakerList = speakers.length ? speakers.map(s => `${s}`).join('') : 'default'; const text = (job.text || '').trim(); const resumeFrom = (job.status !== 'completed' && Number.isFinite(Number(job.resume_from_chunk_index)) && Number(job.resume_from_chunk_index) > 0) ? Number(job.resume_from_chunk_index) + 1 : null; const resumeLine = resumeFrom ? `
Resume from: chunk ${resumeFrom}
` : ''; // Timing metrics const tm = job.timing_metrics || {}; function fmtTime(iso) { if (!iso) return 'N/A'; return new Date(iso).toLocaleString(); } const isLive = ['processing', 'pausing'].includes(job.status); const startedAt = fmtTime(job.started_at || tm.started_at); const completedAt = (job.completed_at || tm.completed_at) ? fmtTime(job.completed_at || tm.completed_at) : (isLive ? 'In progress…' : 'N/A'); const totalTimeLabel = (job.completed_at || tm.completed_at) ? 'Total Job Time' : 'Elapsed Time'; const totalTime = fmtDuration(tm.total_seconds); const avgChunk = tm.avg_chunk_seconds != null ? fmtDuration(tm.avg_chunk_seconds) : 'N/A'; const minChunk = tm.min_chunk_seconds != null ? fmtDuration(tm.min_chunk_seconds) : 'N/A'; const maxChunk = tm.max_chunk_seconds != null ? fmtDuration(tm.max_chunk_seconds) : 'N/A'; const chunkCount = job.total_chunks != null ? job.total_chunks : (tm.chunk_count != null ? tm.chunk_count : 'N/A'); const processedChunks = job.processed_chunks != null ? job.processed_chunks : 'N/A'; const liveBadge = isLive ? 'live' : ''; const hasTimingData = tm.total_seconds != null; const chunkChart = hasTimingData ? buildChunkChart(tm.chunk_times, tm.chunk_count || chunkCount, tm.total_seconds) : ''; const timingSection = hasTimingData ? `

Timing${liveBadge}

Started${startedAt}
Completed${completedAt}
${totalTimeLabel}${totalTime}
Chunks${processedChunks} / ${chunkCount}
Avg Chunk Time${avgChunk}
Fastest Chunk${minChunk}
Slowest Chunk${maxChunk}
${chunkChart}
` : (job.started_at ? `

Timing

Started${startedAt}
Chunks${processedChunks} / ${chunkCount}
` : ''); if (body) { body.innerHTML = `
Engine: ${job.engine || 'Unknown'}
Status: ${job.status || 'Unknown'}
Created: ${job.created_at ? new Date(job.created_at).toLocaleString() : 'N/A'}
${resumeLine}
Speakers:
${speakerList}
${timingSection}
`; } } catch (error) { if (body) body.innerHTML = '
Failed to load job details.
'; alert(error.message || 'Failed to load job details'); } } async function pauseQueueJob(jobId) { if (!jobId) return; try { const response = await fetch(`/api/jobs/${jobId}/pause`, { method: 'POST' }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to pause job'); } loadQueue(); } catch (error) { alert(error.message || 'Failed to pause job'); } } async function resumeQueueJob(jobId) { if (!jobId) return; try { const response = await fetch(`/api/jobs/${jobId}/resume`, { method: 'POST' }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to resume job'); } loadQueue(); } catch (error) { alert(error.message || 'Failed to resume job'); } } async function deleteQueueJob(jobId) { if (!jobId) return; if (!confirm('Remove this job from the queue? (Audio files will be kept)')) { return; } try { const response = await fetch(`/api/jobs/${jobId}/delete`, { method: 'DELETE' }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to delete job'); } completedJobIds.delete(jobId); loadQueue(); document.dispatchEvent(new CustomEvent('library:refresh')); } catch (error) { alert(error.message || 'Failed to delete job'); } } function renderJobProgress(job) { const total = job.total_chunks || 0; const processed = Math.min(job.processed_chunks || 0, total || Infinity); const percent = total > 0 ? Math.round((processed / total) * 100) : (job.status === 'completed' ? 100 : 0); const chunkLabel = total ? `${processed} / ${total} chunk${total === 1 ? '' : 's'}` : 'Estimating…'; const etaLabel = formatEta(job.eta_seconds, job.status); const chapterLabel = job.chapter_mode ? `${job.chapter_count || '?'} chapter${(job.chapter_count || 0) === 1 ? '' : 's'} (per chapter merge)` : 'Single output file'; const postTotal = Number(job.post_process_total || 0); const postDone = Math.min(Number(job.post_process_done || 0), postTotal || Infinity); const postPercent = Number.isFinite(Number(job.post_process_percent)) ? Math.max(0, Math.min(Math.round(Number(job.post_process_percent)), 100)) : (postTotal > 0 ? Math.round((postDone / postTotal) * 100) : 0); const isFinishing = job.status === 'processing' && total > 0 && processed >= total && (job.eta_seconds === 0 || job.eta_seconds === null || typeof job.eta_seconds !== 'number'); const showPost = (postTotal > 0 && (job.post_process_active || postDone > 0)) || isFinishing; const postLabel = postTotal > 0 ? `Post-processing ${postDone} / ${postTotal}` : 'Post-processing…'; const postFillClass = postTotal > 0 ? 'progress-bar-fill' : 'progress-bar-fill indeterminate'; const interruptedChip = job.status === 'interrupted' ? 'Interrupted' : ''; const resumeHint = job.status === 'interrupted' && Number.isFinite(Number(job.resume_from_chunk_index)) ? `Resume from chunk ${Number(job.resume_from_chunk_index) + 1}` : ''; return `
${chunkLabel} ${etaLabel}
${interruptedChip || resumeHint ? ` ` : ''} ${showPost ? `
${postLabel} ${postPercent}%
` : ''}
`; } function formatEta(seconds, status) { if (status === 'completed') { return 'Done'; } if (seconds === 0) { return 'Finishing up…'; } if (typeof seconds !== 'number' || seconds < 0 || Number.isNaN(seconds)) { return 'Calculating…'; } const minutes = Math.floor(seconds / 60); const secs = Math.max(seconds % 60, 0); if (minutes > 0) { return `ETA ${minutes}m ${secs.toFixed(0)}s`; } return `ETA ${secs.toFixed(0)}s`; } async function cancelQueueJob(jobId) { if (!confirm('Are you sure you want to cancel this job?')) { return; } try { const response = await fetch(`/api/cancel/${jobId}`, { method: 'POST' }); const data = await response.json(); if (data.success) { loadQueue(); } else { alert('Failed to cancel job: ' + data.error); } } catch (error) { console.error('Error cancelling job:', error); alert('Error cancelling job'); } } function downloadJobAudio(jobId) { window.location.href = `/api/download/${jobId}`; } function renderReviewCell(job) { if (!job.review_mode) { return 'N/A'; } const hasActiveRegen = Boolean(job.review_has_active_regen); const isOpen = openReviewPanels.has(job.job_id); const reviewState = (() => { switch (job.status) { case 'processing': return 'Live chunks'; case 'waiting_review': return 'Waiting for finish'; case 'completed': return 'Completed'; default: return job.status; } })(); return `
${reviewState} ${hasActiveRegen ? 'Regen running' : ''}
${job.status === 'waiting_review' ? ` ` : ''}
`; } function handleQueueActionClick(event) { const actionButton = event.target.closest('[data-action]'); if (!actionButton) { return; } const action = actionButton.dataset.action; const jobId = actionButton.dataset.jobId; const chunkId = actionButton.dataset.chunkId; switch (action) { case 'toggle-review': toggleReviewPanel(jobId); break; case 'finish-review': finishReviewJob(jobId, actionButton); break; case 'chunk-play': handleChunkPlayback(actionButton); break; case 'chunk-edit': toggleChunkEditing(actionButton); break; case 'chunk-regenerate': triggerChunkRegeneration(jobId, chunkId, actionButton); break; case 'refresh-chunks': renderReviewPanel(jobId); break; case 'regen-job': regenerateEntireJob(jobId, actionButton); break; default: break; } } function toggleReviewPanel(jobId) { if (!jobId) return; const row = document.querySelector(`.review-panel-row[data-review-row="${jobId}"]`); const panel = document.getElementById(`review-panel-${jobId}`); if (!row || !panel) { openReviewPanels.delete(jobId); return; } const isOpening = !openReviewPanels.has(jobId); if (isOpening) { openReviewPanels.add(jobId); row.classList.add('open'); panel.classList.add('open'); renderReviewPanel(jobId); } else { openReviewPanels.delete(jobId); reviewPanelScrollPositions.delete(jobId); clearPanelEditorState(jobId); clearEditingStateForJob(jobId); jobChunkMaps.delete(jobId); row.classList.remove('open'); panel.classList.remove('open'); panel.innerHTML = '
Review panel collapsed
'; } updateReviewToggleButtons(jobId); } function setRefreshButtonState(jobId, isLoading) { document.querySelectorAll(`[data-action="refresh-chunks"][data-job-id="${jobId}"]`).forEach(button => { button.disabled = isLoading; button.textContent = isLoading ? 'Refreshing…' : 'Refresh chunks'; }); } function updateRefreshButtonsForOpenPanels() { openReviewPanels.forEach(jobId => { setRefreshButtonState(jobId, chunkPanelLoadingJobs.has(jobId)); }); } function cacheOpenPanelScrollPositions() { openReviewPanels.forEach(jobId => { const body = document.querySelector(`#review-panel-${jobId} .review-panel-body`); if (body) { reviewPanelScrollPositions.set(jobId, body.scrollTop); } }); } function restoreOpenPanelScrollPositions() { openReviewPanels.forEach(jobId => { restorePanelScroll(jobId); }); } function hookOpenPanelScrollTracking() { openReviewPanels.forEach(jobId => { attachPanelScrollTracking(jobId); }); } function restorePanelScroll(jobId) { const scrollTop = reviewPanelScrollPositions.get(jobId); if (scrollTop == null) { return; } const body = document.querySelector(`#review-panel-${jobId} .review-panel-body`); if (body) { body.scrollTop = scrollTop; } } function attachPanelScrollTracking(jobId) { const body = document.querySelector(`#review-panel-${jobId} .review-panel-body`); if (!body || body.dataset.scrollTrackingAttached === 'true') { return; } const handler = () => { reviewPanelScrollPositions.set(jobId, body.scrollTop); }; body.addEventListener('scroll', handler, { passive: true }); body.dataset.scrollTrackingAttached = 'true'; } function afterPanelRender(jobId) { restorePanelScroll(jobId); attachPanelScrollTracking(jobId); restorePanelEditorState(jobId); syncActiveChunkControls(); } async function renderReviewPanel(jobId, options = {}) { const panel = document.getElementById(`review-panel-${jobId}`); if (!panel) { openReviewPanels.delete(jobId); return; } if (chunkPanelLoadingJobs.has(jobId)) { return; } cachePanelEditorState(jobId); const { silent = false } = options; chunkPanelLoadingJobs.add(jobId); setRefreshButtonState(jobId, true); if (!silent) { panel.innerHTML = '
Loading chunk data…
'; } try { const response = await fetch(`/api/jobs/${jobId}/chunks`); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to load chunk metadata'); } cacheJobChunkMap(jobId, payload); reviewPanelContentCache.set(jobId, payload); const latestPanel = document.getElementById(`review-panel-${jobId}`); if (latestPanel) { latestPanel.innerHTML = renderChunkTable(jobId, payload); afterPanelRender(jobId); syncChunkRegenWatchersFromPayload(jobId, payload); } } catch (error) { console.error('Failed to load chunk data', error); const latestPanel = document.getElementById(`review-panel-${jobId}`); if (latestPanel) { latestPanel.innerHTML = `
${error.message || 'Failed to load chunk metadata'}
`; } } finally { chunkPanelLoadingJobs.delete(jobId); setRefreshButtonState(jobId, false); } } function renderChunkTable(jobId, payload) { const chunks = payload.chunks || []; const regenTasks = payload.regen_tasks || {}; const reviewInfo = payload.review || {}; const hasChunks = chunks.length > 0; const canFinish = reviewInfo.status === 'waiting_review' && !reviewInfo.has_active_regen; const engine = (reviewInfo.engine || '').toLowerCase(); const header = renderReviewPanelHeader(jobId, payload, { hasChunks, canFinish }); const body = hasChunks ? `
${chunks.map(chunk => renderChunkRow(jobId, chunk, regenTasks[chunk.id], engine)).join('')}
` : `
No chunks available yet. Keep this panel open to watch them appear in real time.
`; return ` ${header} ${body} `; } function renderReviewPanelHeader(jobId, payload, overrides = {}) { const chunks = payload.chunks || []; const reviewInfo = payload.review || {}; const hasChunks = overrides.hasChunks != null ? overrides.hasChunks : chunks.length > 0; const canFinishOverride = overrides.canFinish; const canFinish = typeof canFinishOverride === 'boolean' ? canFinishOverride : reviewInfo.status === 'waiting_review' && !reviewInfo.has_active_regen; const isRefreshing = chunkPanelLoadingJobs.has(jobId); return `
Chunks ready: ${chunks.length} ${reviewInfo.has_active_regen ? 'Regen running' : ''}
`; } function renderChunkRow(jobId, chunk, regenState = {}, engine = '') { const isEditing = false; const playLabel = activeChunkId === chunk.id ? 'Stop' : 'Play'; const regenStatusLabel = renderRegenStatus(regenState); const durationLabel = chunk.duration_seconds ? `${chunk.duration_seconds.toFixed(1)}s` : '—'; const chunkLabel = `Chunk ${chunk.order_index + 1}`; const chapterLabel = chunk.chapter_index != null ? `Chapter ${chunk.chapter_index + 1}` : 'Single output'; const voiceLabelTag = renderChunkVoiceLabel(jobId, chunk, engine); const voiceControl = renderChunkVoiceControl(jobId, chunk, engine); const cacheToken = chunk.cache_bust_token || chunk.regenerated_at || chunk.relative_file || Date.now().toString(); const audioUrl = chunk.file_url ? cacheBustUrl(chunk.file_url, cacheToken) : ''; return `
${chunkLabel}${chapterLabel}
${chunk.speaker ? `${chunk.speaker}` : ''} ${durationLabel} ${regenStatusLabel} ${voiceLabelTag}
${voiceControl}
`; } function renderRegenStatus(state = {}) { const status = state.status; if (!status) { return ''; } const label = (() => { switch (status) { case 'queued': return 'Queued'; case 'running': return 'Rendering'; case 'completed': return 'Updated'; case 'failed': return 'Failed'; default: return status; } })(); const tone = status === 'failed' ? 'danger' : status === 'completed' ? 'success' : 'warning'; return `${label}`; } function handleChunkPlayback(button) { const chunkId = button.dataset.chunkId; const audioUrl = button.dataset.audioUrl; if (!chunkId || !audioUrl) { alert('Chunk audio not ready yet.'); return; } if (activeChunkId === chunkId) { stopActiveChunk(); return; } stopActiveChunk(); activeChunkAudio = new Audio(audioUrl); activeChunkId = chunkId; activeChunkButton = button; button.textContent = 'Stop'; activeChunkAudio.play().catch(err => { console.error('Failed to play chunk audio', err); stopActiveChunk(); }); activeChunkAudio.addEventListener('ended', stopActiveChunk); } function stopActiveChunk() { if (activeChunkAudio) { activeChunkAudio.pause(); activeChunkAudio.currentTime = 0; } if (activeChunkButton && document.body.contains(activeChunkButton)) { activeChunkButton.textContent = 'Play'; } activeChunkAudio = null; activeChunkButton = null; activeChunkId = null; } function toggleChunkEditing(button) { const chunkId = button.dataset.chunkId; const jobId = button.dataset.jobId; const textarea = document.querySelector(`textarea[data-chunk-text="${chunkId}"]`); if (!textarea) { return; } const isDisabled = textarea.disabled; if (isDisabled) { textarea.disabled = false; textarea.focus(); button.textContent = 'Lock text'; activeEditingChunks.set(chunkId, jobId); stopQueueAutoRefresh(); } else { textarea.disabled = true; button.textContent = 'Edit text'; activeEditingChunks.delete(chunkId); resumeQueueAutoRefreshIfIdle(); } const selectionEnd = textarea.value.length; textarea.setSelectionRange(selectionEnd, selectionEnd); cachePanelEditorState(jobId); } async function triggerChunkRegeneration(jobId, chunkId, button) { if (!jobId || !chunkId) return; const textarea = document.querySelector(`textarea[data-chunk-text="${chunkId}"]`); if (!textarea) { return; } const updatedText = textarea.value.trim(); if (!updatedText) { alert('Chunk text cannot be empty.'); return; } button.disabled = true; button.textContent = 'Re-generating…'; try { const response = await fetch(`/api/jobs/${jobId}/review/regen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk_id: chunkId, text: updatedText, voice: getVoicePayloadForChunk(jobId, chunkId), }), }); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to queue regeneration'); } showReviewToast('Chunk regeneration queued.'); renderReviewPanel(jobId, { silent: true }); startChunkRegenWatcher(jobId, chunkId); } catch (error) { alert(error.message || 'Failed to queue chunk regeneration.'); } finally { button.disabled = false; button.textContent = 'Re-generate chunk'; } } function getVoicePayloadForChunk(jobId, chunkId) { const override = getChunkVoiceOverride(jobId, chunkId); let payload = override && Object.keys(override).length ? cloneVoiceAssignment(override) : null; if (!payload) { const select = document.querySelector( `[data-chunk-voice-select="true"][data-job-id="${jobId}"][data-chunk-id="${chunkId}"]` ); payload = buildPayloadFromVoiceSelect(select); } if (!payload) { return null; } if (payload.voice && !payload.lang_code) { payload.lang_code = resolveLangCodeForVoice(payload.voice); } return payload; } async function finishReviewJob(jobId, button) { if (!jobId) return; button.disabled = true; button.textContent = 'Finishing…'; try { const response = await fetch(`/api/jobs/${jobId}/review/finish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); const payload = await response.json(); if (!payload.success) { throw new Error(payload.error || 'Failed to finish review'); } clearChunkRegenWatchersForJob(jobId); closeReviewPanelForJob(jobId); showReviewToast('Review finalized! Merging chunks now.'); loadQueue(); } catch (error) { alert(error.message || 'Failed to finish review.'); } finally { button.disabled = false; button.textContent = 'Finish review'; } } function closeReviewPanelForJob(jobId) { if (!jobId) return; openReviewPanels.delete(jobId); reviewPanelScrollPositions.delete(jobId); reviewPanelContentCache.delete(jobId); clearPanelEditorState(jobId); clearEditingStateForJob(jobId); clearVoiceOverridesForJob(jobId); jobChunkMaps.delete(jobId); const row = document.querySelector(`.review-panel-row[data-review-row="${jobId}"]`); const panel = document.getElementById(`review-panel-${jobId}`); if (row) { row.classList.remove('open'); } if (panel) { panel.classList.remove('open'); panel.innerHTML = ''; } updateReviewToggleButtons(jobId); } function syncReviewPanelState(jobs) { const reviewJobs = new Set( jobs.filter(job => job.review_mode).map(job => job.job_id) ); Array.from(openReviewPanels).forEach(jobId => { if (!reviewJobs.has(jobId)) { openReviewPanels.delete(jobId); } }); } function updateReviewToggleButtons(jobId) { const isOpen = openReviewPanels.has(jobId); document.querySelectorAll(`[data-action="toggle-review"][data-job-id="${jobId}"]`).forEach(button => { button.textContent = isOpen ? 'Hide review' : 'Review chunks'; }); } function cachePanelEditorState(jobId) { const panel = document.getElementById(`review-panel-${jobId}`); if (!panel) { reviewPanelEditorState.delete(jobId); return; } const state = {}; panel.querySelectorAll('textarea[data-chunk-text]').forEach(textarea => { const chunkId = textarea.dataset.chunkText; if (!chunkId) { return; } const pendingText = chunkPendingTextEdits.get(chunkId); const lastValue = pendingText != null ? pendingText : textarea.value; let selectionStart = null; let selectionEnd = null; if (!textarea.disabled) { try { selectionStart = textarea.selectionStart; selectionEnd = textarea.selectionEnd; } catch (_) { selectionStart = selectionEnd = textarea.value.length; } } state[chunkId] = { text: lastValue, editing: !textarea.disabled, selectionStart, selectionEnd, hadFocus: document.activeElement === textarea, }; }); reviewPanelEditorState.set(jobId, state); const body = panel.querySelector('.review-panel-body'); if (body) { reviewPanelScrollPositions.set(jobId, body.scrollTop); } } function restorePanelEditorState(jobId) { const state = reviewPanelEditorState.get(jobId); if (!state) { return; } Object.entries(state).forEach(([chunkId, info]) => { const textarea = document.querySelector(`textarea[data-chunk-text="${chunkId}"]`); const button = document.querySelector(`[data-action="chunk-edit"][data-chunk-id="${chunkId}"]`); if (!textarea) { return; } if (info.text != null && textarea.value !== info.text) { textarea.value = info.text; } if (info.editing) { textarea.disabled = false; if (button) { button.textContent = 'Lock text'; } requestAnimationFrame(() => { try { if (info.selectionStart != null && info.selectionEnd != null) { textarea.setSelectionRange(info.selectionStart, info.selectionEnd); } } catch (_) { const end = textarea.value.length; textarea.setSelectionRange(end, end); } if (info.hadFocus) { textarea.focus(); } }); } else { textarea.disabled = true; if (button) { button.textContent = 'Edit text'; } } }); } function clearPanelEditorState(jobId) { reviewPanelEditorState.delete(jobId); cachePendingTextEdits(jobId, true); } function clearEditingStateForJob(jobId) { Array.from(activeEditingChunks.entries()).forEach(([chunkId, mappedJobId]) => { if (mappedJobId === jobId) { activeEditingChunks.delete(chunkId); } }); resumeQueueAutoRefreshIfIdle(); } function syncActiveChunkControls() { if (!activeChunkId) { return; } const button = document.querySelector(`[data-action="chunk-play"][data-chunk-id="${activeChunkId}"]`); if (!button) { stopActiveChunk(); return; } activeChunkButton = button; if (activeChunkAudio && !activeChunkAudio.paused) { button.textContent = 'Stop'; } else { button.textContent = 'Play'; } } function showReviewToast(message) { const toast = document.createElement('div'); toast.className = 'review-toast'; toast.textContent = message; document.body.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('show'); }); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 2500); }