Spaces:
Running
Running
| // App State Management | |
| const state = { | |
| videoFile: null, | |
| videoUrl: null, | |
| audioBuffer: null, | |
| wavBlob: null, | |
| transcriptData: null, | |
| isProcessing: false, | |
| isDemoMode: false, | |
| currentActiveIndex: -1, | |
| apiKey: localStorage.getItem('typhoon_api_key') || 'sk-9gvN1OPfmjUnnBxWTrcLSe5viRBPyzOSdTuR6zaL5HuxRSS6', | |
| model: localStorage.getItem('typhoon_model') || 'typhoon-asr-realtime', | |
| language: localStorage.getItem('typhoon_language') || '' | |
| }; | |
| // UI Elements | |
| const els = { | |
| uploadZone: document.getElementById('uploadZone'), | |
| fileInput: document.getElementById('fileInput'), | |
| videoContainer: document.getElementById('videoContainer'), | |
| videoPlayer: document.getElementById('videoPlayer'), | |
| settingsBtn: document.getElementById('settingsBtn'), | |
| settingsPanel: document.getElementById('settingsPanel'), | |
| closeSettings: document.getElementById('closeSettings'), | |
| apiKeyInput: document.getElementById('apiKey'), | |
| modelSelect: document.getElementById('modelSelect'), | |
| languageSelect: document.getElementById('languageSelect'), | |
| saveSettings: document.getElementById('saveSettings'), | |
| transcribeBtn: document.getElementById('transcribeBtn'), | |
| demoBtn: document.getElementById('demoBtn'), | |
| loadingOverlay: document.getElementById('loadingOverlay'), | |
| loadingStatus: document.getElementById('loadingStatus'), | |
| loadingProgress: document.getElementById('loadingProgress'), | |
| progressBarFill: document.getElementById('progressBarFill'), | |
| mainDashboard: document.getElementById('mainDashboard'), | |
| transcriptPanel: document.getElementById('transcriptPanel'), | |
| fullTextContent: document.getElementById('fullTextContent'), | |
| segmentsList: document.getElementById('segmentsList'), | |
| searchTranscript: document.getElementById('searchTranscript'), | |
| tabText: document.getElementById('tabText'), | |
| tabSegments: document.getElementById('tabSegments'), | |
| panelText: document.getElementById('panelText'), | |
| panelSegments: document.getElementById('panelSegments'), | |
| exportBtn: document.getElementById('exportBtn'), | |
| exportMenu: document.getElementById('exportMenu'), | |
| exportTxt: document.getElementById('exportTxt'), | |
| exportSrt: document.getElementById('exportSrt'), | |
| exportVtt: document.getElementById('exportVtt'), | |
| exportJson: document.getElementById('exportJson'), | |
| fileInfo: document.getElementById('fileInfo'), | |
| fileName: document.getElementById('fileName'), | |
| fileSize: document.getElementById('fileSize') | |
| }; | |
| // Initialize Application | |
| function init() { | |
| setupEventListeners(); | |
| loadSettings(); | |
| // Set default api key if present | |
| if (state.apiKey) { | |
| els.apiKeyInput.value = state.apiKey; | |
| } | |
| } | |
| // Load configurations from state | |
| function loadSettings() { | |
| els.apiKeyInput.value = state.apiKey; | |
| els.modelSelect.value = state.model; | |
| els.languageSelect.value = state.language; | |
| } | |
| // Setup Event Listeners | |
| function setupEventListeners() { | |
| // Drag and Drop | |
| els.uploadZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| els.uploadZone.classList.add('dragover'); | |
| }); | |
| els.uploadZone.addEventListener('dragleave', () => { | |
| els.uploadZone.classList.remove('dragover'); | |
| }); | |
| els.uploadZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| els.uploadZone.classList.remove('dragover'); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('video/')) { | |
| handleVideoSelect(file); | |
| } else { | |
| showNotification('กรุณาเลือกไฟล์วิดีโอเท่านั้น', 'error'); | |
| } | |
| }); | |
| els.uploadZone.addEventListener('click', () => { | |
| els.fileInput.click(); | |
| }); | |
| els.fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| handleVideoSelect(file); | |
| } | |
| }); | |
| // Settings Panel | |
| els.settingsBtn.addEventListener('click', () => { | |
| els.settingsPanel.classList.toggle('open'); | |
| }); | |
| els.closeSettings.addEventListener('click', () => { | |
| els.settingsPanel.classList.remove('open'); | |
| }); | |
| els.saveSettings.addEventListener('click', () => { | |
| state.apiKey = els.apiKeyInput.value.trim(); | |
| state.model = els.modelSelect.value; | |
| state.language = els.languageSelect.value; | |
| localStorage.setItem('typhoon_api_key', state.apiKey); | |
| localStorage.setItem('typhoon_model', state.model); | |
| localStorage.setItem('typhoon_language', state.language); | |
| els.settingsPanel.classList.remove('open'); | |
| showNotification('บันทึกการตั้งค่าแล้ว', 'success'); | |
| }); | |
| // Transcription Controls | |
| els.transcribeBtn.addEventListener('click', () => startTranscriptionFlow(false)); | |
| els.demoBtn.addEventListener('click', () => startTranscriptionFlow(true)); | |
| // Tab Navigation | |
| els.tabText.addEventListener('click', () => switchTab('text')); | |
| els.tabSegments.addEventListener('click', () => switchTab('segments')); | |
| // Video Timeupdate for Interactive Transcript Sync | |
| els.videoPlayer.addEventListener('timeupdate', syncTranscriptHighlight); | |
| // Search Transcript | |
| els.searchTranscript.addEventListener('input', handleSearch); | |
| // Export Dropdown | |
| els.exportBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| els.exportMenu.classList.toggle('show'); | |
| }); | |
| document.addEventListener('click', () => { | |
| els.exportMenu.classList.remove('show'); | |
| }); | |
| els.exportTxt.addEventListener('click', () => exportTranscript('txt')); | |
| els.exportSrt.addEventListener('click', () => exportTranscript('srt')); | |
| els.exportVtt.addEventListener('click', () => exportTranscript('vtt')); | |
| els.exportJson.addEventListener('click', () => exportTranscript('json')); | |
| } | |
| // Handle File Selection | |
| function handleVideoSelect(file) { | |
| state.videoFile = file; | |
| // Format file size | |
| const sizeMB = (file.size / (1024 * 1024)).toFixed(1); | |
| els.fileName.textContent = file.name; | |
| els.fileSize.textContent = `${sizeMB} MB`; | |
| if (file.size > 200 * 1024 * 1024) { | |
| showNotification('ไฟล์มีขนาดใหญ่กว่า 200MB การประมวลผลเสียงในเบราว์เซอร์อาจใช้เวลานานขึ้น', 'warning'); | |
| } | |
| // Create Video URL and load it | |
| if (state.videoUrl) { | |
| URL.revokeObjectURL(state.videoUrl); | |
| } | |
| state.videoUrl = URL.createObjectURL(file); | |
| els.videoPlayer.src = state.videoUrl; | |
| // Reset previous data | |
| state.transcriptData = null; | |
| state.audioBuffer = null; | |
| state.wavBlob = null; | |
| els.mainDashboard.classList.add('hidden'); | |
| els.fileInfo.classList.remove('hidden'); | |
| els.videoContainer.classList.remove('hidden'); | |
| showNotification('โหลดไฟล์วิดีโอสำเร็จ', 'success'); | |
| } | |
| // Switch between full text and interactive segments tabs | |
| function switchTab(tabType) { | |
| if (tabType === 'text') { | |
| els.tabText.classList.add('active'); | |
| els.tabSegments.classList.remove('active'); | |
| els.panelText.classList.remove('hidden'); | |
| els.panelSegments.classList.add('hidden'); | |
| } else { | |
| els.tabText.classList.remove('active'); | |
| els.tabSegments.classList.add('active'); | |
| els.panelText.classList.add('hidden'); | |
| els.panelSegments.classList.remove('hidden'); | |
| } | |
| } | |
| // Flow Manager: Audio Extraction -> Worker Encoding -> ASR API Call | |
| async function startTranscriptionFlow(isDemo = false) { | |
| if (!state.videoFile) { | |
| showNotification('กรุณาอัปโหลดไฟล์วิดีโอก่อน', 'error'); | |
| return; | |
| } | |
| if (!isDemo && !state.apiKey) { | |
| els.settingsPanel.classList.add('open'); | |
| showNotification('กรุณากรอก Typhoon API Key ในหน้าการตั้งค่าก่อนเริ่มถอดความ', 'warning'); | |
| return; | |
| } | |
| state.isDemoMode = isDemo; | |
| state.isProcessing = true; | |
| updateLoadingUI('เริ่มกระบวนการ...', 0); | |
| els.loadingOverlay.classList.remove('hidden'); | |
| try { | |
| // 1. Extract audio | |
| updateLoadingUI('กำลังสกัดสัญญาณเสียงจากไฟล์วิดีโอ...', 15); | |
| const audioBuffer = await extractAudioFromVideo(state.videoFile); | |
| state.audioBuffer = audioBuffer; | |
| // 2. Encode to WAV | |
| updateLoadingUI('กำลังแปลงสัญญาณเสียงเป็น 16kHz WAV...', 40); | |
| const wavBlob = await encodeAudioToWav(audioBuffer); | |
| state.wavBlob = wavBlob; | |
| // 3. Call Typhoon API | |
| if (isDemo) { | |
| updateLoadingUI('จำลองการเรียกใช้งาน Typhoon AI (Demo Mode)...', 80); | |
| await delay(1500); // Simulate API latency | |
| state.transcriptData = generateDemoTranscript(els.videoPlayer.duration || 20); | |
| } else { | |
| updateLoadingUI('กำลังส่งไฟล์เสียงไปประมวลผลที่ Typhoon AI ASR...', 70); | |
| const transcript = await callTyphoonASR(wavBlob); | |
| state.transcriptData = transcript; | |
| } | |
| // 4. Render results | |
| renderTranscript(state.transcriptData); | |
| els.mainDashboard.classList.remove('hidden'); | |
| showNotification('ถอดความวิดีโอสำเร็จ!', 'success'); | |
| } catch (error) { | |
| console.error(error); | |
| showNotification(`เกิดข้อผิดพลาด: ${error.message}`, 'error'); | |
| } finally { | |
| state.isProcessing = false; | |
| els.loadingOverlay.classList.add('hidden'); | |
| } | |
| } | |
| // Update loader screen text and bar width | |
| function updateLoadingUI(statusText, progressPercent) { | |
| els.loadingStatus.textContent = statusText; | |
| els.progressBarFill.style.width = `${progressPercent}%`; | |
| els.loadingProgress.textContent = `${progressPercent}%`; | |
| } | |
| // Web Audio API: Extract audio from video blob | |
| async function extractAudioFromVideo(file) { | |
| const fileReader = new FileReader(); | |
| const arrayBufferPromise = new Promise((resolve, reject) => { | |
| fileReader.onload = () => resolve(fileReader.result); | |
| fileReader.onerror = () => reject(new Error('ไม่สามารถอ่านไฟล์วิดีโอได้')); | |
| }); | |
| fileReader.readAsArrayBuffer(file); | |
| const arrayBuffer = await arrayBufferPromise; | |
| // Use AudioContext to decode | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| const audioCtx = new AudioContextClass(); | |
| try { | |
| return await audioCtx.decodeAudioData(arrayBuffer); | |
| } catch (e) { | |
| throw new Error('ไม่สามารถถอดรหัสเสียงจากวิดีโอนี้ได้ (บราวเซอร์ไม่รองรับ Codec นี้)'); | |
| } finally { | |
| audioCtx.close(); | |
| } | |
| } | |
| // Web Worker Helper: Encode AudioBuffer to WAV | |
| function encodeAudioToWav(audioBuffer) { | |
| return new Promise((resolve, reject) => { | |
| const channelData = audioBuffer.getChannelData(0); // Mono channel | |
| const sampleRate = audioBuffer.sampleRate; | |
| // Load web worker | |
| const worker = new Worker('wav-worker.js'); | |
| worker.postMessage({ | |
| channelData: channelData, | |
| sampleRate: sampleRate | |
| }); | |
| worker.onmessage = function(e) { | |
| const data = e.data; | |
| if (data.type === 'progress') { | |
| // Map progress from worker into loader progress (40% to 70% range) | |
| const mappedProgress = Math.round(40 + (data.progress * 0.3)); | |
| updateLoadingUI(`แปลงสัญญาณเสียงเป็น 16kHz WAV (${data.status === 'downsampling' ? 'Downsampling' : 'Encoding'})...`, mappedProgress); | |
| } else if (data.type === 'done') { | |
| const blob = new Blob([data.buffer], { type: 'audio/wav' }); | |
| worker.terminate(); | |
| resolve(blob); | |
| } else if (data.type === 'error') { | |
| worker.terminate(); | |
| reject(new Error(data.message)); | |
| } | |
| }; | |
| worker.onerror = function(err) { | |
| worker.terminate(); | |
| reject(new Error('เกิดความล้มเหลวในการทำงานของ Web Worker')); | |
| }; | |
| }); | |
| } | |
| // Call OpenTyphoon Speech-To-Text API | |
| async function callTyphoonASR(wavBlob) { | |
| const formData = new FormData(); | |
| formData.append('file', wavBlob, 'audio.wav'); | |
| formData.append('model', state.model); | |
| formData.append('response_format', 'verbose_json'); | |
| if (state.language) { | |
| formData.append('language', state.language); | |
| } | |
| const url = 'https://api.opentyphoon.ai/v1/audio/transcriptions'; | |
| const headers = { | |
| 'Authorization': `Bearer ${state.apiKey}` | |
| }; | |
| const response = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| let errorJson; | |
| try { | |
| errorJson = JSON.parse(errorText); | |
| } catch(e) {} | |
| const msg = errorJson?.error?.message || errorText || `HTTP ${response.status}`; | |
| throw new Error(`การสื่อสารกับ API ล้มเหลว: ${msg}`); | |
| } | |
| return await response.json(); | |
| } | |
| // Generate high-quality mock data for demo mode | |
| function generateDemoTranscript(duration) { | |
| const baseSegments = [ | |
| { text: "สวัสดีครับ ยินดีต้อนรับสู่แอปพลิเคชันถอดสคริปต์วิดีโอด้วยเทคโนโลยี AI จาก Typhoon" }, | |
| { text: "โดยแอปพลิเคชันนี้ทำงานบนเว็บบราวเซอร์ของคุณโดยตรง มีความปลอดภัยสูง และทำงานได้รวดเร็ว" }, | |
| { text: "คุณสามารถสกัดเสียงจากวิดีโอ แปลงฟอร์แมต และส่งให้โมเดล Typhoon ASR ถอดความออกมา" }, | |
| { text: "ระบบอินเตอร์แอคทีฟของเราจะไฮไลต์ข้อความประโยคต่างๆ ตามที่กำลังเล่นอยู่ในวิดีโอแบบสดๆ" }, | |
| { text: "หากคุณต้องการย้อนกลับไปฟังเฉพาะบางคำหรือบางประโยค ก็เพียงแค่คลิกเลือกประโยคนั้นเพื่อรับชมได้ทันที" }, | |
| { text: "และสุดท้าย คุณยังสามารถดาวน์โหลดเอกสารเก็บไว้ใช้งานต่อได้ ไม่ว่าจะเป็นรูปแบบไฟล์ Text ธรรมดา หรือ ซับไตเติล SRT และ VTT" } | |
| ]; | |
| const count = baseSegments.length; | |
| const segmentDuration = duration / count; | |
| const segments = baseSegments.map((seg, index) => { | |
| return { | |
| id: index, | |
| start: index * segmentDuration, | |
| end: (index + 1) * segmentDuration, | |
| text: seg.text | |
| }; | |
| }); | |
| const fullText = segments.map(s => s.text).join(' '); | |
| return { | |
| text: fullText, | |
| segments: segments | |
| }; | |
| } | |
| // Render Transcript results into UI | |
| function renderTranscript(data) { | |
| // Render Full Text | |
| els.fullTextContent.innerHTML = `<p>${data.text}</p>`; | |
| // Render Interactive Segments | |
| els.segmentsList.innerHTML = ''; | |
| if (data.segments && data.segments.length > 0) { | |
| data.segments.forEach((seg, index) => { | |
| const segmentEl = document.createElement('div'); | |
| segmentEl.className = 'segment-item'; | |
| segmentEl.dataset.index = index; | |
| segmentEl.dataset.start = seg.start; | |
| segmentEl.dataset.end = seg.end; | |
| const timeStr = formatTimeShort(seg.start); | |
| segmentEl.innerHTML = ` | |
| <span class="segment-time" title="คลิกเพื่อข้ามวิดีโอไปช่วงเวลานี้">${timeStr}</span> | |
| <span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${seg.text}</span> | |
| `; | |
| // Jump video when clicking on a segment (but not if editing text) | |
| segmentEl.addEventListener('click', (e) => { | |
| if (e.target.classList.contains('segment-text')) { | |
| return; // Avoid jumping video when trying to edit text | |
| } | |
| els.videoPlayer.currentTime = seg.start; | |
| els.videoPlayer.play(); | |
| }); | |
| const textEl = segmentEl.querySelector('.segment-text'); | |
| // Save changes on blur | |
| textEl.addEventListener('blur', () => { | |
| const newText = textEl.textContent.trim(); | |
| if (newText !== seg.text) { | |
| seg.text = newText; | |
| // Sync with state.transcriptData | |
| state.transcriptData.text = state.transcriptData.segments.map(s => s.text).join(' '); | |
| els.fullTextContent.innerHTML = `<p>${state.transcriptData.text}</p>`; | |
| showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success'); | |
| } | |
| }); | |
| // Pressing enter blurs input (saves changes) | |
| textEl.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| textEl.blur(); | |
| } | |
| }); | |
| els.segmentsList.appendChild(segmentEl); | |
| }); | |
| } else { | |
| // If API response doesn't contain segments array, construct single segment | |
| els.segmentsList.innerHTML = ` | |
| <div class="segment-item" data-index="0" data-start="0" data-end="${els.videoPlayer.duration || 10}"> | |
| <span class="segment-time">00:00</span> | |
| <span class="segment-text" contenteditable="true" spellcheck="false" title="คลิกเพื่อแก้ไขข้อความ">${data.text}</span> | |
| </div> | |
| `; | |
| const segmentEl = els.segmentsList.querySelector('.segment-item'); | |
| const textEl = segmentEl.querySelector('.segment-text'); | |
| segmentEl.addEventListener('click', (e) => { | |
| if (e.target.classList.contains('segment-text')) return; | |
| els.videoPlayer.currentTime = 0; | |
| els.videoPlayer.play(); | |
| }); | |
| textEl.addEventListener('blur', () => { | |
| const newText = textEl.textContent.trim(); | |
| if (newText !== data.text) { | |
| data.text = newText; | |
| els.fullTextContent.innerHTML = `<p>${newText}</p>`; | |
| showNotification('บันทึกการแก้ไขคำถอดความแล้ว', 'success'); | |
| } | |
| }); | |
| textEl.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| textEl.blur(); | |
| } | |
| }); | |
| } | |
| } | |
| // Sync video timeline with highlighted transcript segment | |
| function syncTranscriptHighlight() { | |
| if (!state.transcriptData || !state.transcriptData.segments) return; | |
| const currentTime = els.videoPlayer.currentTime; | |
| const segments = state.transcriptData.segments; | |
| // Find current segment index | |
| let activeIndex = -1; | |
| for (let i = 0; i < segments.length; i++) { | |
| if (currentTime >= segments[i].start && currentTime <= segments[i].end) { | |
| activeIndex = i; | |
| break; | |
| } | |
| } | |
| // If active segment changed, update classes and scroll | |
| if (activeIndex !== state.currentActiveIndex) { | |
| state.currentActiveIndex = activeIndex; | |
| // Remove active state from all | |
| const items = els.segmentsList.querySelectorAll('.segment-item'); | |
| items.forEach(item => item.classList.remove('active')); | |
| if (activeIndex !== -1) { | |
| const activeItem = els.segmentsList.querySelector(`.segment-item[data-index="${activeIndex}"]`); | |
| if (activeItem) { | |
| activeItem.classList.add('active'); | |
| // Auto scroll segment list to center the active item | |
| const containerHeight = els.segmentsList.clientHeight; | |
| const itemTop = activeItem.offsetTop; | |
| const itemHeight = activeItem.clientHeight; | |
| els.segmentsList.scrollTo({ | |
| top: itemTop - (containerHeight / 2) + (itemHeight / 2), | |
| behavior: 'smooth' | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Handle search functionality inside segments | |
| function handleSearch(e) { | |
| const query = e.target.value.toLowerCase().trim(); | |
| const items = els.segmentsList.querySelectorAll('.segment-item'); | |
| items.forEach(item => { | |
| const text = item.querySelector('.segment-text').textContent.toLowerCase(); | |
| if (text.includes(query)) { | |
| item.classList.remove('hidden-search'); | |
| // Highlight matching search term in text | |
| if (query !== '') { | |
| const originalText = state.transcriptData.segments[item.dataset.index].text; | |
| const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); | |
| item.querySelector('.segment-text').innerHTML = originalText.replace(regex, '<mark class="highlight-mark">$1</mark>'); | |
| } else { | |
| item.querySelector('.segment-text').innerHTML = state.transcriptData.segments[item.dataset.index].text; | |
| } | |
| } else { | |
| item.classList.add('hidden-search'); | |
| } | |
| }); | |
| } | |
| function escapeRegExp(string) { | |
| return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| // Helpers: Time Formatting | |
| function formatTimeShort(seconds) { | |
| const m = Math.floor(seconds / 60).toString().padStart(2, '0'); | |
| const s = Math.floor(seconds % 60).toString().padStart(2, '0'); | |
| return `${m}:${s}`; | |
| } | |
| function formatTimeSRT(seconds) { | |
| const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); | |
| const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); | |
| const s = Math.floor(seconds % 60).toString().padStart(2, '0'); | |
| const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0'); | |
| return `${h}:${m}:${s},${ms}`; | |
| } | |
| function formatTimeVTT(seconds) { | |
| const h = Math.floor(seconds / 3600).toString().padStart(2, '0'); | |
| const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); | |
| const s = Math.floor(seconds % 60).toString().padStart(2, '0'); | |
| const ms = Math.floor((seconds % 1) * 1000).toString().padStart(3, '0'); | |
| return `${h}:${m}:${s}.${ms}`; | |
| } | |
| // Export Transcript in diverse formats | |
| function exportTranscript(format) { | |
| if (!state.transcriptData) { | |
| showNotification('ไม่มีข้อมูลทรานสคริปต์ให้ดาวน์โหลด', 'error'); | |
| return; | |
| } | |
| let content = ''; | |
| let filename = `${state.videoFile ? state.videoFile.name.replace(/\.[^/.]+$/, "") : "transcript"}`; | |
| let mimeType = 'text/plain'; | |
| const segments = state.transcriptData.segments || []; | |
| if (format === 'txt') { | |
| content = state.transcriptData.text; | |
| filename += '.txt'; | |
| } else if (format === 'srt') { | |
| filename += '.srt'; | |
| content = segments.map((seg, index) => { | |
| return `${index + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text}\n`; | |
| }).join('\n'); | |
| } else if (format === 'vtt') { | |
| filename += '.vtt'; | |
| content = 'WEBVTT\n\n' + segments.map((seg, index) => { | |
| return `${index + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text}\n`; | |
| }).join('\n'); | |
| } else if (format === 'json') { | |
| filename += '.json'; | |
| content = JSON.stringify(state.transcriptData, null, 2); | |
| mimeType = 'application/json'; | |
| } | |
| // Trigger browser download | |
| const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| showNotification(`ดาวน์โหลดไฟล์ ${format.toUpperCase()} สำเร็จ`, 'success'); | |
| } | |
| // Utility: Show Toast Notifications | |
| function showNotification(message, type = 'info') { | |
| // Check if notification container exists, if not create it | |
| let container = document.getElementById('notificationContainer'); | |
| if (!container) { | |
| container = document.createElement('div'); | |
| container.id = 'notificationContainer'; | |
| document.body.appendChild(container); | |
| } | |
| const toast = document.createElement('div'); | |
| toast.className = `toast toast-${type}`; | |
| let icon = '🔔'; | |
| if (type === 'success') icon = '✅'; | |
| if (type === 'error') icon = '❌'; | |
| if (type === 'warning') icon = '⚠️'; | |
| toast.innerHTML = ` | |
| <span class="toast-icon">${icon}</span> | |
| <span class="toast-message">${message}</span> | |
| `; | |
| container.appendChild(toast); | |
| // Triggers smooth slide in | |
| setTimeout(() => toast.classList.add('show'), 10); | |
| // Remove toast after 4s | |
| setTimeout(() => { | |
| toast.classList.remove('show'); | |
| setTimeout(() => { | |
| toast.remove(); | |
| }, 300); | |
| }, 4000); | |
| } | |
| // Delay helper | |
| function delay(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| // Start the app when content is loaded | |
| document.addEventListener('DOMContentLoaded', init); | |