/** * UI management composable * Handles dark mode, color schemes, sidebar, and other UI state */ export function useUI(state, utils, processedTranscription) { const { isDarkMode, currentColorScheme, colorSchemes, isSidebarCollapsed, showColorSchemeModal, isUserMenuOpen, currentView, selectedRecording, windowWidth, isMobileScreen, showAdvancedFilters, showSortOptions, searchTipsExpanded, isMetadataExpanded, editingParticipants, editingMeetingDate, editingSummary, tempSummaryContent, summaryMarkdownEditorInstance, leftColumnWidth, rightColumnWidth, isResizing, playerVolume, audioIsPlaying, audioCurrentTime, audioDuration, audioIsMuted, audioIsLoading, editingNotes, tempNotesContent, transcriptionViewMode, notesMarkdownEditor, markdownEditorInstance, autoSaveTimer, csrfToken, summaryMarkdownEditor, recordingNotesEditor, recordingMarkdownEditorInstance, recordingNotes, showDownloadMenu, currentPlayingSegmentIndex, followPlayerMode, playbackRate, showSpeedMenu, playbackSpeeds, modalPlaybackRate, speedMenuPosition, videoFullscreen, fullscreenControlsVisible, fullscreenControlsTimer, videoCollapsed } = state; const autoSaveDelay = 2000; // 2 seconds const { showToast, nextTick, t } = utils; const { ref, computed, watch } = Vue; // isMobile computed const isMobile = computed(() => windowWidth.value < 768); // Toggle dark mode const toggleDarkMode = () => { isDarkMode.value = !isDarkMode.value; localStorage.setItem('darkMode', isDarkMode.value); if (isDarkMode.value) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } // Re-apply current color scheme for new mode applyColorScheme(currentColorScheme.value); }; // Initialize dark mode from storage const initializeDarkMode = () => { const savedMode = localStorage.getItem('darkMode'); if (savedMode !== null) { isDarkMode.value = savedMode === 'true'; } else { // Check system preference isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches; } if (isDarkMode.value) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }; // Apply a color scheme const applyColorScheme = (schemeId, mode = null) => { const targetMode = mode || (isDarkMode.value ? 'dark' : 'light'); const scheme = colorSchemes[targetMode].find(s => s.id === schemeId); if (!scheme) { console.warn(`Color scheme '${schemeId}' not found for mode '${targetMode}'`); return; } // Remove all theme classes const allThemeClasses = [ ...colorSchemes.light.map(s => s.class), ...colorSchemes.dark.map(s => s.class) ].filter(c => c !== ''); document.documentElement.classList.remove(...allThemeClasses); // Add new theme class if not default if (scheme.class) { document.documentElement.classList.add(scheme.class); } currentColorScheme.value = schemeId; localStorage.setItem('colorScheme', schemeId); }; // Initialize color scheme from storage const initializeColorScheme = () => { const savedScheme = localStorage.getItem('colorScheme'); if (savedScheme) { applyColorScheme(savedScheme); } else { // Apply default scheme applyColorScheme('blue'); } }; // Open color scheme modal const openColorSchemeModal = () => { showColorSchemeModal.value = true; isUserMenuOpen.value = false; }; // Close color scheme modal const closeColorSchemeModal = () => { showColorSchemeModal.value = false; }; // Select a color scheme const selectColorScheme = (schemeId) => { applyColorScheme(schemeId); showToast(t('messages.colorSchemeApplied'), 'fa-palette'); }; // Reset to default color scheme const resetColorScheme = () => { applyColorScheme('blue'); showToast(t('messages.colorSchemeReset'), 'fa-undo'); }; // Toggle sidebar const toggleSidebar = () => { isSidebarCollapsed.value = !isSidebarCollapsed.value; localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value); }; // Initialize sidebar state const initializeSidebar = () => { const saved = localStorage.getItem('sidebarCollapsed'); if (saved !== null) { isSidebarCollapsed.value = saved === 'true'; } }; // Switch to upload view const switchToUploadView = () => { currentView.value = 'upload'; if (isMobileScreen.value) { isSidebarCollapsed.value = true; } }; // Switch to detail view const switchToDetailView = () => { currentView.value = 'detail'; }; // Switch to recording view const switchToRecordingView = () => { currentView.value = 'recording'; if (isMobileScreen.value) { isSidebarCollapsed.value = true; } }; // Set global error const setGlobalError = (message, duration = 7000) => { if (state.globalError) { state.globalError.value = message; if (duration > 0) { setTimeout(() => { if (state.globalError.value === message) { state.globalError.value = null; } }, duration); } } }; // Format file size const formatFileSize = (bytes) => { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // Format display date const formatDisplayDate = (dateString) => { if (!dateString) return ''; const date = new Date(dateString); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; // Format short date const formatShortDate = (dateString) => { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const diff = now - date; const oneDay = 24 * 60 * 60 * 1000; if (diff < oneDay) { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } else if (diff < 7 * oneDay) { return date.toLocaleDateString(undefined, { weekday: 'short' }); } else { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } }; // Format status const formatStatus = (status) => { if (!status || status === 'COMPLETED') return ''; const statusMap = { 'PENDING': t('status.queued'), 'QUEUED': t('status.queued'), 'PROCESSING': t('status.processing'), 'TRANSCRIBING': t('status.transcribing'), 'SUMMARIZING': t('status.summarizing'), 'FAILED': t('status.failed'), 'UPLOADING': t('status.uploading') }; return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); }; // Get status class const getStatusClass = (status) => { switch(status) { case 'PENDING': return 'status-pending'; case 'QUEUED': return 'status-pending'; case 'PROCESSING': return 'status-processing'; case 'SUMMARIZING': return 'status-summarizing'; case 'COMPLETED': return ''; case 'FAILED': return 'status-failed'; default: return 'status-pending'; } }; // Format time (seconds to HH:MM:SS) const formatTime = (seconds) => { if (!seconds && seconds !== 0) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } return `${m}:${s.toString().padStart(2, '0')}`; }; // Format duration in seconds to human readable const formatDuration = (seconds) => { if (!seconds && seconds !== 0) return ''; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const parts = []; if (h > 0) parts.push(`${h}h`); if (m > 0) parts.push(`${m}m`); if (s > 0 || parts.length === 0) parts.push(`${s}s`); return parts.join(' '); }; // Format processing duration const formatProcessingDuration = (seconds) => { if (!seconds) return ''; if (seconds < 60) { return `${Math.round(seconds)}s`; } else if (seconds < 3600) { const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return `${m}m ${s}s`; } else { const h = Math.floor(seconds / 3600); const m = Math.round((seconds % 3600) / 60); return `${h}h ${m}m`; } }; // --- Inline Editing --- const saveInlineEdit = async (field) => { if (!selectedRecording.value) return; const fullPayload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date }; try { const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfTokenValue }, body: JSON.stringify(fullPayload) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to save metadata'); // Update the recording with returned HTML if (data.recording) { if (field === 'notes' && data.recording.notes_html) { selectedRecording.value.notes_html = data.recording.notes_html; } else if (field === 'summary' && data.recording.summary_html) { selectedRecording.value.summary_html = data.recording.summary_html; } } } catch (error) { showToast(t('messages.failedToSave', { error: error.message }), 'fa-exclamation-circle', 3000, 'error'); } }; const toggleEditParticipants = () => { editingParticipants.value = !editingParticipants.value; if (!editingParticipants.value) { saveInlineEdit('participants'); } }; const toggleEditMeetingDate = () => { editingMeetingDate.value = !editingMeetingDate.value; if (!editingMeetingDate.value) { saveInlineEdit('meeting_date'); } }; const toggleEditTitle = () => { if (!selectedRecording.value) return; // Check if user has permission to edit if (selectedRecording.value.can_edit === false) { showToast(t('messages.noPermissionToEdit'), 'fa-exclamation-circle', 3000, 'error'); return; } if (!state.editingTitle.value) { // Start editing state.originalTitle.value = selectedRecording.value.title || ''; state.editingTitle.value = true; nextTick(() => { // Focus the input field const titleInput = document.querySelector('input[ref="titleInput"]'); if (titleInput) { titleInput.focus(); titleInput.select(); } }); } }; const saveTitle = async () => { if (!selectedRecording.value) return; state.editingTitle.value = false; // Only save if title changed if (selectedRecording.value.title !== state.originalTitle.value) { await saveInlineEdit('title'); } }; const cancelEditTitle = () => { if (!selectedRecording.value) return; // Restore original title selectedRecording.value.title = state.originalTitle.value; state.editingTitle.value = false; }; const toggleEditSummary = () => { editingSummary.value = !editingSummary.value; if (editingSummary.value) { tempSummaryContent.value = selectedRecording.value?.summary || ''; nextTick(() => { initializeSummaryMarkdownEditor(); }); } }; const cancelEditSummary = () => { if (summaryMarkdownEditorInstance.value) { summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } editingSummary.value = false; // Restore original content if (selectedRecording.value) { selectedRecording.value.summary = tempSummaryContent.value; } }; const saveEditSummary = async () => { if (summaryMarkdownEditorInstance.value) { selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } editingSummary.value = false; await saveInlineEdit('summary'); }; const initializeSummaryMarkdownEditor = () => { if (!summaryMarkdownEditor.value) return; try { summaryMarkdownEditorInstance.value = new EasyMDE({ element: summaryMarkdownEditor.value, spellChecker: false, autofocus: true, placeholder: t('form.enterSummaryMarkdown'), initialValue: selectedRecording.value?.summary || '', status: false, toolbar: [ "bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "side-by-side", "fullscreen" ], previewClass: ["editor-preview", "notes-preview"], theme: isDarkMode.value ? "dark" : "light" }); // Add auto-save functionality summaryMarkdownEditorInstance.value.codemirror.on('change', () => { if (autoSaveTimer.value) { clearTimeout(autoSaveTimer.value); } autoSaveTimer.value = setTimeout(() => { autoSaveSummary(); }, autoSaveDelay); }); } catch (error) { console.error('Failed to initialize summary markdown editor:', error); editingSummary.value = true; } }; const autoSaveSummary = async () => { if (summaryMarkdownEditorInstance.value && editingSummary.value) { // Just save the content to the model, don't exit edit mode selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); // Silently save to backend without changing UI state try { const payload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date }; const response = await fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken.value }, body: JSON.stringify(payload) }); const data = await response.json(); if (response.ok && data.recording) { // Update the HTML rendered versions if they exist if (data.recording.summary_html) { selectedRecording.value.summary_html = data.recording.summary_html; } } else { console.error('Failed to auto-save summary'); } } catch (error) { console.error('Error auto-saving summary:', error); } } }; const toggleTranscriptionViewMode = () => { transcriptionViewMode.value = transcriptionViewMode.value === 'simple' ? 'bubble' : 'simple'; localStorage.setItem('transcriptionViewMode', transcriptionViewMode.value); }; const toggleEditNotes = () => { editingNotes.value = !editingNotes.value; if (editingNotes.value) { tempNotesContent.value = selectedRecording.value?.notes || ''; // Initialize markdown editor when entering edit mode nextTick(() => { initializeMarkdownEditor(); }); } }; const cancelEditNotes = () => { if (markdownEditorInstance.value) { markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } editingNotes.value = false; // Restore original content if (selectedRecording.value) { selectedRecording.value.notes = tempNotesContent.value; } }; const saveEditNotes = async () => { if (markdownEditorInstance.value) { // Get the markdown content from the editor selectedRecording.value.notes = markdownEditorInstance.value.value(); markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } editingNotes.value = false; await saveInlineEdit('notes'); }; const initializeMarkdownEditor = () => { if (!notesMarkdownEditor.value) return; try { markdownEditorInstance.value = new EasyMDE({ element: notesMarkdownEditor.value, spellChecker: false, autofocus: true, placeholder: t('form.enterNotesMarkdown'), initialValue: selectedRecording.value?.notes || '', status: false, toolbar: [ "bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "side-by-side", "fullscreen" ], previewClass: ["editor-preview", "notes-preview"], theme: isDarkMode.value ? "dark" : "light" }); // Add auto-save functionality markdownEditorInstance.value.codemirror.on('change', () => { if (autoSaveTimer.value) { clearTimeout(autoSaveTimer.value); } autoSaveTimer.value = setTimeout(() => { autoSaveNotes(); }, autoSaveDelay); }); } catch (error) { console.error('Failed to initialize markdown editor:', error); // Fallback to regular textarea editing editingNotes.value = true; } }; const autoSaveNotes = async () => { if (markdownEditorInstance.value && editingNotes.value) { // Just save the content to the model, don't exit edit mode selectedRecording.value.notes = markdownEditorInstance.value.value(); // Silently save to backend without changing UI state try { const payload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date }; const response = await fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken.value }, body: JSON.stringify(payload) }); const data = await response.json(); if (response.ok && data.recording) { // Update the HTML rendered versions if they exist if (data.recording.notes_html) { selectedRecording.value.notes_html = data.recording.notes_html; } } else { console.error('Failed to auto-save notes'); } } catch (error) { console.error('Error auto-saving notes:', error); } } }; const clickToEditNotes = () => { // Allow clicking on empty notes area to start editing if (!editingNotes.value && (!selectedRecording.value?.notes || selectedRecording.value.notes.trim() === '')) { toggleEditNotes(); } }; const clickToEditSummary = () => { // Allow clicking on empty summary area to start editing if (!editingSummary.value && (!selectedRecording.value?.summary || selectedRecording.value.summary.trim() === '')) { toggleEditSummary(); } }; const downloadNotes = async () => { if (!selectedRecording.value || !selectedRecording.value.notes) { showToast(t('messages.noNotesAvailableDownload'), 'fa-exclamation-circle'); return; } try { const response = await fetch(`/recording/${selectedRecording.value.id}/download/notes`); if (!response.ok) { const error = await response.json(); showToast(error.error || t('messages.notesDownloadFailed'), 'fa-exclamation-circle'); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${selectedRecording.value.title || 'notes'}.md`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(t('messages.notesDownloadSuccess')); } catch (error) { showToast(t('messages.notesDownloadFailed'), 'fa-exclamation-circle'); } }; const downloadEventICS = async (event) => { if (!event || !event.id) { showToast(t('messages.invalidEventData'), 'fa-exclamation-circle'); return; } try { const response = await fetch(`/api/event/${event.id}/ics`); if (!response.ok) { const error = await response.json(); showToast(error.error || t('messages.eventDownloadFailed'), 'fa-exclamation-circle'); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `${event.title || 'event'}.ics`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(t('messages.eventDownloadSuccess', { title: event.title }), 'fa-calendar-check', 3000); } catch (error) { console.error('Download failed:', error); showToast(t('messages.eventDownloadFailed'), 'fa-exclamation-circle'); } }; const downloadICS = async () => { if (!selectedRecording.value || !selectedRecording.value.events || selectedRecording.value.events.length === 0) { showToast(t('messages.noEventsToExport'), 'fa-exclamation-circle'); return; } try { const response = await fetch(`/api/recording/${selectedRecording.value.id}/events/ics`); if (!response.ok) { const error = await response.json(); showToast(error.error || t('messages.eventsExportFailed'), 'fa-exclamation-circle'); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `events-${selectedRecording.value.title || selectedRecording.value.id}.ics`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(t('messages.eventsExportSuccess', { count: selectedRecording.value.events.length }), 'fa-calendar-check'); } catch (error) { console.error('Download all events ICS error:', error); showToast(t('messages.eventsExportFailed'), 'fa-exclamation-circle'); } }; const deleteEvent = async (event) => { if (!event || !event.id) { showToast(t('messages.invalidEventData'), 'fa-exclamation-circle'); return; } // Confirm deletion if (!confirm(t('events.confirmDelete', { title: event.title }) || `Delete event "${event.title}"?`)) { return; } try { const response = await fetch(`/api/event/${event.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || '' } }); if (!response.ok) { const error = await response.json(); showToast(error.error || t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle'); return; } // Remove event from local state if (selectedRecording.value && selectedRecording.value.events) { selectedRecording.value.events = selectedRecording.value.events.filter(e => e.id !== event.id); } showToast(t('events.deleted') || 'Event deleted', 'fa-check-circle'); } catch (error) { console.error('Delete event failed:', error); showToast(t('events.deleteFailed') || 'Failed to delete event', 'fa-exclamation-circle'); } }; const formatEventDateTime = (dateTimeStr) => { if (!dateTimeStr) return ''; try { const date = new Date(dateTimeStr); const options = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }; return date.toLocaleString(undefined, options); } catch (e) { return dateTimeStr; } }; // --- Column Resizing --- const startColumnResize = (event) => { isResizing.value = true; const startX = event.clientX; const startLeftWidth = leftColumnWidth.value; const handleMouseMove = (e) => { if (!isResizing.value) return; const container = document.getElementById('mainContentColumns'); if (!container) return; const containerRect = container.getBoundingClientRect(); const deltaX = e.clientX - startX; const containerWidth = containerRect.width; const deltaPercent = (deltaX / containerWidth) * 100; let newLeftWidth = startLeftWidth + deltaPercent; newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); leftColumnWidth.value = newLeftWidth; rightColumnWidth.value = 100 - newLeftWidth; }; const handleMouseUp = () => { isResizing.value = false; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); localStorage.setItem('transcriptColumnWidth', leftColumnWidth.value); localStorage.setItem('summaryColumnWidth', rightColumnWidth.value); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); event.preventDefault(); }; // --- Audio Player --- const seekAudio = (time, context = 'main') => { let audioPlayer = null; if (context === 'modal') { audioPlayer = document.querySelector('audio.speaker-modal-transcript') || document.querySelector('video.speaker-modal-transcript'); } else { audioPlayer = document.querySelector('.main-content-area audio') || document.querySelector('.main-content-area video'); } if (!audioPlayer) { audioPlayer = document.querySelector('audio') || document.querySelector('video'); } if (audioPlayer && isFinite(time)) { const wasPlaying = !audioPlayer.paused; try { audioPlayer.currentTime = time; if (wasPlaying) { audioPlayer.play().catch(e => console.warn('Play after seek failed:', e)); } } catch (e) { console.warn('Seek failed:', e); } } }; const seekAudioFromEvent = (event) => { const segmentElement = event.target.closest('[data-start-time]'); if (!segmentElement) return; const time = parseFloat(segmentElement.dataset.startTime); if (isNaN(time)) return; const isInSpeakerModal = event.target.closest('.speaker-modal-transcript') !== null; const context = isInSpeakerModal ? 'modal' : 'main'; seekAudio(time, context); }; const onPlayerVolumeChange = (event) => { const newVolume = event.target.volume; playerVolume.value = newVolume; localStorage.setItem('playerVolume', newVolume); }; // --- Custom Audio Player Controls --- const getAudioElement = () => { // First check fullscreen overlay (teleported to body) const fullscreenMedia = document.querySelector('.video-fullscreen-overlay video'); if (fullscreenMedia) return fullscreenMedia; // Then check for audio/video in visible modals (z-50 class) - these take priority const modalMedia = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video'); if (modalMedia) { return modalMedia; } // Fall back to main player in right column (desktop) or detail view (mobile) return document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') || document.querySelector('.detail-view audio') || document.querySelector('.detail-view video') || document.querySelector('audio') || document.querySelector('video'); }; const toggleAudioPlayback = () => { const audio = getAudioElement(); if (!audio) return; if (audio.paused) { audio.play(); } else { audio.pause(); } }; const toggleAudioMute = () => { const audio = getAudioElement(); if (!audio) return; audio.muted = !audio.muted; audioIsMuted.value = audio.muted; }; const setAudioVolume = (volume) => { const audio = getAudioElement(); if (!audio) return; audio.volume = Math.max(0, Math.min(1, volume)); playerVolume.value = audio.volume; localStorage.setItem('playerVolume', audio.volume); if (audio.volume === 0) { audio.muted = true; audioIsMuted.value = true; } else if (audio.muted) { audio.muted = false; audioIsMuted.value = false; } }; const seekAudioTo = (time) => { const audio = getAudioElement(); if (!audio || !isFinite(time)) return; // Use our tracked duration (server-side) as fallback if browser duration is broken const maxTime = audioDuration.value || (isFinite(audio.duration) ? audio.duration : time); try { audio.currentTime = Math.max(0, Math.min(time, maxTime)); } catch (e) { console.warn('Seek failed:', e); } }; const seekAudioByPercent = (percent) => { const audio = getAudioElement(); // Use our tracked duration (server-side) which works for WebM files without duration metadata const dur = audioDuration.value || audio?.duration; if (!audio || !dur || !isFinite(dur)) return; const time = (percent / 100) * dur; try { audio.currentTime = time; } catch (e) { console.warn('Seek by percent failed:', e); } }; // Progress bar drag state const isDraggingProgress = ref(false); const dragPreviewPercent = ref(0); // Handle progress bar drag - supports both mouse and touch, only seeks on release const startProgressDrag = (event) => { const bar = event.currentTarget.querySelector('.progress-track') || event.currentTarget.querySelector('.h-2') || event.currentTarget; const rect = bar.getBoundingClientRect(); const isTouch = event.type === 'touchstart'; const getPercent = (evt) => { const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); }; const getPercentFromEnd = (evt) => { const clientX = isTouch ? evt.changedTouches[0].clientX : evt.clientX; return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); }; // Start dragging - show preview isDraggingProgress.value = true; dragPreviewPercent.value = getPercent(event); const onMove = (evt) => { evt.preventDefault(); const clientX = isTouch ? evt.touches[0].clientX : evt.clientX; dragPreviewPercent.value = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); }; const onUp = (evt) => { document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove); document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp); // Seek to final position on release seekAudioByPercent(dragPreviewPercent.value); isDraggingProgress.value = false; }; document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false }); document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp); }; const handleAudioPlayPause = (event) => { audioIsPlaying.value = !event.target.paused; }; const handleAudioLoadedMetadata = (event) => { const duration = event.target.duration; // Only set browser duration if we don't already have a server-side duration // Server-side duration (from ffprobe) is more reliable for formats like WebM if (!audioDuration.value || audioDuration.value === 0) { if (duration && isFinite(duration) && duration > 0) { audioDuration.value = duration; } } audioIsLoading.value = false; // Apply saved playback rate when audio loads if (playbackRate.value !== 1) { event.target.playbackRate = playbackRate.value; } }; const handleAudioEnded = () => { audioIsPlaying.value = false; audioCurrentTime.value = 0; }; const handleCustomAudioTimeUpdate = (event) => { audioCurrentTime.value = event.target.currentTime; // Fallback: if duration wasn't set yet, try to get it now if (!audioDuration.value || audioDuration.value === 0) { const duration = event.target.duration; if (duration && isFinite(duration) && duration > 0) { audioDuration.value = duration; } } // Also call the existing handler for segment tracking handleAudioTimeUpdate(event); }; const handleAudioWaiting = () => { audioIsLoading.value = true; }; const handleAudioCanPlay = (event) => { audioIsLoading.value = false; // Fallback: try to get duration if not set yet if (!audioDuration.value || audioDuration.value === 0) { const duration = event.target.duration; if (duration && isFinite(duration) && duration > 0) { audioDuration.value = duration; } } }; const handleAudioDurationChange = (event) => { // WebM and some other formats may initially report Infinity duration // This handler catches when the actual duration becomes available // Only set if we don't already have a server-side duration if (!audioDuration.value || audioDuration.value === 0) { const duration = event.target.duration; if (duration && isFinite(duration) && duration > 0) { audioDuration.value = duration; } } }; const formatAudioTime = (seconds) => { if (!seconds || isNaN(seconds)) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; // --- Playback Speed Control --- const formatPlaybackRate = (rate) => { if (rate === 1) return '1x'; if (rate === Math.floor(rate)) return `${rate}x`; return `${rate}x`; }; const setPlaybackRate = (rate) => { playbackRate.value = rate; localStorage.setItem('playbackRate', rate); // Apply to all audio and video elements document.querySelectorAll('audio, video').forEach(el => { el.playbackRate = rate; }); }; const cyclePlaybackRate = () => { const currentIndex = playbackSpeeds.indexOf(playbackRate.value); const nextIndex = (currentIndex + 1) % playbackSpeeds.length; setPlaybackRate(playbackSpeeds[nextIndex]); }; const cycleModalPlaybackRate = () => { const currentIndex = playbackSpeeds.indexOf(modalPlaybackRate.value); const nextIndex = (currentIndex + 1) % playbackSpeeds.length; modalPlaybackRate.value = playbackSpeeds[nextIndex]; // Apply to modal audio elements const modalAudio = document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('audio') || document.querySelector('.speaker-modal-transcript')?.closest('.fixed')?.querySelector('video') || document.querySelector('[ref="speakerModalAudioRef"]') || document.querySelector('[ref="asrEditorAudioRef"]'); if (modalAudio) { modalAudio.playbackRate = modalPlaybackRate.value; } }; const initializePlaybackRate = () => { const savedRate = localStorage.getItem('playbackRate'); if (savedRate) { const rate = parseFloat(savedRate); if (playbackSpeeds.includes(rate)) { playbackRate.value = rate; } } }; const updateSpeedMenuPosition = (buttonEl) => { if (!buttonEl) return; const rect = buttonEl.getBoundingClientRect(); const menuWidth = 52; const menuMaxHeight = 160; const gap = 4; const safeZone = 60; // Account for header/navbar // Check available space above and below const spaceAbove = rect.top - safeZone; const spaceBelow = window.innerHeight - rect.bottom - 8; // Decide direction: prefer above, but use below if not enough space const showBelow = spaceAbove < menuMaxHeight && spaceBelow > spaceAbove; const right = window.innerWidth - rect.right; if (showBelow) { // Position below the button speedMenuPosition.value = { right: `${Math.max(8, right)}px`, top: `${rect.bottom + gap}px`, bottom: 'auto', maxHeight: `${Math.min(menuMaxHeight, spaceBelow)}px` }; } else { // Position above the button speedMenuPosition.value = { right: `${Math.max(8, right)}px`, bottom: `${window.innerHeight - rect.top + gap}px`, top: 'auto', maxHeight: `${Math.min(menuMaxHeight, spaceAbove)}px` }; } }; const audioProgressPercent = computed(() => { // Use preview position while dragging for smooth UI if (isDraggingProgress.value) { return dragPreviewPercent.value; } if (!audioDuration.value) return 0; return (audioCurrentTime.value / audioDuration.value) * 100; }); // Preview time display while dragging const displayCurrentTime = computed(() => { if (isDraggingProgress.value && audioDuration.value) { return (dragPreviewPercent.value / 100) * audioDuration.value; } return audioCurrentTime.value; }); // Reset audio player state (called when recording changes) const resetAudioPlayerState = () => { audioIsPlaying.value = false; audioCurrentTime.value = 0; audioDuration.value = 0; audioIsMuted.value = false; audioIsLoading.value = false; }; // --- Active Segment Tracking --- // Binary search to find the segment containing the current time // Returns the index of the last segment where startTime <= currentTime // O(log n) instead of O(n) - critical for long transcriptions (4500+ segments) const binarySearchSegment = (segments, currentTime) => { if (segments.length === 0) return null; let low = 0; let high = segments.length - 1; let result = null; while (low <= high) { const mid = Math.floor((low + high) / 2); const startTime = segments[mid].startTime || segments[mid].start_time; if (startTime === undefined) { // Skip segments without timing info high = mid - 1; continue; } if (startTime <= currentTime) { result = mid; // This segment is a candidate low = mid + 1; // Look for later segments that might also match } else { high = mid - 1; // Current time is before this segment } } return result; }; const handleAudioTimeUpdate = (event) => { const transcription = processedTranscription.value; if (!transcription || !transcription.isJson) { return; } const audioElement = event.target; const currentTime = audioElement.currentTime; // Find the segment that contains the current time const segments = transcription.simpleSegments || []; if (segments.length === 0) { return; } // Find the active segment index using binary search - O(log n) const activeIndex = binarySearchSegment(segments, currentTime); // Only update if changed if (activeIndex !== currentPlayingSegmentIndex.value) { currentPlayingSegmentIndex.value = activeIndex; // Scroll to active segment if follow mode is enabled if (followPlayerMode.value && activeIndex !== null) { scrollToActiveSegment(activeIndex); } } }; const scrollToActiveSegment = (segmentIndex) => { // Find the active segment element const segments = document.querySelectorAll('.transcript-segment[data-segment-index], .speaker-segment[data-segment-index], .speaker-bubble[data-segment-index]'); if (segments[segmentIndex]) { segments[segmentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } }; const toggleFollowPlayerMode = () => { followPlayerMode.value = !followPlayerMode.value; localStorage.setItem('followPlayerMode', followPlayerMode.value); if (followPlayerMode.value) { showToast(t('messages.followPlayerEnabled'), 'fa-link'); // Scroll to current position if we have an active segment if (currentPlayingSegmentIndex.value !== null) { scrollToActiveSegment(currentPlayingSegmentIndex.value); } } else { showToast(t('messages.followPlayerDisabled'), 'fa-unlink'); } }; // --- Video Fullscreen --- const handleFullscreenKeydown = (e) => { if (e.key === 'Escape' || e.key === 'f') { exitVideoFullscreen(); } else if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggleAudioPlayback(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); seekAudioTo(audioCurrentTime.value - 10); } else if (e.key === 'ArrowRight') { e.preventDefault(); seekAudioTo(audioCurrentTime.value + 10); } else if (e.key === 'ArrowUp') { e.preventDefault(); const audio = getAudioElement(); if (audio) setAudioVolume(Math.min(1, audio.volume + 0.1)); } else if (e.key === 'ArrowDown') { e.preventDefault(); const audio = getAudioElement(); if (audio) setAudioVolume(Math.max(0, audio.volume - 0.1)); } }; const resetFullscreenControlsTimer = () => { fullscreenControlsVisible.value = true; if (fullscreenControlsTimer.value) { clearTimeout(fullscreenControlsTimer.value); } fullscreenControlsTimer.value = setTimeout(() => { if (audioIsPlaying.value) { fullscreenControlsVisible.value = false; } }, 3000); }; const handleFullscreenMouseMove = () => { resetFullscreenControlsTimer(); }; const enterVideoFullscreen = () => { videoFullscreen.value = true; videoCollapsed.value = false; fullscreenControlsVisible.value = true; resetFullscreenControlsTimer(); document.addEventListener('keydown', handleFullscreenKeydown); document.body.style.overflow = 'hidden'; }; const exitVideoFullscreen = () => { videoFullscreen.value = false; fullscreenControlsVisible.value = true; if (fullscreenControlsTimer.value) { clearTimeout(fullscreenControlsTimer.value); fullscreenControlsTimer.value = null; } document.removeEventListener('keydown', handleFullscreenKeydown); document.body.style.overflow = ''; }; // --- Copy Functions --- const animateCopyButton = (button) => { if (!button) return; const icon = button.querySelector('i'); if (icon) { const originalClass = icon.className; icon.className = 'fas fa-check'; setTimeout(() => { icon.className = originalClass; }, 2000); } }; const fallbackCopyTextToClipboard = (text) => { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); showToast(t('messages.copiedSuccessfully')); } catch (err) { showToast(t('messages.copyFailed'), 'fa-exclamation-circle'); } document.body.removeChild(textArea); }; const copyTranscription = (event) => { if (!selectedRecording.value || !selectedRecording.value.transcription) { showToast(t('messages.noTranscriptionToCopy'), 'fa-exclamation-circle'); return; } const button = event?.currentTarget; let textToCopy = ''; try { const transcriptionData = JSON.parse(selectedRecording.value.transcription); if (Array.isArray(transcriptionData)) { const wasDiarized = transcriptionData.some(segment => segment.speaker); if (wasDiarized) { textToCopy = transcriptionData.map(segment => { return `[${segment.speaker}]: ${segment.text || segment.sentence}`; }).join('\n'); } else { textToCopy = transcriptionData.map(segment => segment.text || segment.sentence).join('\n'); } } else { textToCopy = selectedRecording.value.transcription; } } catch (e) { textToCopy = selectedRecording.value.transcription; } animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(textToCopy) .then(() => showToast(t('messages.transcriptionCopied'))) .catch(() => fallbackCopyTextToClipboard(textToCopy)); } else { fallbackCopyTextToClipboard(textToCopy); } }; const copySummary = (event) => { if (!selectedRecording.value || !selectedRecording.value.summary) { showToast(t('messages.noSummaryToCopy'), 'fa-exclamation-circle'); return; } const button = event?.currentTarget; animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(selectedRecording.value.summary) .then(() => showToast(t('messages.summaryCopied'))) .catch(() => fallbackCopyTextToClipboard(selectedRecording.value.summary)); } else { fallbackCopyTextToClipboard(selectedRecording.value.summary); } }; const copyNotes = (event) => { if (!selectedRecording.value || !selectedRecording.value.notes) { showToast(t('messages.noNotesToCopy'), 'fa-exclamation-circle'); return; } const button = event?.currentTarget; animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(selectedRecording.value.notes) .then(() => showToast(t('messages.notesCopied'))) .catch(() => fallbackCopyTextToClipboard(selectedRecording.value.notes)); } else { fallbackCopyTextToClipboard(selectedRecording.value.notes); } }; // --- Download Functions --- const downloadSummary = async () => { if (!selectedRecording.value || !selectedRecording.value.summary) { showToast(t('messages.noSummaryToDownload'), 'fa-exclamation-circle'); return; } try { const response = await fetch(`/recording/${selectedRecording.value.id}/download/summary`); if (!response.ok) { const error = await response.json(); showToast(error.error || t('messages.summaryDownloadFailed'), 'fa-exclamation-circle'); return; } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; const contentDisposition = response.headers.get('Content-Disposition'); let filename = 'summary.docx'; if (contentDisposition) { const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); if (utf8Match) { filename = decodeURIComponent(utf8Match[1]); } else { const regularMatch = /filename="(.+)"/.exec(contentDisposition); if (regularMatch) { filename = regularMatch[1]; } } } a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast(t('messages.summaryDownloadSuccess')); } catch (error) { showToast(t('messages.summaryDownloadFailed'), 'fa-exclamation-circle'); } }; const downloadTranscriptWord = async () => { if (!selectedRecording.value || !selectedRecording.value.transcription) { showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle'); return; } try { const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript/word`); if (!response.ok) { const error = await response.json(); showToast(error.error || 'Erreur lors du téléchargement Word', 'fa-exclamation-circle'); return; } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; const contentDisposition = response.headers.get('Content-Disposition'); let filename = 'transcript.docx'; if (contentDisposition) { const utf8Match = /filename\*=utf-8''(.+)/.exec(contentDisposition); if (utf8Match) { filename = decodeURIComponent(utf8Match[1]); } else { const regularMatch = /filename="(.+)"/.exec(contentDisposition); if (regularMatch) { filename = regularMatch[1]; } } } a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast('Transcription Word téléchargée !'); } catch (error) { showToast('Erreur lors du téléchargement Word', 'fa-exclamation-circle'); } }; const downloadTranscript = async () => { if (!selectedRecording.value || !selectedRecording.value.transcription) { showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle'); return; } try { // First, fetch available templates const templatesResponse = await fetch('/api/transcript-templates'); let templates = []; if (templatesResponse.ok) { templates = await templatesResponse.json(); } // If there are templates, show a selection dialog let templateId = null; if (templates.length > 0) { // Create a simple modal for template selection const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.innerHTML = `