|
|
const state = { |
|
|
config: { moonshine: {}, sensevoice: {}, llms: {} }, |
|
|
backend: 'sensevoice', |
|
|
utterances: [], |
|
|
diarizedUtterances: null, |
|
|
diarizationStats: null, |
|
|
speakerNames: {}, |
|
|
summary: '', |
|
|
title: '', |
|
|
audioUrl: null, |
|
|
sourcePath: null, |
|
|
uploadedFile: null, |
|
|
transcribing: false, |
|
|
summarizing: false, |
|
|
detectingSpeakerNames: false, |
|
|
transcriptionController: null, |
|
|
summaryController: null, |
|
|
}; |
|
|
|
|
|
const elements = { |
|
|
backendSelect: document.getElementById('backend-select'), |
|
|
modelSelect: document.getElementById('model-select'), |
|
|
llmSelect: document.getElementById('llm-select'), |
|
|
promptInput: document.getElementById('prompt-input'), |
|
|
vadSlider: document.getElementById('vad-threshold'), |
|
|
vadValue: document.getElementById('vad-value'), |
|
|
diarizationToggle: document.getElementById('diarization-toggle'), |
|
|
diarizationSettings: document.getElementById('diarization-settings'), |
|
|
numSpeakers: document.getElementById('num-speakers'), |
|
|
clusterSlider: document.getElementById('cluster-threshold'), |
|
|
clusterValue: document.getElementById('cluster-value'), |
|
|
sensevoiceOptions: document.getElementById('sensevoice-options'), |
|
|
sensevoiceLanguage: document.getElementById('sensevoice-language'), |
|
|
transcribeBtn: document.getElementById('transcribe-btn'), |
|
|
summaryBtn: document.getElementById('summary-btn'), |
|
|
detectSpeakerNamesBtn: document.getElementById('detect-speaker-names-btn'), |
|
|
statusText: document.getElementById('status-text'), |
|
|
audioPlayer: document.getElementById('audio-player'), |
|
|
transcriptList: document.getElementById('transcript-list'), |
|
|
transcriptTemplate: document.getElementById('utterance-template'), |
|
|
utteranceCount: document.getElementById('utterance-count'), |
|
|
summaryOutput: document.getElementById('summary-output'), |
|
|
titleOutput: document.getElementById('title-output'), |
|
|
diarizationPanel: document.getElementById('diarization-summary'), |
|
|
diarizationMetrics: document.getElementById('diarization-metrics'), |
|
|
speakerBreakdown: document.getElementById('speaker-breakdown'), |
|
|
transcriptFormat: document.getElementById('transcript-format'), |
|
|
summaryFormat: document.getElementById('summary-format'), |
|
|
exportTranscriptBtn: document.getElementById('export-transcript'), |
|
|
exportSummaryBtn: document.getElementById('export-summary'), |
|
|
includeTimestamps: document.getElementById('include-timestamps'), |
|
|
fileInput: document.getElementById('file-input'), |
|
|
youtubeUrl: document.getElementById('youtube-url'), |
|
|
youtubeFetch: document.getElementById('youtube-fetch'), |
|
|
podcastQuery: document.getElementById('podcast-query'), |
|
|
podcastSearch: document.getElementById('podcast-search'), |
|
|
podcastResults: document.getElementById('podcast-results'), |
|
|
episodeResults: document.getElementById('episode-results'), |
|
|
progressContainer: document.getElementById('progress-container'), |
|
|
progressFill: document.getElementById('progress-fill'), |
|
|
cancelTranscribeBtn: document.getElementById('cancel-transcribe-btn'), |
|
|
cancelSummaryBtn: document.getElementById('cancel-summary-btn'), |
|
|
|
|
|
playPauseBtn: document.getElementById('play-pause-btn'), |
|
|
playIcon: document.querySelector('.play-icon'), |
|
|
pauseIcon: document.querySelector('.pause-icon'), |
|
|
currentTimeDisplay: document.getElementById('current-time'), |
|
|
durationTimeDisplay: document.getElementById('duration-time'), |
|
|
timelineBar: document.getElementById('timeline-bar'), |
|
|
timelineProgress: document.getElementById('timeline-progress'), |
|
|
timelineSegments: document.getElementById('timeline-segments'), |
|
|
timelineHandle: document.getElementById('timeline-handle'), |
|
|
waveformCanvas: document.getElementById('waveform-canvas'), |
|
|
volumeBtn: document.getElementById('volume-btn'), |
|
|
volumeSlider: document.getElementById('volume-slider'), |
|
|
}; |
|
|
|
|
|
const TRANSCRIPT_FORMATS = [ |
|
|
'SRT (SubRip)', |
|
|
'VTT (WebVTT)', |
|
|
'ASS (Advanced SubStation Alpha)', |
|
|
'Plain Text', |
|
|
'JSON', |
|
|
'ELAN (EAF)', |
|
|
]; |
|
|
|
|
|
const SUMMARY_FORMATS = ['Markdown', 'Plain Text']; |
|
|
|
|
|
|
|
|
const SPEAKER_COLORS = [ |
|
|
'#ef4444', |
|
|
'#3b82f6', |
|
|
'#10b981', |
|
|
'#f59e0b', |
|
|
'#8b5cf6', |
|
|
'#ec4899', |
|
|
'#14b8a6', |
|
|
'#f97316', |
|
|
'#06b6d4', |
|
|
'#84cc16', |
|
|
'#dc2626', |
|
|
'#2563eb', |
|
|
'#059669', |
|
|
'#d97706', |
|
|
'#7c3aed', |
|
|
'#db2777', |
|
|
'#0d9488', |
|
|
'#ea580c', |
|
|
'#0891b2', |
|
|
'#65a30d', |
|
|
'#f87171', |
|
|
'#60a5fa', |
|
|
'#34d399', |
|
|
'#fbbf24', |
|
|
'#a78bfa', |
|
|
'#f472b6', |
|
|
'#2dd4bf', |
|
|
'#fb923c', |
|
|
'#22d3ee', |
|
|
'#a3e635', |
|
|
]; |
|
|
|
|
|
function getSpeakerColor(speakerId) { |
|
|
if (typeof speakerId !== 'number') return null; |
|
|
return SPEAKER_COLORS[speakerId % SPEAKER_COLORS.length]; |
|
|
} |
|
|
|
|
|
let activeTab = 'podcast-tab'; |
|
|
let activeUtteranceIndex = -1; |
|
|
|
|
|
|
|
|
marked.setOptions({ |
|
|
breaks: true, |
|
|
gfm: true, |
|
|
headerIds: false, |
|
|
mangle: false, |
|
|
}); |
|
|
|
|
|
|
|
|
function renderMarkdown(markdown) { |
|
|
if (!markdown) return ''; |
|
|
return marked.parse(markdown); |
|
|
} |
|
|
|
|
|
function setStatus(message, tone = 'info') { |
|
|
elements.statusText.textContent = message; |
|
|
elements.statusText.dataset.tone = tone; |
|
|
} |
|
|
|
|
|
function showProgress(visible = true) { |
|
|
if (visible) { |
|
|
elements.progressContainer.classList.remove('hidden'); |
|
|
} else { |
|
|
elements.progressContainer.classList.add('hidden'); |
|
|
elements.progressFill.style.width = '0%'; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateProgress(percent, text = null) { |
|
|
elements.progressFill.style.width = `${Math.min(100, Math.max(0, percent))}%`; |
|
|
|
|
|
} |
|
|
|
|
|
function formatTime(seconds) { |
|
|
const mins = Math.floor(seconds / 60); |
|
|
const secs = Math.floor(seconds % 60).toString().padStart(2, '0'); |
|
|
return `${mins}:${secs}`; |
|
|
} |
|
|
|
|
|
function setListEmpty(container, message) { |
|
|
if (!container) return; |
|
|
container.innerHTML = `<div class="empty-state">${message}</div>`; |
|
|
} |
|
|
|
|
|
async function fetchConfig() { |
|
|
try { |
|
|
const res = await fetch('/api/config/models'); |
|
|
if (!res.ok) throw new Error('Failed to fetch model catalog'); |
|
|
state.config = await res.json(); |
|
|
populateModelSelect(); |
|
|
populateLLMSelect(); |
|
|
populateExportSelects(); |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
function populateModelSelect() { |
|
|
const backend = state.backend; |
|
|
elements.modelSelect.innerHTML = ''; |
|
|
const models = backend === 'moonshine' ? state.config.moonshine : state.config.sensevoice; |
|
|
Object.entries(models).forEach(([label, value]) => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = value; |
|
|
option.textContent = label; |
|
|
elements.modelSelect.appendChild(option); |
|
|
}); |
|
|
if (elements.modelSelect.options.length > 0) { |
|
|
elements.modelSelect.selectedIndex = 0; |
|
|
} |
|
|
elements.sensevoiceOptions.classList.toggle('hidden', backend !== 'sensevoice'); |
|
|
} |
|
|
|
|
|
function populateLLMSelect() { |
|
|
elements.llmSelect.innerHTML = ''; |
|
|
Object.keys(state.config.llms).forEach((name) => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = name; |
|
|
option.textContent = name; |
|
|
elements.llmSelect.appendChild(option); |
|
|
}); |
|
|
} |
|
|
|
|
|
function populateExportSelects() { |
|
|
elements.transcriptFormat.innerHTML = ''; |
|
|
TRANSCRIPT_FORMATS.forEach((fmt) => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = fmt; |
|
|
option.textContent = fmt; |
|
|
elements.transcriptFormat.appendChild(option); |
|
|
}); |
|
|
|
|
|
elements.summaryFormat.innerHTML = ''; |
|
|
SUMMARY_FORMATS.forEach((fmt) => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = fmt; |
|
|
option.textContent = fmt; |
|
|
elements.summaryFormat.appendChild(option); |
|
|
}); |
|
|
} |
|
|
|
|
|
function initTabs() { |
|
|
document.querySelectorAll('.tab').forEach((tab) => { |
|
|
tab.addEventListener('click', () => { |
|
|
if (tab.dataset.target === activeTab) return; |
|
|
document.querySelectorAll('.tab').forEach((btn) => btn.classList.remove('active')); |
|
|
document.querySelectorAll('.tab-panel').forEach((panel) => panel.classList.remove('active')); |
|
|
tab.classList.add('active'); |
|
|
document.getElementById(tab.dataset.target).classList.add('active'); |
|
|
activeTab = tab.dataset.target; |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
function initSidebarInteractions() { |
|
|
elements.backendSelect.addEventListener('change', () => { |
|
|
state.backend = elements.backendSelect.value; |
|
|
populateModelSelect(); |
|
|
}); |
|
|
|
|
|
elements.vadSlider.addEventListener('input', () => { |
|
|
elements.vadValue.textContent = Number(elements.vadSlider.value).toFixed(2); |
|
|
}); |
|
|
|
|
|
elements.diarizationToggle.addEventListener('change', () => { |
|
|
elements.diarizationSettings.classList.toggle('hidden', !elements.diarizationToggle.checked); |
|
|
}); |
|
|
|
|
|
elements.clusterSlider.addEventListener('input', () => { |
|
|
elements.clusterValue.textContent = Number(elements.clusterSlider.value).toFixed(2); |
|
|
}); |
|
|
} |
|
|
|
|
|
function resetTranscriptionState() { |
|
|
state.utterances = []; |
|
|
state.diarizedUtterances = null; |
|
|
state.diarizationStats = null; |
|
|
activeUtteranceIndex = -1; |
|
|
elements.transcriptList.innerHTML = ''; |
|
|
elements.utteranceCount.textContent = ''; |
|
|
elements.diarizationPanel.classList.add('hidden'); |
|
|
} |
|
|
|
|
|
function resetCompleteSession() { |
|
|
|
|
|
resetTranscriptionState(); |
|
|
|
|
|
|
|
|
state.speakerNames = {}; |
|
|
|
|
|
|
|
|
state.summary = ''; |
|
|
state.title = ''; |
|
|
|
|
|
|
|
|
elements.summaryOutput.innerHTML = ''; |
|
|
elements.titleOutput.textContent = ''; |
|
|
|
|
|
|
|
|
renderTimelineSegments(); |
|
|
|
|
|
|
|
|
elements.detectSpeakerNamesBtn.classList.add('hidden'); |
|
|
|
|
|
|
|
|
setStatus('Ready for new transcription', 'info'); |
|
|
} |
|
|
|
|
|
function prepareTranscriptionOptions() { |
|
|
const textnormValue = document.querySelector('input[name="textnorm"]:checked')?.value || 'withitn'; |
|
|
return { |
|
|
backend: state.backend, |
|
|
model_name: elements.modelSelect.value, |
|
|
vad_threshold: Number(elements.vadSlider.value), |
|
|
language: state.backend === 'sensevoice' ? elements.sensevoiceLanguage.value : 'auto', |
|
|
textnorm: textnormValue, |
|
|
diarization: { |
|
|
enable: elements.diarizationToggle.checked, |
|
|
num_speakers: Number(elements.numSpeakers.value || -1), |
|
|
cluster_threshold: Number(elements.clusterSlider.value), |
|
|
}, |
|
|
}; |
|
|
} |
|
|
|
|
|
async function handleTranscription() { |
|
|
if (state.transcribing) return; |
|
|
if (!state.uploadedFile && !state.audioUrl) { |
|
|
setStatus('Upload or select an audio source first', 'warning'); |
|
|
return; |
|
|
} |
|
|
|
|
|
resetTranscriptionState(); |
|
|
state.transcribing = true; |
|
|
state.transcriptionController = new AbortController(); |
|
|
setStatus('Starting transcription...', 'info'); |
|
|
showProgress(true); |
|
|
updateProgress(0, 'Initializing...'); |
|
|
|
|
|
|
|
|
elements.transcribeBtn.classList.add('hidden'); |
|
|
elements.cancelTranscribeBtn.classList.remove('hidden'); |
|
|
|
|
|
const formData = new FormData(); |
|
|
if (state.uploadedFile) { |
|
|
formData.append('audio', state.uploadedFile, state.uploadedFile.name); |
|
|
} else if (state.audioUrl) { |
|
|
formData.append('source', state.audioUrl); |
|
|
} |
|
|
formData.append('options', JSON.stringify(prepareTranscriptionOptions())); |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/transcribe', { |
|
|
method: 'POST', |
|
|
body: formData, |
|
|
signal: state.transcriptionController.signal, |
|
|
}); |
|
|
if (!response.ok || !response.body) { |
|
|
throw new Error('Transcription request failed'); |
|
|
} |
|
|
|
|
|
const reader = response.body.getReader(); |
|
|
const decoder = new TextDecoder(); |
|
|
let buffer = ''; |
|
|
|
|
|
while (true) { |
|
|
const { done, value } = await reader.read(); |
|
|
if (done) break; |
|
|
buffer += decoder.decode(value, { stream: true }); |
|
|
let lines = buffer.split('\n'); |
|
|
buffer = lines.pop(); |
|
|
for (const line of lines) { |
|
|
if (!line.trim()) continue; |
|
|
const event = JSON.parse(line); |
|
|
handleTranscriptionEvent(event); |
|
|
} |
|
|
} |
|
|
|
|
|
if (buffer.trim()) { |
|
|
handleTranscriptionEvent(JSON.parse(buffer)); |
|
|
} |
|
|
|
|
|
setStatus('Transcription complete', 'success'); |
|
|
showProgress(false); |
|
|
} catch (err) { |
|
|
if (err.name === 'AbortError') { |
|
|
setStatus('Transcription cancelled', 'warning'); |
|
|
} else { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} |
|
|
showProgress(false); |
|
|
} finally { |
|
|
state.transcribing = false; |
|
|
state.transcriptionController = null; |
|
|
|
|
|
elements.cancelTranscribeBtn.classList.add('hidden'); |
|
|
elements.transcribeBtn.classList.remove('hidden'); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleTranscriptionEvent(event) { |
|
|
switch (event.type) { |
|
|
case 'ready': |
|
|
if (event.audioUrl) { |
|
|
state.audioUrl = event.audioUrl; |
|
|
elements.audioPlayer.src = event.audioUrl; |
|
|
elements.audioPlayer.currentTime = 0; |
|
|
} |
|
|
break; |
|
|
case 'status': |
|
|
setStatus(event.message, 'info'); |
|
|
break; |
|
|
case 'progress': |
|
|
if (event.stage === 'diarization') { |
|
|
setStatus(`Performing speaker diarization... (${event.progress}%)`, 'info'); |
|
|
updateProgress(event.progress); |
|
|
} else { |
|
|
updateProgress(event.progress || 0); |
|
|
} |
|
|
break; |
|
|
case 'utterance': |
|
|
state.utterances.push(event.utterance); |
|
|
const progress = event.progress || 0; |
|
|
setStatus(`Transcribing audio... (${state.utterances.length} utterances, ${progress}%)`, 'info'); |
|
|
updateProgress(progress); |
|
|
renderTranscript(); |
|
|
break; |
|
|
case 'complete': |
|
|
if (event.diarization) { |
|
|
state.diarizedUtterances = event.diarization.utterances || []; |
|
|
state.diarizationStats = event.diarization.stats || null; |
|
|
} |
|
|
if (event.utterances) { |
|
|
const diarized = state.diarizedUtterances?.length ? state.diarizedUtterances : null; |
|
|
state.utterances = diarized |
|
|
? diarized.map((utt, index) => ({ |
|
|
...(event.utterances[index] || {}), |
|
|
...utt, |
|
|
})) |
|
|
: event.utterances; |
|
|
} else if (state.diarizedUtterances?.length) { |
|
|
state.utterances = state.diarizedUtterances; |
|
|
} |
|
|
renderTranscript(); |
|
|
renderDiarizationStats(); |
|
|
break; |
|
|
case 'error': |
|
|
setStatus(event.message || 'Transcription error', 'error'); |
|
|
break; |
|
|
} |
|
|
} |
|
|
|
|
|
function createUtteranceElement(utt, index) { |
|
|
const node = elements.transcriptTemplate.content.cloneNode(true); |
|
|
const item = node.querySelector('.utterance-item'); |
|
|
item.dataset.index = index.toString(); |
|
|
item.dataset.start = utt.start; |
|
|
item.dataset.end = utt.end; |
|
|
|
|
|
node.querySelector('.timestamp').textContent = `[${formatTime(utt.start)}]`; |
|
|
node.querySelector('.utterance-text').textContent = utt.text; |
|
|
|
|
|
const speakerTag = node.querySelector('.speaker-tag'); |
|
|
if (typeof utt.speaker === 'number') { |
|
|
const speakerId = utt.speaker; |
|
|
const speakerInfo = state.speakerNames?.[speakerId]; |
|
|
const speakerName = speakerInfo?.name || `Speaker ${speakerId + 1}`; |
|
|
const speakerColor = getSpeakerColor(speakerId); |
|
|
|
|
|
speakerTag.textContent = speakerName; |
|
|
speakerTag.classList.remove('hidden'); |
|
|
speakerTag.classList.add('editable-speaker'); |
|
|
speakerTag.dataset.speakerId = speakerId; |
|
|
speakerTag.title = 'Click to edit speaker name'; |
|
|
|
|
|
|
|
|
if (speakerColor) { |
|
|
speakerTag.style.backgroundColor = speakerColor + '40'; |
|
|
speakerTag.style.borderColor = speakerColor; |
|
|
speakerTag.style.color = '#ffffff'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (index === activeUtteranceIndex) { |
|
|
item.classList.add('active'); |
|
|
} |
|
|
|
|
|
return node; |
|
|
} |
|
|
|
|
|
function renderTranscript(forceRebuild = false) { |
|
|
const currentCount = elements.transcriptList.children.length; |
|
|
const totalCount = state.utterances.length; |
|
|
|
|
|
|
|
|
if (currentCount === 0 && totalCount > 0) { |
|
|
const fragment = document.createDocumentFragment(); |
|
|
state.utterances.forEach((utt, index) => { |
|
|
fragment.appendChild(createUtteranceElement(utt, index)); |
|
|
}); |
|
|
elements.transcriptList.appendChild(fragment); |
|
|
} |
|
|
|
|
|
else if (totalCount > currentCount && !forceRebuild) { |
|
|
const fragment = document.createDocumentFragment(); |
|
|
const newUtterances = state.utterances.slice(currentCount); |
|
|
newUtterances.forEach((utt, i) => { |
|
|
const index = currentCount + i; |
|
|
fragment.appendChild(createUtteranceElement(utt, index)); |
|
|
}); |
|
|
elements.transcriptList.appendChild(fragment); |
|
|
} |
|
|
|
|
|
else if (forceRebuild || totalCount !== currentCount) { |
|
|
elements.transcriptList.innerHTML = ''; |
|
|
const fragment = document.createDocumentFragment(); |
|
|
state.utterances.forEach((utt, index) => { |
|
|
fragment.appendChild(createUtteranceElement(utt, index)); |
|
|
}); |
|
|
elements.transcriptList.appendChild(fragment); |
|
|
} |
|
|
|
|
|
elements.utteranceCount.textContent = `${state.utterances.length} segments`; |
|
|
|
|
|
|
|
|
renderTimelineSegments(); |
|
|
} |
|
|
|
|
|
function renderDiarizationStats() { |
|
|
if (!state.diarizationStats) { |
|
|
elements.diarizationPanel.classList.add('hidden'); |
|
|
elements.detectSpeakerNamesBtn.classList.add('hidden'); |
|
|
return; |
|
|
} |
|
|
elements.diarizationPanel.classList.remove('hidden'); |
|
|
elements.detectSpeakerNamesBtn.classList.remove('hidden'); |
|
|
const stats = state.diarizationStats; |
|
|
|
|
|
elements.diarizationMetrics.innerHTML = ''; |
|
|
const metricsFragment = document.createDocumentFragment(); |
|
|
|
|
|
const totalCard = document.createElement('div'); |
|
|
totalCard.className = 'metric-card'; |
|
|
totalCard.innerHTML = `<strong>Total speakers:</strong> ${stats.total_speakers || 0}<br/><strong>Duration:</strong> ${stats.total_duration?.toFixed(1) || 0}s`; |
|
|
metricsFragment.appendChild(totalCard); |
|
|
elements.diarizationMetrics.appendChild(metricsFragment); |
|
|
|
|
|
elements.speakerBreakdown.innerHTML = ''; |
|
|
const speakersFragment = document.createDocumentFragment(); |
|
|
Object.entries(stats.speakers || {}).forEach(([speakerId, info]) => { |
|
|
const card = document.createElement('div'); |
|
|
card.className = 'metric-card'; |
|
|
card.innerHTML = ` |
|
|
<strong>Speaker ${Number(speakerId) + 1}</strong><br/> |
|
|
Speaking time: ${info.speaking_time.toFixed(1)}s<br/> |
|
|
Percentage: ${info.percentage.toFixed(1)}%<br/> |
|
|
Utterances: ${info.utterances}<br/> |
|
|
Avg length: ${info.avg_utterance_length.toFixed(1)}s |
|
|
`; |
|
|
speakersFragment.appendChild(card); |
|
|
}); |
|
|
elements.speakerBreakdown.appendChild(speakersFragment); |
|
|
} |
|
|
|
|
|
function findActiveUtterance(currentTime) { |
|
|
let left = 0; |
|
|
let right = state.utterances.length - 1; |
|
|
let match = -1; |
|
|
while (left <= right) { |
|
|
const mid = Math.floor((left + right) / 2); |
|
|
const utt = state.utterances[mid]; |
|
|
if (currentTime >= utt.start && currentTime < utt.end) { |
|
|
return mid; |
|
|
} |
|
|
if (currentTime < utt.start) { |
|
|
right = mid - 1; |
|
|
} else { |
|
|
match = mid; |
|
|
left = mid + 1; |
|
|
} |
|
|
} |
|
|
return match; |
|
|
} |
|
|
|
|
|
function updateActiveUtterance(index) { |
|
|
if (index === activeUtteranceIndex) return; |
|
|
const previous = elements.transcriptList.querySelector('.utterance-item.active'); |
|
|
if (previous) previous.classList.remove('active'); |
|
|
const current = elements.transcriptList.querySelector(`.utterance-item[data-index="${index}"]`); |
|
|
if (current) { |
|
|
current.classList.add('active'); |
|
|
current.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|
|
} |
|
|
activeUtteranceIndex = index; |
|
|
} |
|
|
|
|
|
function initAudioInteractions() { |
|
|
elements.audioPlayer.addEventListener('timeupdate', () => { |
|
|
if (!state.utterances.length) return; |
|
|
const idx = findActiveUtterance(elements.audioPlayer.currentTime); |
|
|
if (idx >= 0) { |
|
|
updateActiveUtterance(idx); |
|
|
updateActiveSegment(); |
|
|
} |
|
|
}); |
|
|
|
|
|
elements.transcriptList.addEventListener('click', (event) => { |
|
|
const item = event.target.closest('.utterance-item'); |
|
|
if (!item) return; |
|
|
|
|
|
const editButton = event.target.closest('.edit-btn'); |
|
|
const saveButton = event.target.closest('.save-edit'); |
|
|
const cancelButton = event.target.closest('.cancel-edit'); |
|
|
const speakerTag = event.target.closest('.editable-speaker'); |
|
|
const editArea = event.target.closest('.edit-area'); |
|
|
|
|
|
|
|
|
const isTextarea = event.target.tagName === 'TEXTAREA'; |
|
|
|
|
|
const index = Number(item.dataset.index); |
|
|
|
|
|
|
|
|
if (speakerTag && !speakerTag.querySelector('input')) { |
|
|
startSpeakerEdit(speakerTag); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (editButton) { |
|
|
event.stopPropagation(); |
|
|
toggleEdit(item, true); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (saveButton) { |
|
|
event.stopPropagation(); |
|
|
const textarea = item.querySelector('textarea'); |
|
|
const newText = textarea.value.trim(); |
|
|
if (newText.length === 0) return; |
|
|
state.utterances[index].text = newText; |
|
|
item.querySelector('.utterance-text').textContent = newText; |
|
|
toggleEdit(item, false); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (cancelButton) { |
|
|
event.stopPropagation(); |
|
|
toggleEdit(item, false); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (isTextarea || editArea) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const start = Number(item.dataset.start); |
|
|
seekToTime(start); |
|
|
}); |
|
|
} |
|
|
|
|
|
function toggleEdit(item, editing) { |
|
|
const textBlock = item.querySelector('.utterance-text'); |
|
|
const editArea = item.querySelector('.edit-area'); |
|
|
if (!textBlock || !editArea) return; |
|
|
|
|
|
if (editing) { |
|
|
const textarea = editArea.querySelector('textarea'); |
|
|
textarea.value = textBlock.textContent; |
|
|
textBlock.classList.add('hidden'); |
|
|
editArea.classList.remove('hidden'); |
|
|
} else { |
|
|
textBlock.classList.remove('hidden'); |
|
|
editArea.classList.add('hidden'); |
|
|
} |
|
|
} |
|
|
|
|
|
function startSpeakerEdit(speakerTag) { |
|
|
const speakerId = Number(speakerTag.dataset.speakerId); |
|
|
const currentName = speakerTag.textContent; |
|
|
|
|
|
|
|
|
const input = document.createElement('input'); |
|
|
input.type = 'text'; |
|
|
input.className = 'speaker-edit-input'; |
|
|
input.value = currentName; |
|
|
input.dataset.speakerId = speakerId; |
|
|
|
|
|
|
|
|
speakerTag.innerHTML = ''; |
|
|
speakerTag.appendChild(input); |
|
|
input.focus(); |
|
|
input.select(); |
|
|
|
|
|
|
|
|
const finishEdit = (save = true) => { |
|
|
const newName = input.value.trim(); |
|
|
|
|
|
if (save) { |
|
|
if (newName) { |
|
|
|
|
|
if (!state.speakerNames) state.speakerNames = {}; |
|
|
state.speakerNames[speakerId] = { |
|
|
name: newName, |
|
|
confidence: 'user', |
|
|
reason: 'User edited' |
|
|
}; |
|
|
} else { |
|
|
|
|
|
if (state.speakerNames && state.speakerNames[speakerId]) { |
|
|
delete state.speakerNames[speakerId]; |
|
|
} |
|
|
} |
|
|
|
|
|
renderTranscript(true); |
|
|
renderTimelineSegments(); |
|
|
renderDiarizationStats(); |
|
|
} else { |
|
|
|
|
|
const originalName = state.speakerNames?.[speakerId]?.name || `Speaker ${speakerId + 1}`; |
|
|
speakerTag.textContent = originalName; |
|
|
speakerTag.classList.add('editable-speaker'); |
|
|
} |
|
|
}; |
|
|
|
|
|
input.addEventListener('keydown', (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
e.preventDefault(); |
|
|
finishEdit(true); |
|
|
} else if (e.key === 'Escape') { |
|
|
e.preventDefault(); |
|
|
finishEdit(false); |
|
|
} |
|
|
}); |
|
|
|
|
|
input.addEventListener('blur', () => { |
|
|
finishEdit(true); |
|
|
}); |
|
|
} |
|
|
|
|
|
function seekToTime(timeInSeconds) { |
|
|
if (!Number.isFinite(timeInSeconds)) return; |
|
|
const audio = elements.audioPlayer; |
|
|
|
|
|
const executeSeek = () => { |
|
|
audio.currentTime = Math.max(0, timeInSeconds); |
|
|
updateActiveUtterance(findActiveUtterance(audio.currentTime)); |
|
|
audio.play().catch(() => {}); |
|
|
}; |
|
|
|
|
|
if (audio.readyState >= 1) { |
|
|
executeSeek(); |
|
|
} else { |
|
|
const onLoaded = () => { |
|
|
executeSeek(); |
|
|
audio.removeEventListener('loadedmetadata', onLoaded); |
|
|
}; |
|
|
audio.addEventListener('loadedmetadata', onLoaded); |
|
|
audio.load(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function initCustomAudioPlayer() { |
|
|
const audio = elements.audioPlayer; |
|
|
|
|
|
|
|
|
elements.playPauseBtn.addEventListener('click', () => { |
|
|
if (audio.paused) { |
|
|
audio.play(); |
|
|
} else { |
|
|
audio.pause(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
audio.addEventListener('play', () => { |
|
|
elements.playIcon.classList.add('hidden'); |
|
|
elements.pauseIcon.classList.remove('hidden'); |
|
|
}); |
|
|
|
|
|
audio.addEventListener('pause', () => { |
|
|
elements.playIcon.classList.remove('hidden'); |
|
|
elements.pauseIcon.classList.add('hidden'); |
|
|
}); |
|
|
|
|
|
|
|
|
audio.addEventListener('timeupdate', () => { |
|
|
updateTimelinePosition(); |
|
|
updateTimeDisplays(); |
|
|
}); |
|
|
|
|
|
|
|
|
audio.addEventListener('loadedmetadata', () => { |
|
|
updateTimeDisplays(); |
|
|
renderTimelineSegments(); |
|
|
}); |
|
|
|
|
|
audio.addEventListener('durationchange', () => { |
|
|
updateTimeDisplays(); |
|
|
renderTimelineSegments(); |
|
|
}); |
|
|
|
|
|
|
|
|
let isDragging = false; |
|
|
|
|
|
elements.timelineBar.addEventListener('mousedown', (e) => { |
|
|
isDragging = true; |
|
|
seekToPosition(e); |
|
|
}); |
|
|
|
|
|
document.addEventListener('mousemove', (e) => { |
|
|
if (isDragging) { |
|
|
seekToPosition(e); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.addEventListener('mouseup', () => { |
|
|
isDragging = false; |
|
|
}); |
|
|
|
|
|
elements.timelineBar.addEventListener('click', (e) => { |
|
|
if (!isDragging) { |
|
|
seekToPosition(e); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
elements.volumeBtn.addEventListener('click', () => { |
|
|
audio.muted = !audio.muted; |
|
|
updateVolumeIcon(); |
|
|
}); |
|
|
|
|
|
elements.volumeSlider.addEventListener('input', (e) => { |
|
|
audio.volume = e.target.value / 100; |
|
|
audio.muted = false; |
|
|
updateVolumeIcon(); |
|
|
}); |
|
|
|
|
|
audio.addEventListener('volumechange', () => { |
|
|
updateVolumeIcon(); |
|
|
}); |
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (e) => { |
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
|
|
|
|
|
if (e.code === 'Space') { |
|
|
e.preventDefault(); |
|
|
if (audio.paused) { |
|
|
audio.play(); |
|
|
} else { |
|
|
audio.pause(); |
|
|
} |
|
|
} else if (e.code === 'ArrowLeft') { |
|
|
e.preventDefault(); |
|
|
audio.currentTime = Math.max(0, audio.currentTime - 5); |
|
|
} else if (e.code === 'ArrowRight') { |
|
|
e.preventDefault(); |
|
|
audio.currentTime = Math.min(audio.duration, audio.currentTime + 5); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function seekToPosition(e) { |
|
|
const audio = elements.audioPlayer; |
|
|
const rect = elements.timelineBar.getBoundingClientRect(); |
|
|
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); |
|
|
audio.currentTime = percent * audio.duration; |
|
|
} |
|
|
|
|
|
function updateTimelinePosition() { |
|
|
const audio = elements.audioPlayer; |
|
|
if (!audio.duration) return; |
|
|
|
|
|
const percent = (audio.currentTime / audio.duration) * 100; |
|
|
elements.timelineProgress.style.width = `${percent}%`; |
|
|
elements.timelineHandle.style.left = `${percent}%`; |
|
|
} |
|
|
|
|
|
function updateTimeDisplays() { |
|
|
const audio = elements.audioPlayer; |
|
|
elements.currentTimeDisplay.textContent = formatTime(audio.currentTime || 0); |
|
|
elements.durationTimeDisplay.textContent = formatTime(audio.duration || 0); |
|
|
} |
|
|
|
|
|
function updateVolumeIcon() { |
|
|
const audio = elements.audioPlayer; |
|
|
if (audio.muted || audio.volume === 0) { |
|
|
elements.volumeBtn.textContent = 'π'; |
|
|
} else if (audio.volume < 0.5) { |
|
|
elements.volumeBtn.textContent = 'π'; |
|
|
} else { |
|
|
elements.volumeBtn.textContent = 'π'; |
|
|
} |
|
|
} |
|
|
|
|
|
function renderTimelineSegments() { |
|
|
const audio = elements.audioPlayer; |
|
|
if (!audio.duration || !state.utterances.length) { |
|
|
elements.timelineSegments.innerHTML = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
elements.timelineSegments.innerHTML = ''; |
|
|
const fragment = document.createDocumentFragment(); |
|
|
|
|
|
state.utterances.forEach((utt, index) => { |
|
|
const segment = document.createElement('div'); |
|
|
segment.className = 'timeline-segment'; |
|
|
segment.dataset.index = index; |
|
|
|
|
|
|
|
|
const startPercent = (utt.start / audio.duration) * 100; |
|
|
const endPercent = (utt.end / audio.duration) * 100; |
|
|
const widthPercent = endPercent - startPercent; |
|
|
|
|
|
segment.style.left = `${startPercent}%`; |
|
|
segment.style.width = `${widthPercent}%`; |
|
|
|
|
|
|
|
|
if (typeof utt.speaker === 'number') { |
|
|
const speakerColor = getSpeakerColor(utt.speaker); |
|
|
if (speakerColor) { |
|
|
segment.style.backgroundColor = speakerColor + '66'; |
|
|
} |
|
|
} else { |
|
|
segment.style.backgroundColor = 'rgba(148, 163, 184, 0.5)'; |
|
|
} |
|
|
|
|
|
|
|
|
const speakerInfo = typeof utt.speaker === 'number' |
|
|
? (state.speakerNames?.[utt.speaker]?.name || `Speaker ${utt.speaker + 1}`) |
|
|
: ''; |
|
|
segment.title = `${speakerInfo ? speakerInfo + ': ' : ''}${utt.text.substring(0, 50)}${utt.text.length > 50 ? '...' : ''}`; |
|
|
|
|
|
|
|
|
segment.addEventListener('click', (e) => { |
|
|
e.stopPropagation(); |
|
|
seekToTime(utt.start); |
|
|
}); |
|
|
|
|
|
fragment.appendChild(segment); |
|
|
}); |
|
|
|
|
|
elements.timelineSegments.appendChild(fragment); |
|
|
|
|
|
|
|
|
updateActiveSegment(); |
|
|
} |
|
|
|
|
|
function updateActiveSegment() { |
|
|
const audio = elements.audioPlayer; |
|
|
if (!state.utterances.length) return; |
|
|
|
|
|
const currentIndex = findActiveUtterance(audio.currentTime); |
|
|
|
|
|
|
|
|
const prevActive = elements.timelineSegments.querySelector('.timeline-segment.active'); |
|
|
if (prevActive) prevActive.classList.remove('active'); |
|
|
|
|
|
|
|
|
if (currentIndex >= 0) { |
|
|
const activeSegment = elements.timelineSegments.querySelector(`.timeline-segment[data-index="${currentIndex}"]`); |
|
|
if (activeSegment) activeSegment.classList.add('active'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleSummaryGeneration() { |
|
|
if (state.summarizing || !state.utterances.length) return; |
|
|
state.summarizing = true; |
|
|
state.summaryController = new AbortController(); |
|
|
setStatus('Generating summary...', 'info'); |
|
|
showProgress(true); |
|
|
updateProgress(0, 'Initializing summary generation...'); |
|
|
elements.summaryOutput.textContent = ''; |
|
|
elements.titleOutput.textContent = ''; |
|
|
state.title = ''; |
|
|
|
|
|
|
|
|
elements.summaryBtn.classList.add('hidden'); |
|
|
elements.cancelSummaryBtn.classList.remove('hidden'); |
|
|
|
|
|
const payload = { |
|
|
transcript: state.utterances.map((u) => u.text).join('\n'), |
|
|
llm_model: elements.llmSelect.value, |
|
|
prompt: elements.promptInput.value || 'Summarize the transcript below.', |
|
|
generate_title: true, |
|
|
}; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/summarize', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(payload), |
|
|
signal: state.summaryController.signal, |
|
|
}); |
|
|
|
|
|
if (!response.ok || !response.body) throw new Error('Failed to generate summary'); |
|
|
|
|
|
const reader = response.body.getReader(); |
|
|
const decoder = new TextDecoder(); |
|
|
let buffer = ''; |
|
|
|
|
|
while (true) { |
|
|
const { done, value } = await reader.read(); |
|
|
if (done) break; |
|
|
buffer += decoder.decode(value, { stream: true }); |
|
|
let lines = buffer.split('\n'); |
|
|
buffer = lines.pop(); |
|
|
for (const line of lines) { |
|
|
if (!line.trim()) continue; |
|
|
const event = JSON.parse(line); |
|
|
if (event.type === 'title' && event.content) { |
|
|
state.title = event.content; |
|
|
elements.titleOutput.textContent = event.content; |
|
|
updateProgress(50); |
|
|
} else if (event.type === 'partial' && event.content) { |
|
|
elements.summaryOutput.innerHTML = renderMarkdown(event.content); |
|
|
updateProgress(75); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
setStatus('Summary ready', 'success'); |
|
|
showProgress(false); |
|
|
} catch (err) { |
|
|
if (err.name === 'AbortError') { |
|
|
setStatus('Summary generation cancelled', 'warning'); |
|
|
} else { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} |
|
|
showProgress(false); |
|
|
} finally { |
|
|
state.summarizing = false; |
|
|
state.summaryController = null; |
|
|
|
|
|
elements.cancelSummaryBtn.classList.add('hidden'); |
|
|
elements.summaryBtn.classList.remove('hidden'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleSpeakerNameDetection() { |
|
|
if (state.detectingSpeakerNames || !state.diarizationStats) return; |
|
|
|
|
|
state.detectingSpeakerNames = true; |
|
|
setStatus('Detecting speaker names...', 'info'); |
|
|
|
|
|
const payload = { |
|
|
utterances: state.utterances, |
|
|
llm_model: elements.llmSelect.value, |
|
|
}; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/detect-speaker-names', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(payload), |
|
|
}); |
|
|
|
|
|
if (!response.ok) throw new Error('Failed to detect speaker names'); |
|
|
|
|
|
const speakerNames = await response.json(); |
|
|
|
|
|
|
|
|
const mergedNames = { ...speakerNames }; |
|
|
if (state.speakerNames) { |
|
|
Object.entries(state.speakerNames).forEach(([speakerId, info]) => { |
|
|
if (info.confidence === 'user') { |
|
|
|
|
|
mergedNames[speakerId] = info; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
state.speakerNames = mergedNames; |
|
|
|
|
|
|
|
|
renderTranscript(true); |
|
|
|
|
|
const detectedCount = Object.keys(speakerNames).length; |
|
|
if (detectedCount > 0) { |
|
|
setStatus(`Detected names for ${detectedCount} speaker(s)`, 'success'); |
|
|
} else { |
|
|
setStatus('No speaker names could be confidently detected', 'info'); |
|
|
} |
|
|
|
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} finally { |
|
|
state.detectingSpeakerNames = false; |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleExportTranscript() { |
|
|
if (!state.utterances.length) return; |
|
|
const payload = { |
|
|
format: elements.transcriptFormat.value, |
|
|
include_timestamps: elements.includeTimestamps.checked, |
|
|
utterances: state.utterances, |
|
|
title: state.title || null, |
|
|
}; |
|
|
await downloadFile('/api/export/transcript', payload, 'transcript'); |
|
|
} |
|
|
|
|
|
async function handleExportSummary() { |
|
|
if (!elements.summaryOutput.textContent.trim()) return; |
|
|
const payload = { |
|
|
format: elements.summaryFormat.value, |
|
|
summary: elements.summaryOutput.textContent, |
|
|
metadata: {}, |
|
|
title: state.title || null, |
|
|
}; |
|
|
await downloadFile('/api/export/summary', payload, 'summary'); |
|
|
} |
|
|
|
|
|
async function downloadFile(url, payload, prefix) { |
|
|
try { |
|
|
const response = await fetch(url, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(payload), |
|
|
}); |
|
|
if (!response.ok) throw new Error('Export failed'); |
|
|
const blob = await response.blob(); |
|
|
const filename = getFilenameFromDisposition(response.headers.get('Content-Disposition')) || `${prefix}.txt`; |
|
|
const link = document.createElement('a'); |
|
|
link.href = URL.createObjectURL(blob); |
|
|
link.download = filename; |
|
|
link.click(); |
|
|
URL.revokeObjectURL(link.href); |
|
|
setStatus('Export complete', 'success'); |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
function getFilenameFromDisposition(disposition) { |
|
|
if (!disposition) return null; |
|
|
const match = disposition.match(/filename="?([^"]+)"?/i); |
|
|
return match ? match[1] : null; |
|
|
} |
|
|
|
|
|
function handleFileUpload(event) { |
|
|
const file = event.target.files?.[0]; |
|
|
if (!file) return; |
|
|
|
|
|
|
|
|
resetCompleteSession(); |
|
|
|
|
|
state.uploadedFile = file; |
|
|
state.audioUrl = null; |
|
|
const objectUrl = URL.createObjectURL(file); |
|
|
elements.audioPlayer.src = objectUrl; |
|
|
setStatus(`Loaded ${file.name}`, 'info'); |
|
|
} |
|
|
|
|
|
async function handleYoutubeFetch() { |
|
|
if (!elements.youtubeUrl.value.trim()) return; |
|
|
setStatus('Downloading audio from YouTube...', 'info'); |
|
|
try { |
|
|
const res = await fetch('/api/youtube/fetch', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ url: elements.youtubeUrl.value.trim() }), |
|
|
}); |
|
|
if (!res.ok) throw new Error('YouTube download failed'); |
|
|
const data = await res.json(); |
|
|
|
|
|
|
|
|
resetCompleteSession(); |
|
|
|
|
|
state.audioUrl = data.audioUrl; |
|
|
state.uploadedFile = null; |
|
|
elements.audioPlayer.src = data.audioUrl; |
|
|
setStatus('YouTube audio ready', 'success'); |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function handlePodcastSearch() { |
|
|
const query = elements.podcastQuery.value.trim(); |
|
|
if (!query) return; |
|
|
setStatus('Searching podcasts...', 'info'); |
|
|
setListEmpty(elements.podcastResults, 'Searching podcasts...'); |
|
|
setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); |
|
|
try { |
|
|
const res = await fetch(`/api/podcast/search?query=${encodeURIComponent(query)}`); |
|
|
if (!res.ok) throw new Error('Podcast search failed'); |
|
|
const series = await res.json(); |
|
|
if (!series.length) { |
|
|
setListEmpty(elements.podcastResults, 'No podcasts match your search yet.'); |
|
|
return; |
|
|
} |
|
|
elements.podcastResults.innerHTML = ''; |
|
|
const fragment = document.createDocumentFragment(); |
|
|
series.forEach((item) => { |
|
|
const div = document.createElement('div'); |
|
|
div.className = 'list-item'; |
|
|
div.innerHTML = ` |
|
|
<div> |
|
|
<strong>${item.title}</strong><br/> |
|
|
<span>${item.artist || 'Unknown artist'}</span> |
|
|
</div> |
|
|
<button data-feed="${item.feed_url}">Episodes</button> |
|
|
`; |
|
|
fragment.appendChild(div); |
|
|
}); |
|
|
elements.podcastResults.appendChild(fragment); |
|
|
setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
setListEmpty(elements.podcastResults, 'Unable to load podcasts right now.'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadEpisodes(feedUrl, sourceItem = null) { |
|
|
setStatus('Loading episodes...', 'info'); |
|
|
if (sourceItem) { |
|
|
elements.podcastResults.querySelectorAll('.list-item').forEach((item) => item.classList.remove('selected')); |
|
|
sourceItem.classList.add('selected'); |
|
|
} |
|
|
setListEmpty(elements.episodeResults, 'Loading episodes...'); |
|
|
try { |
|
|
const res = await fetch(`/api/podcast/episodes?feed_url=${encodeURIComponent(feedUrl)}`); |
|
|
if (!res.ok) throw new Error('Failed to load episodes'); |
|
|
const episodes = await res.json(); |
|
|
if (!episodes.length) { |
|
|
setListEmpty(elements.episodeResults, 'No episodes available for this podcast.'); |
|
|
return; |
|
|
} |
|
|
elements.episodeResults.innerHTML = ''; |
|
|
const fragment = document.createDocumentFragment(); |
|
|
episodes.slice(0, 15).forEach((ep) => { |
|
|
const div = document.createElement('div'); |
|
|
div.className = 'list-item'; |
|
|
div.innerHTML = ` |
|
|
<div> |
|
|
<strong>${ep.title}</strong><br/> |
|
|
<span>${ep.published || ''}</span> |
|
|
</div> |
|
|
<button data-url="${ep.audio_url}" data-title="${ep.title}">Download</button> |
|
|
`; |
|
|
fragment.appendChild(div); |
|
|
}); |
|
|
elements.episodeResults.appendChild(fragment); |
|
|
setStatus('Episodes ready', 'success'); |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
setListEmpty(elements.episodeResults, 'Unable to load episodes right now.'); |
|
|
} |
|
|
} |
|
|
|
|
|
async function downloadEpisode(audioUrl, title, triggerButton = null) { |
|
|
setStatus('Downloading episode...', 'info'); |
|
|
let originalLabel = null; |
|
|
if (triggerButton) { |
|
|
originalLabel = triggerButton.innerHTML; |
|
|
triggerButton.disabled = true; |
|
|
triggerButton.classList.add('loading'); |
|
|
triggerButton.textContent = 'Downloadingβ¦'; |
|
|
} |
|
|
try { |
|
|
const res = await fetch('/api/podcast/download', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ audioUrl, title }), |
|
|
}); |
|
|
if (!res.ok) throw new Error('Episode download failed'); |
|
|
const data = await res.json(); |
|
|
|
|
|
|
|
|
resetCompleteSession(); |
|
|
|
|
|
state.audioUrl = data.audioUrl; |
|
|
state.uploadedFile = null; |
|
|
elements.audioPlayer.src = data.audioUrl; |
|
|
setStatus('Episode ready', 'success'); |
|
|
if (triggerButton) { |
|
|
triggerButton.textContent = 'β Ready'; |
|
|
triggerButton.classList.add('success'); |
|
|
setTimeout(() => { |
|
|
triggerButton.classList.remove('success'); |
|
|
triggerButton.textContent = originalLabel || 'Download'; |
|
|
}, 3000); |
|
|
} |
|
|
} catch (err) { |
|
|
console.error(err); |
|
|
setStatus(err.message, 'error'); |
|
|
if (triggerButton) { |
|
|
triggerButton.textContent = 'β Retry'; |
|
|
triggerButton.classList.add('error'); |
|
|
setTimeout(() => { |
|
|
triggerButton.classList.remove('error'); |
|
|
triggerButton.textContent = originalLabel || 'Download'; |
|
|
}, 3000); |
|
|
} |
|
|
} finally { |
|
|
if (triggerButton) { |
|
|
triggerButton.disabled = false; |
|
|
triggerButton.classList.remove('loading'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => { |
|
|
|
|
|
initTabs(); |
|
|
|
|
|
|
|
|
initSidebarInteractions(); |
|
|
|
|
|
|
|
|
initAudioInteractions(); |
|
|
|
|
|
|
|
|
initCustomAudioPlayer(); |
|
|
|
|
|
|
|
|
await fetchConfig(); |
|
|
|
|
|
|
|
|
elements.backendSelect.innerHTML = ` |
|
|
<option value="moonshine">Moonshine</option> |
|
|
<option value="sensevoice" selected>SenseVoice</option> |
|
|
`; |
|
|
state.backend = elements.backendSelect.value; |
|
|
|
|
|
|
|
|
setListEmpty(elements.podcastResults, 'Search to discover podcasts.'); |
|
|
setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); |
|
|
|
|
|
|
|
|
elements.podcastResults.addEventListener('click', (event) => { |
|
|
const button = event.target.closest('button[data-feed]'); |
|
|
if (button) { |
|
|
const feedUrl = button.dataset.feed; |
|
|
const sourceItem = button.closest('.list-item'); |
|
|
loadEpisodes(feedUrl, sourceItem); |
|
|
} |
|
|
}); |
|
|
|
|
|
elements.episodeResults.addEventListener('click', (event) => { |
|
|
const button = event.target.closest('button[data-url]'); |
|
|
if (button) { |
|
|
const audioUrl = button.dataset.url; |
|
|
const title = button.dataset.title; |
|
|
downloadEpisode(audioUrl, title, button); |
|
|
} |
|
|
}); |
|
|
|
|
|
elements.podcastQuery.addEventListener('keydown', (event) => { |
|
|
if (event.key === 'Enter') { |
|
|
event.preventDefault(); |
|
|
handlePodcastSearch(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
elements.transcribeBtn.addEventListener('click', handleTranscription); |
|
|
elements.summaryBtn.addEventListener('click', handleSummaryGeneration); |
|
|
elements.detectSpeakerNamesBtn.addEventListener('click', handleSpeakerNameDetection); |
|
|
elements.exportTranscriptBtn.addEventListener('click', handleExportTranscript); |
|
|
elements.exportSummaryBtn.addEventListener('click', handleExportSummary); |
|
|
elements.fileInput.addEventListener('change', handleFileUpload); |
|
|
elements.youtubeFetch.addEventListener('click', handleYoutubeFetch); |
|
|
elements.podcastSearch.addEventListener('click', handlePodcastSearch); |
|
|
elements.cancelTranscribeBtn.addEventListener('click', () => { |
|
|
if (state.transcriptionController) { |
|
|
state.transcriptionController.abort(); |
|
|
} |
|
|
}); |
|
|
elements.cancelSummaryBtn.addEventListener('click', () => { |
|
|
if (state.summaryController) { |
|
|
state.summaryController.abort(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
setStatus('Ready', 'info'); |
|
|
}); |
|
|
|