/** * Transcription editing composable * Handles ASR editor, text editor, and segment management */ export function useTranscription(state, utils) { const { showTextEditorModal, showAsrEditorModal, selectedRecording, editingTranscriptionContent, editingSegments, availableSpeakers, recordings, dropdownPositions, openAsrDropdownIndex } = state; const { showToast, setGlobalError, nextTick } = utils; // ========================================= // Text Editor Modal // ========================================= const openTranscriptionEditor = () => { if (!selectedRecording.value || !selectedRecording.value.transcription) { return; } // Check if transcription is JSON (ASR format) try { const parsed = JSON.parse(selectedRecording.value.transcription); if (Array.isArray(parsed)) { openAsrEditorModal(); } else { openTextEditorModal(); } } catch (e) { // Not JSON, use text editor openTextEditorModal(); } }; const openTextEditorModal = () => { if (!selectedRecording.value) return; editingTranscriptionContent.value = selectedRecording.value.transcription || ''; showTextEditorModal.value = true; }; const closeTextEditorModal = () => { showTextEditorModal.value = false; editingTranscriptionContent.value = ''; }; const saveTranscription = async () => { if (!selectedRecording.value) return; await saveTranscriptionContent(editingTranscriptionContent.value); closeTextEditorModal(); }; // ========================================= // ASR Editor Modal // ========================================= // Helper to pause outer audio player when opening modals with their own player const pauseOuterAudioPlayer = () => { const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') || document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)'); if (outerAudio && !outerAudio.paused) { outerAudio.pause(); } }; const openAsrEditorModal = async () => { if (!selectedRecording.value) return; // Pause outer audio player to avoid conflicts with modal's player pauseOuterAudioPlayer(); try { const segments = JSON.parse(selectedRecording.value.transcription); // Populate available speakers from THIS recording only const speakersInTranscript = [...new Set(segments.map(s => s.speaker))].sort(); availableSpeakers.value = speakersInTranscript; editingSegments.value = segments.map((s, i) => ({ ...s, id: i, showSuggestions: false, filteredSpeakers: [...speakersInTranscript] })); showAsrEditorModal.value = true; // Reset virtual scroll state for fresh modal render if (utils.resetAsrEditorScroll) { utils.resetAsrEditorScroll(); } } catch (e) { console.error("Could not parse transcription as JSON for ASR editor:", e); setGlobalError("This transcription is not in the correct format for the ASR editor."); } }; const closeAsrEditorModal = () => { // Pause any playing modal audio before closing const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video'); if (modalAudio) { modalAudio.pause(); } // Reset modal audio state (keep main player independent) if (utils.resetModalAudioState) { utils.resetModalAudioState(); } showAsrEditorModal.value = false; editingSegments.value = []; }; const saveAsrTranscription = async () => { if (!selectedRecording.value) return; // Remove extra UI fields and save the rest const contentToSave = JSON.stringify(editingSegments.value.map(({ id, showSuggestions, filteredSpeakers, ...rest }) => rest)); await saveTranscriptionContent(contentToSave); closeAsrEditorModal(); }; // ========================================= // Segment Management // ========================================= const adjustTime = (index, field, amount) => { if (editingSegments.value[index]) { editingSegments.value[index][field] = Math.max(0, editingSegments.value[index][field] + amount ); } }; const filterSpeakerSuggestions = (index) => { const segment = editingSegments.value[index]; if (segment) { const query = segment.speaker?.toLowerCase() || ''; if (query === '') { segment.filteredSpeakers = [...availableSpeakers.value]; } else { segment.filteredSpeakers = availableSpeakers.value.filter( speaker => speaker.toLowerCase().includes(query) ); } } }; // O(1) dropdown management using single ref instead of O(n) forEach const openSpeakerSuggestions = (index) => { if (editingSegments.value[index]) { // Simply set the open index - O(1) instead of O(n) forEach openAsrDropdownIndex.value = index; filterSpeakerSuggestions(index); updateDropdownPosition(index); } }; const closeSpeakerSuggestions = (index) => { // Only close if this index is currently open if (openAsrDropdownIndex.value === index) { openAsrDropdownIndex.value = null; } }; const closeAllSpeakerSuggestions = () => { // O(1) instead of O(n) - just set to null openAsrDropdownIndex.value = null; }; // Helper to check if a dropdown is open (for template v-if) const isDropdownOpen = (index) => { return openAsrDropdownIndex.value === index; }; const getDropdownPosition = (index) => { const pos = dropdownPositions.value[index]; if (pos) { const style = { left: pos.left + 'px', width: pos.width + 'px' }; // When opening upward, anchor from bottom so dropdown grows upward if (pos.openUpward) { style.bottom = pos.bottom + 'px'; style.top = 'auto'; } else { style.top = pos.top + 'px'; style.bottom = 'auto'; } // Apply calculated max height if (pos.maxHeight) { style.maxHeight = pos.maxHeight + 'px'; } return style; } return { top: '0px', left: '0px' }; }; const updateDropdownPosition = (index) => { nextTick(() => { // Find row by data attribute to work correctly with virtual scrolling const row = document.querySelector(`.asr-editor-table tbody tr[data-segment-index="${index}"]`); if (row) { const cell = row.querySelector('td:first-child'); if (cell) { const rect = cell.getBoundingClientRect(); const viewportHeight = window.innerHeight; // Calculate available space above and below const spaceBelow = viewportHeight - rect.bottom - 10; const spaceAbove = rect.top - 10; // Determine max height based on available space (cap at 192px which is max-h-48) const maxDropdownHeight = 192; let top, bottom, openUpward, maxHeight; if (spaceBelow >= maxDropdownHeight || spaceBelow >= spaceAbove) { // Open downward top = rect.bottom + 2; bottom = null; openUpward = false; maxHeight = Math.min(spaceBelow, maxDropdownHeight); } else { // Open upward - anchor from bottom so dropdown grows upward openUpward = true; maxHeight = Math.min(spaceAbove, maxDropdownHeight); // Bottom is distance from viewport bottom to the top of the cell bottom = viewportHeight - rect.top + 2; top = null; } dropdownPositions.value[index] = { top: top, bottom: bottom, left: rect.left, width: rect.width, openUpward: openUpward, maxHeight: maxHeight }; } } }); }; const selectSpeaker = (index, speaker) => { if (editingSegments.value[index]) { editingSegments.value[index].speaker = speaker; closeSpeakerSuggestions(index); } }; const addSegment = () => { const lastSegment = editingSegments.value[editingSegments.value.length - 1]; const newStart = lastSegment ? lastSegment.end_time : 0; editingSegments.value.push({ speaker: availableSpeakers.value[0] || 'Speaker 1', start_time: newStart, end_time: newStart + 5, sentence: '', id: editingSegments.value.length, showSuggestions: false, filteredSpeakers: [...availableSpeakers.value] }); }; const removeSegment = (index) => { editingSegments.value.splice(index, 1); // Re-index segments editingSegments.value.forEach((seg, i) => { seg.id = i; }); }; const addSegmentBelow = (index) => { const currentSegment = editingSegments.value[index]; const nextSegment = editingSegments.value[index + 1]; const newStart = currentSegment.end_time; const newEnd = nextSegment ? nextSegment.start_time : newStart + 5; editingSegments.value.splice(index + 1, 0, { speaker: currentSegment.speaker, start_time: newStart, end_time: newEnd, sentence: '', id: index + 1, showSuggestions: false, filteredSpeakers: [...availableSpeakers.value] }); // Re-index segments editingSegments.value.forEach((seg, i) => { seg.id = i; }); }; const seekToSegmentTime = (time) => { // Find audio elements and use the one in a visible modal (z-50) const mediaElements = document.querySelectorAll('.fixed.z-50 audio, .fixed.z-50 video'); const audioElement = mediaElements.length > 0 ? mediaElements[mediaElements.length - 1] : null; if (audioElement) { audioElement.currentTime = time; audioElement.play(); } }; const autoResizeTextarea = (event) => { const textarea = event.target; textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; }; // ========================================= // Save Transcription Content // ========================================= const saveTranscriptionContent = async (content) => { if (!selectedRecording.value) return; try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcription`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ transcription: content }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update transcription'); // Update recording selectedRecording.value.transcription = content; const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index].transcription = content; } showToast('Transcription updated successfully!', 'fa-check-circle'); } catch (error) { setGlobalError(`Failed to save transcription: ${error.message}`); } }; // ========================================= // Save Summary // ========================================= const saveSummary = async (summary) => { if (!selectedRecording.value) return; try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const payload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: summary, meeting_date: selectedRecording.value.meeting_date }; const response = await fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update summary'); // Update recording selectedRecording.value.summary = summary; const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index].summary = summary; } showToast('Summary saved!', 'fa-check-circle'); } catch (error) { setGlobalError(`Failed to save summary: ${error.message}`); } }; // ========================================= // Save Notes // ========================================= const saveNotes = async (notes) => { if (!selectedRecording.value) return; // Handle incognito recordings - save to sessionStorage only if (selectedRecording.value.incognito) { selectedRecording.value.notes = notes; // Update sessionStorage try { const stored = sessionStorage.getItem('speakr_incognito_recording'); if (stored) { const data = JSON.parse(stored); data.notes = notes; sessionStorage.setItem('speakr_incognito_recording', JSON.stringify(data)); } } catch (e) { console.error('[Incognito] Failed to save notes to sessionStorage:', e); } showToast('Notes saved (in browser only)', 'fa-check-circle'); return; } try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/api/recordings/${selectedRecording.value.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ notes }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update notes'); // Update recording selectedRecording.value.notes = notes; const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index].notes = notes; } showToast('Notes saved!', 'fa-check-circle'); } catch (error) { setGlobalError(`Failed to save notes: ${error.message}`); } }; return { // Text editor openTranscriptionEditor, openTextEditorModal, closeTextEditorModal, saveTranscription, // ASR editor openAsrEditorModal, closeAsrEditorModal, saveAsrTranscription, // Segment management adjustTime, filterSpeakerSuggestions, openSpeakerSuggestions, closeSpeakerSuggestions, closeAllSpeakerSuggestions, isDropdownOpen, getDropdownPosition, updateDropdownPosition, selectSpeaker, addSegment, removeSegment, addSegmentBelow, seekToSegmentTime, autoResizeTextarea, // Save saveTranscriptionContent, saveSummary, saveNotes }; }