/** * Speaker management composable * Handles speaker identification, naming, and navigation */ export function useSpeakers(state, utils, processedTranscription) { const { nextTick } = Vue; const { showSpeakerModal, speakerModalTab, showAddSpeakerModal, showEditSpeakersModal, showEditTextModal, selectedRecording, recordings, speakerMap, speakerColorMap, modalSpeakers, speakerDisplayMap, speakerSuggestions, loadingSuggestions, activeSpeakerInput, regenerateSummaryAfterSpeakerUpdate, editingSpeakersList, databaseSpeakers, editingSpeakerSuggestions, editSpeakerDropdownPositions, newSpeakerName, newSpeakerIsMe, newSpeakerSuggestions, loadingNewSpeakerSuggestions, showNewSpeakerSuggestions, editingSegmentIndex, editingSpeakerIndex, editedText, editedTranscriptData, highlightedSpeaker, isAutoIdentifying, availableSpeakers, editingSegments, currentSpeakerGroupIndex, speakerGroups, currentUserName, voiceSuggestions, loadingVoiceSuggestions } = state; const { showToast, setGlobalError, onChatComplete } = utils; // i18n helper — falls back to the provided fallback string if i18n is not loaded const t = (key, params, fallback) => window.i18n ? window.i18n.t(key, params) : (fallback || key); const tc = (key, count, params) => window.i18n ? window.i18n.tc(key, count, params) : (params && params.count != null ? `${params.count}` : key); // Current speaker highlight state let currentSpeakerId = null; // Number of speaker colors available in CSS (must match styles.css and app.modular.js) const SPEAKER_COLOR_COUNT = 16; // Get speaker color from the shared color map // If speaker not in map, assign next available color const getSpeakerColor = (speakerId) => { if (speakerColorMap.value[speakerId]) { return speakerColorMap.value[speakerId]; } // Assign next color to new speaker const colorIndex = Object.keys(speakerColorMap.value).length; const color = `speaker-color-${(colorIndex % SPEAKER_COLOR_COUNT) + 1}`; speakerColorMap.value[speakerId] = color; return color; }; // Helper to pause outer audio player when opening modals with their own player const pauseOuterAudioPlayer = () => { // Find the audio player in the right panel (not in a modal) 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(); } }; // ========================================= // Speaker Identification Modal // ========================================= const openSpeakerModal = () => { if (!selectedRecording.value) return; // Pause outer audio player to avoid conflicts with modal's player pauseOuterAudioPlayer(); // Clear any existing speaker map data first speakerMap.value = {}; speakerDisplayMap.value = {}; // Get the same speaker order used in processedTranscription const transcription = selectedRecording.value?.transcription; let speakers = []; if (transcription) { try { const transcriptionData = JSON.parse(transcription); if (transcriptionData && Array.isArray(transcriptionData)) { // Use the exact same logic as processedTranscription to get speakers speakers = [...new Set(transcriptionData.map(segment => segment.speaker).filter(Boolean))]; } } catch (e) { // Fall back to getIdentifiedSpeakers if JSON parsing fails speakers = getIdentifiedSpeakers(); } } // Initialize speaker map FIRST with colors from shared color map // Clear existing map and rebuild it speakerMap.value = {}; speakerDisplayMap.value = {}; speakers.forEach(speaker => { speakerMap.value[speaker] = { name: '', isMe: false, color: getSpeakerColor(speaker) }; speakerDisplayMap.value[speaker] = speaker; }); // Set modalSpeakers AFTER speakerMap is populated (triggers render) modalSpeakers.value = speakers; highlightedSpeaker.value = null; speakerSuggestions.value = {}; loadingSuggestions.value = {}; activeSpeakerInput.value = null; isAutoIdentifying.value = false; regenerateSummaryAfterSpeakerUpdate.value = true; voiceSuggestions.value = {}; speakerModalTab.value = 'speakers'; // Reset to speakers tab on mobile showSpeakerModal.value = true; // Reset virtual scroll state for fresh modal render if (utils.resetSpeakerModalScroll) { utils.resetSpeakerModalScroll(); } // Load voice-based suggestions if embeddings are available loadVoiceSuggestions(); }; const getIdentifiedSpeakers = () => { // Ensure we have a valid recording and transcription if (!selectedRecording.value?.transcription) { return []; } const transcription = selectedRecording.value.transcription; let transcriptionData; try { transcriptionData = JSON.parse(transcription); } catch (e) { transcriptionData = null; } // Handle new simplified JSON format (array of segments) if (transcriptionData && Array.isArray(transcriptionData)) { // JSON format - extract speakers in order of appearance const speakersInOrder = []; const seenSpeakers = new Set(); transcriptionData.forEach(segment => { if (segment.speaker && !seenSpeakers.has(segment.speaker)) { seenSpeakers.add(segment.speaker); speakersInOrder.push(segment.speaker); } }); return speakersInOrder; } else if (typeof transcription === 'string') { // Plain text format - find speakers in order of appearance const speakerRegex = /\[([^\]]+)\]:/g; const speakersInOrder = []; const seenSpeakers = new Set(); let match; while ((match = speakerRegex.exec(transcription)) !== null) { const speaker = match[1].trim(); if (speaker && !seenSpeakers.has(speaker)) { seenSpeakers.add(speaker); speakersInOrder.push(speaker); } } return speakersInOrder; } return []; }; const closeSpeakerModal = () => { // 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(); } showSpeakerModal.value = false; showAutoIdDropdown.value = false; highlightedSpeaker.value = null; // Clear the speaker map to prevent stale data from persisting speakerMap.value = {}; speakerSuggestions.value = {}; loadingSuggestions.value = {}; clearSpeakerHighlight(); }; const saveTranscriptImmediately = async (transcriptData) => { if (!selectedRecording.value) return; try { // Save transcript without closing modal const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { if (speakerData.name && speakerData.name.trim() !== '') { acc[speakerId] = speakerData; } return acc; }, {}); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ transcript_data: transcriptData, speaker_map: filteredSpeakerMap, regenerate_summary: false // Don't regenerate on immediate saves }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update transcript'); // Update recordings list and selected recording without closing modal const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index] = data.recording; } selectedRecording.value = data.recording; editedTranscriptData.value = null; showToast(t('help.saved'), 'fa-check-circle', 2000, 'success'); } catch (error) { console.error('Save Transcript Error:', error); showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error'); } }; const saveTranscriptEdits = async () => { if (!selectedRecording.value || !editedTranscriptData.value) { return saveSpeakerNames(); // Fall back to regular speaker name save } try { // Save both speaker names and transcript edits const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { if (speakerData.name && speakerData.name.trim() !== '') { acc[speakerId] = speakerData; } return acc; }, {}); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ transcript_data: editedTranscriptData.value, speaker_map: filteredSpeakerMap, regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update transcript'); closeSpeakerModal(); // If summary regeneration was requested, update status immediately if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) { // Update recording status to SUMMARIZING immediately for UI feedback const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' }; const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index] = summarizingRecording; } selectedRecording.value = summarizingRecording; editedTranscriptData.value = null; showToast(t('help.transcriptUpdated'), 'fa-check-circle'); showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt'); // Poll for summary completion pollForSummaryCompletion(selectedRecording.value.id); } else { const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index] = data.recording; } selectedRecording.value = data.recording; editedTranscriptData.value = null; showToast(t('help.transcriptUpdated'), 'fa-check-circle'); } } catch (error) { console.error('Save Transcript Error:', error); showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error'); } }; const saveSpeakerNames = async () => { if (!selectedRecording.value) return; // If there are transcript edits, save those instead if (editedTranscriptData.value) { return saveTranscriptEdits(); } // Create a filtered speaker map that excludes entries with blank names const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => { if (speakerData.name && speakerData.name.trim() !== '') { acc[speakerId] = speakerData; } return acc; }, {}); try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/update_speakers`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ speaker_map: filteredSpeakerMap, regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Failed to update speaker names'); closeSpeakerModal(); // If summary regeneration was requested, update status immediately if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) { // Update recording status to SUMMARIZING immediately for UI feedback const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' }; const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index] = summarizingRecording; } selectedRecording.value = summarizingRecording; showToast(t('help.speakerNamesUpdated'), 'fa-check-circle'); showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt'); // Poll for summary completion pollForSummaryCompletion(selectedRecording.value.id); } else { // The backend returns the fully updated recording object const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id); if (index !== -1) { recordings.value[index] = data.recording; } selectedRecording.value = data.recording; showToast(t('help.speakerNamesUpdated'), 'fa-check-circle'); } } catch (error) { setGlobalError(`Failed to save speaker names: ${error.message}`); } }; // Poll for summary completion after regeneration const pollForSummaryCompletion = async (recordingId) => { const maxAttempts = 40; // Poll for up to 2 minutes (40 * 3 seconds) let attempts = 0; const pollInterval = setInterval(async () => { attempts++; try { // Use lightweight status-only endpoint for polling const response = await fetch(`/recording/${recordingId}/status`); if (!response.ok) { clearInterval(pollInterval); return; } const statusData = await response.json(); // Update status in recordings list const index = recordings.value.findIndex(r => r.id === recordingId); if (index !== -1) { // Create new object to ensure Vue reactivity recordings.value[index] = { ...recordings.value[index], status: statusData.status }; } // Update selectedRecording with new object reference for reactivity if (selectedRecording.value && selectedRecording.value.id === recordingId) { selectedRecording.value = { ...selectedRecording.value, status: statusData.status }; } // Check if summarization is complete if (statusData.status === 'COMPLETED') { clearInterval(pollInterval); // Now fetch the full recording with the new summary const fullResponse = await fetch(`/api/recordings/${recordingId}`); if (fullResponse.ok) { const fullData = await fullResponse.json(); // Update in recordings list first const currentIndex = recordings.value.findIndex(r => r.id === recordingId); if (currentIndex !== -1) { recordings.value[currentIndex] = fullData; } // Always update selectedRecording if it's the current recording if (selectedRecording.value && selectedRecording.value.id === recordingId) { selectedRecording.value = fullData; // Force Vue to detect the change await nextTick(); } } showToast(t('help.summaryUpdated'), 'fa-check-circle'); // Refresh token budget after LLM operation if (onChatComplete) onChatComplete(); } else if (statusData.status === 'FAILED' || statusData.status === 'ERROR') { // Stop polling if it failed clearInterval(pollInterval); showToast(t('help.summaryGenerationFailed'), 'fa-exclamation-circle', 3000, 'error'); } else if (attempts >= maxAttempts) { // Stop polling after max attempts clearInterval(pollInterval); showToast(t('help.summaryGenerationTimedOut'), 'fa-clock', 3000, 'warning'); } } catch (error) { console.error('Error polling for summary:', error); clearInterval(pollInterval); } }, 3000); // Poll every 3 seconds }; // ========================================= // Speaker Suggestions // ========================================= const loadVoiceSuggestions = async () => { if (!selectedRecording.value?.id) return; loadingVoiceSuggestions.value = true; voiceSuggestions.value = {}; try { const response = await fetch(`/speakers/suggestions/${selectedRecording.value.id}`); if (!response.ok) throw new Error('Failed to load voice suggestions'); const data = await response.json(); if (data.success && data.suggestions) { // Only keep suggestions that have matches voiceSuggestions.value = Object.fromEntries( Object.entries(data.suggestions).filter(([_, matches]) => matches && matches.length > 0) ); } } catch (error) { console.error('Error loading voice suggestions:', error); voiceSuggestions.value = {}; } finally { loadingVoiceSuggestions.value = false; } }; const applyVoiceSuggestion = (speakerId, suggestion) => { if (speakerMap.value[speakerId]) { speakerMap.value[speakerId].name = suggestion.name; // Don't delete the suggestion - let it reappear if user clears the field } }; // Handle "This is Me" checkbox changes const handleIsMeChange = (speakerId) => { if (!speakerMap.value[speakerId]) return; if (speakerMap.value[speakerId].isMe) { // Checkbox is now checked - set the name to current user's name speakerMap.value[speakerId].name = currentUserName.value || 'Me'; } else { // Checkbox is now unchecked - clear the name speakerMap.value[speakerId].name = ''; } }; // Determine if voice suggestion pill should be shown inside the input field const shouldShowVoiceSuggestionPill = (speakerId) => { // Don't show if no suggestions available if (!voiceSuggestions.value[speakerId] || voiceSuggestions.value[speakerId].length === 0) { return false; } // Don't show if "This is Me" is checked if (speakerMap.value[speakerId]?.isMe) { return false; } // Only show when the input field is empty const typedName = speakerMap.value[speakerId]?.name?.trim(); if (typedName && typedName.length > 0) { return false; } return true; }; const searchSpeakers = async (query, speakerId) => { if (!query || query.length < 2) { speakerSuggestions.value[speakerId] = []; return; } loadingSuggestions.value[speakerId] = true; try { const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`); if (!response.ok) throw new Error('Failed to search speakers'); const speakers = await response.json(); speakerSuggestions.value[speakerId] = speakers; } catch (error) { console.error('Error searching speakers:', error); speakerSuggestions.value[speakerId] = []; } finally { loadingSuggestions.value[speakerId] = false; } }; const selectSpeakerSuggestion = (speakerId, suggestion) => { if (speakerMap.value[speakerId]) { speakerMap.value[speakerId].name = suggestion.name; speakerSuggestions.value[speakerId] = []; activeSpeakerInput.value = null; } }; const closeSpeakerSuggestionsOnClick = (event) => { // Check if the click was on an input field or dropdown const clickedInput = event.target.closest('input[type="text"]'); const clickedDropdown = event.target.closest('.absolute.z-10'); // If not clicking on input or dropdown, close all suggestions if (!clickedInput && !clickedDropdown) { Object.keys(speakerSuggestions.value).forEach(speakerId => { speakerSuggestions.value[speakerId] = []; }); } }; // ========================================= // Speaker Navigation (Index-Based for Virtual Scroll) // ========================================= /** * Find speaker groups by analyzing segment data (not DOM). * Returns groups with startIndex instead of startElement for virtual scroll compatibility. */ const findSpeakerGroups = (speakerId) => { if (!speakerId) return []; // Get segments from processedTranscription const segments = processedTranscription.value?.simpleSegments || []; if (segments.length === 0) return []; const groups = []; let currentGroup = null; let lastSpeakerId = null; segments.forEach((segment, index) => { const segmentSpeakerId = segment.speakerId; if (segmentSpeakerId === speakerId) { // If this is a new group (not consecutive with previous) if (lastSpeakerId !== speakerId) { currentGroup = { startIndex: index, indices: [index] }; groups.push(currentGroup); } else if (currentGroup) { // Add to existing group currentGroup.indices.push(index); } } lastSpeakerId = segmentSpeakerId; }); return groups; }; const highlightSpeakerInTranscript = (speakerId) => { highlightedSpeaker.value = speakerId; if (speakerId) { // Find all speaker groups for navigation (index-based, no DOM queries) speakerGroups.value = findSpeakerGroups(speakerId); if (speakerGroups.value.length > 0) { // Get the current visible range from the virtual scroll const visibleRange = utils.getSpeakerModalVisibleRange ? utils.getSpeakerModalVisibleRange() : null; if (visibleRange) { const { start: visibleStart, end: visibleEnd } = visibleRange; const visibleCenter = Math.floor((visibleStart + visibleEnd) / 2); // Check if any group is already visible const visibleGroupIndex = speakerGroups.value.findIndex(group => group.startIndex >= visibleStart && group.startIndex < visibleEnd ); if (visibleGroupIndex !== -1) { // A group is already visible, just set it as current (no scroll needed) currentSpeakerGroupIndex.value = visibleGroupIndex; } else { // No group visible - find the nearest group to the visible center let nearestIndex = 0; let nearestDistance = Infinity; speakerGroups.value.forEach((group, index) => { const distance = Math.abs(group.startIndex - visibleCenter); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } }); currentSpeakerGroupIndex.value = nearestIndex; // Scroll to the nearest group const nearestGroup = speakerGroups.value[nearestIndex]; if (nearestGroup && typeof nearestGroup.startIndex === 'number' && utils.scrollToSegmentIndex) { utils.scrollToSegmentIndex(nearestGroup.startIndex); } } } else { // Fallback: no visible range available, scroll to first group currentSpeakerGroupIndex.value = 0; const firstGroup = speakerGroups.value[0]; if (firstGroup && typeof firstGroup.startIndex === 'number' && utils.scrollToSegmentIndex) { utils.scrollToSegmentIndex(firstGroup.startIndex); } } } else { currentSpeakerGroupIndex.value = -1; } } else { speakerGroups.value = []; currentSpeakerGroupIndex.value = -1; } }; /** * Select a speaker for navigation from the dropdown. * Uses index-based navigation compatible with virtual scrolling. */ const selectSpeakerForNavigation = (speakerId) => { if (!speakerId) { highlightedSpeaker.value = null; speakerGroups.value = []; currentSpeakerGroupIndex.value = -1; return; } highlightedSpeaker.value = speakerId; // Find groups immediately (no DOM dependency) speakerGroups.value = findSpeakerGroups(speakerId); currentSpeakerGroupIndex.value = 0; // Scroll to first occurrence if (speakerGroups.value.length > 0) { const firstGroup = speakerGroups.value[0]; if (firstGroup && typeof firstGroup.startIndex === 'number') { if (utils.scrollToSegmentIndex) { utils.scrollToSegmentIndex(firstGroup.startIndex); } } } }; const navigateToNextSpeakerGroup = () => { if (speakerGroups.value.length === 0) return; // Update the index currentSpeakerGroupIndex.value = (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length; const group = speakerGroups.value[currentSpeakerGroupIndex.value]; if (group && typeof group.startIndex === 'number') { if (utils.scrollToSegmentIndex) { utils.scrollToSegmentIndex(group.startIndex); } } }; const navigateToPrevSpeakerGroup = () => { if (speakerGroups.value.length === 0) return; // Update the index currentSpeakerGroupIndex.value = currentSpeakerGroupIndex.value <= 0 ? speakerGroups.value.length - 1 : currentSpeakerGroupIndex.value - 1; const group = speakerGroups.value[currentSpeakerGroupIndex.value]; if (group && typeof group.startIndex === 'number') { if (utils.scrollToSegmentIndex) { utils.scrollToSegmentIndex(group.startIndex); } } }; const focusSpeaker = (speakerId) => { // Set this as the active speaker input activeSpeakerInput.value = speakerId; // Only highlight if not already highlighted (to preserve navigation state) if (highlightedSpeaker.value !== speakerId) { highlightSpeakerInTranscript(speakerId); } }; const blurSpeaker = () => { // Clear the active speaker input after a delay to allow clicking on suggestions setTimeout(() => { activeSpeakerInput.value = null; speakerSuggestions.value = {}; }, 200); clearSpeakerHighlight(); }; const clearSpeakerHighlight = () => { highlightedSpeaker.value = null; }; // ========================================= // Auto-Identify Speakers // ========================================= // Split button dropdown visibility + click-outside handling const showAutoIdDropdown = Vue.ref(false); const autoIdSplitBtn = Vue.ref(null); const onAutoIdClickOutside = (e) => { if (autoIdSplitBtn.value && !autoIdSplitBtn.value.contains(e.target)) { showAutoIdDropdown.value = false; } }; Vue.watch(showAutoIdDropdown, (open) => { if (open) { document.addEventListener('click', onAutoIdClickOutside, true); } else { document.removeEventListener('click', onAutoIdClickOutside, true); } }); /** * Auto-identify speakers via LLM. * @param {boolean} identifyAll - When false (default), only fill speakers with empty names. * When true, overwrite all speaker names. */ const autoIdentifySpeakers = async (identifyAll = false) => { showAutoIdDropdown.value = false; if (!selectedRecording.value) { showToast(t('help.noRecordingSelected'), 'fa-exclamation-circle'); return; } isAutoIdentifying.value = true; showToast(t('help.startingAutoIdentification'), 'fa-magic'); try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const response = await fetch(`/recording/${selectedRecording.value.id}/auto_identify_speakers`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ current_speaker_map: speakerMap.value }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Unknown error occurred during auto-identification.'); } // Check if there's a message (e.g., all speakers already identified) if (data.message) { showToast(data.message, 'fa-info-circle'); return; } // Update speakerMap with the identified names let identifiedCount = 0; for (const speakerId in data.speaker_map) { const identifiedName = data.speaker_map[speakerId]; if (speakerMap.value[speakerId] && identifiedName && identifiedName.trim() !== '') { // Skip speakers that already have a name unless identifyAll is true if (!identifyAll && speakerMap.value[speakerId].name && speakerMap.value[speakerId].name.trim() !== '') { continue; } speakerMap.value[speakerId].name = identifiedName; identifiedCount++; } } if (identifiedCount > 0) { showToast(tc('help.speakersIdentified', identifiedCount, { count: identifiedCount }), 'fa-check-circle'); } else { showToast(t('help.noSpeakersIdentified'), 'fa-info-circle'); } // Refresh token budget after LLM operation if (onChatComplete) onChatComplete(); } catch (error) { console.error('Auto Identify Speakers Error:', error); showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 5000, 'error'); } finally { isAutoIdentifying.value = false; } }; // ========================================= // Apply Suggested Names // ========================================= /** True when any unnamed, non-isMe speaker has voice or autocomplete suggestions */ const hasAnySuggestions = Vue.computed(() => { for (const speakerId of modalSpeakers.value) { const data = speakerMap.value[speakerId]; if (!data || data.isMe) continue; if (data.name && data.name.trim() !== '') continue; // Check voice suggestions if (voiceSuggestions.value[speakerId] && voiceSuggestions.value[speakerId].length > 0) { return true; } // Check autocomplete suggestions if (speakerSuggestions.value[speakerId] && speakerSuggestions.value[speakerId].length > 0) { return true; } } return false; }); /** Bulk-apply voice suggestions (priority) then autocomplete suggestions to empty names only */ const applySuggestedNames = () => { let appliedCount = 0; for (const speakerId of modalSpeakers.value) { const data = speakerMap.value[speakerId]; if (!data || data.isMe) continue; if (data.name && data.name.trim() !== '') continue; // Priority 1: voice suggestions const voice = voiceSuggestions.value[speakerId]; if (voice && voice.length > 0) { data.name = voice[0].name; appliedCount++; continue; } // Priority 2: autocomplete suggestions const auto = speakerSuggestions.value[speakerId]; if (auto && auto.length > 0) { data.name = auto[0].name; appliedCount++; } } if (appliedCount > 0) { showToast(tc('help.appliedSuggestedNames', appliedCount, { count: appliedCount }), 'fa-check-circle'); } else { showToast(t('help.noSuggestionsToApply'), 'fa-info-circle'); } }; // ========================================= // Add Speaker Modal // ========================================= const searchNewSpeaker = async () => { const query = newSpeakerName.value; if (!query || query.length < 2) { newSpeakerSuggestions.value = []; return; } loadingNewSpeakerSuggestions.value = true; try { const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`); if (!response.ok) throw new Error('Failed to search speakers'); const speakers = await response.json(); newSpeakerSuggestions.value = speakers; } catch (error) { console.error('Error searching speakers:', error); newSpeakerSuggestions.value = []; } finally { loadingNewSpeakerSuggestions.value = false; } }; const selectNewSpeakerSuggestion = (suggestion) => { newSpeakerName.value = suggestion.name; newSpeakerSuggestions.value = []; showNewSpeakerSuggestions.value = false; }; const hideNewSpeakerSuggestionsDelayed = () => { setTimeout(() => { showNewSpeakerSuggestions.value = false; newSpeakerSuggestions.value = []; }, 200); }; const openAddSpeakerModal = () => { newSpeakerName.value = ''; newSpeakerIsMe.value = false; newSpeakerSuggestions.value = []; loadingNewSpeakerSuggestions.value = false; showNewSpeakerSuggestions.value = false; showAddSpeakerModal.value = true; }; const closeAddSpeakerModal = () => { showAddSpeakerModal.value = false; newSpeakerName.value = ''; newSpeakerIsMe.value = false; newSpeakerSuggestions.value = []; loadingNewSpeakerSuggestions.value = false; showNewSpeakerSuggestions.value = false; }; const addNewSpeaker = () => { const name = newSpeakerIsMe.value ? (currentUserName.value || 'Me') : newSpeakerName.value.trim(); if (!newSpeakerIsMe.value && !name) { showToast(t('help.pleaseEnterSpeakerName'), 'fa-exclamation-circle'); return; } // Generate new speaker ID const existingSpeakerNumbers = modalSpeakers.value .map(s => { const match = s.match(/^SPEAKER_(\d+)$/); return match ? parseInt(match[1]) : -1; }) .filter(n => n >= 0); const nextNumber = existingSpeakerNumbers.length > 0 ? Math.max(...existingSpeakerNumbers) + 1 : modalSpeakers.value.length; const newSpeakerId = `SPEAKER_${String(nextNumber).padStart(2, '0')}`; // Add to speakerMap FIRST (before modalSpeakers) to avoid render race condition speakerMap.value[newSpeakerId] = { name: name, isMe: newSpeakerIsMe.value, color: getSpeakerColor(newSpeakerId) }; // Add to speakerDisplayMap speakerDisplayMap.value[newSpeakerId] = newSpeakerId; // Add to modalSpeakers LAST (triggers re-render, but speakerMap is already populated) modalSpeakers.value.push(newSpeakerId); closeAddSpeakerModal(); showToast(t('help.speakerAdded'), 'fa-check-circle'); }; // ========================================= // Edit Speakers Modal // ========================================= const openEditSpeakersModal = async () => { // Close any open suggestions editingSegments.value.forEach(seg => seg.showSuggestions = false); // Copy current speakers to editing list with original and current properties editingSpeakersList.value = availableSpeakers.value.map(s => ({ original: s, current: s })); // Fetch speakers from database for autocomplete try { const response = await fetch('/speakers'); const speakers = await response.json(); // Keep full objects with id and name for autocomplete dropdown databaseSpeakers.value = speakers; } catch (e) { console.error('Failed to fetch speakers:', e); databaseSpeakers.value = []; } editingSpeakerSuggestions.value = {}; showEditSpeakersModal.value = true; }; const closeEditSpeakersModal = () => { showEditSpeakersModal.value = false; editingSpeakersList.value = []; }; const addEditingSpeaker = () => { editingSpeakersList.value.push({ original: '', current: '' }); }; const removeEditingSpeaker = (index) => { editingSpeakersList.value.splice(index, 1); }; const filterEditingSpeakerSuggestions = (index) => { const query = editingSpeakersList.value[index]?.current?.toLowerCase().trim() || ''; if (query === '') { // Show all speakers when field is empty/focused editingSpeakerSuggestions.value[index] = [...databaseSpeakers.value]; } else { editingSpeakerSuggestions.value[index] = databaseSpeakers.value.filter( s => s.name.toLowerCase().includes(query) ); } }; const selectEditingSpeakerSuggestion = (index, name) => { editingSpeakersList.value[index].current = name; editingSpeakerSuggestions.value[index] = []; }; const closeEditingSpeakerSuggestions = (index) => { editingSpeakerSuggestions.value[index] = []; }; const onEditSpeakerBlur = (index) => { // Delay closing to allow clicking on suggestions setTimeout(() => { closeEditingSpeakerSuggestions(index); }, 200); }; const getEditSpeakerDropdownPosition = (index) => { // Find the input element for this index and calculate position const inputs = document.querySelectorAll('[class*="edit-speakers-modal"] input[placeholder="New name..."], .max-w-md input[placeholder="New name..."]'); if (inputs[index]) { const rect = inputs[index].getBoundingClientRect(); return { top: rect.bottom + 2 + 'px', left: rect.left + 'px', width: rect.width + 'px' }; } return { top: '0px', left: '0px', width: '200px' }; }; const saveEditingSpeakers = async () => { const map = {}; editingSpeakersList.value.forEach(item => { if (item.original && item.current) { map[item.original] = item.current; } }); // Update ASR editor state if it's open if (editingSegments.value.length > 0) { // Build new list of available speakers const newSpeakers = new Set(); // Apply renames to all segments editingSegments.value.forEach(segment => { if (map[segment.speaker]) { segment.speaker = map[segment.speaker]; } newSpeakers.add(segment.speaker); }); // Add any newly added speakers from the modal editingSpeakersList.value.forEach(item => { if (!item.original && item.current) { // This is a new speaker (no original) newSpeakers.add(item.current); } }); // Update available speakers list availableSpeakers.value = [...newSpeakers].sort(); // Update filtered speakers for all segments editingSegments.value.forEach(segment => { segment.filteredSpeakers = [...availableSpeakers.value]; }); closeEditSpeakersModal(); showToast(t('help.speakersUpdatedSaveToApply'), 'fa-check-circle'); } else { // Regular flow for non-ASR editor context speakerMap.value = map; closeEditSpeakersModal(); await saveSpeakerNames(); } }; // ========================================= // Edit Text Modal // ========================================= const openEditTextModal = (segmentIndex) => { if (!selectedRecording.value?.transcription) return; try { const transcriptionData = JSON.parse(selectedRecording.value.transcription); if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) { editingSegmentIndex.value = segmentIndex; editedText.value = transcriptionData[segmentIndex].sentence || ''; showEditTextModal.value = true; } } catch (e) { console.error('Error opening text editor:', e); showToast(t('help.errorOpeningTextEditor'), 'fa-exclamation-circle', 3000, 'error'); } }; const closeEditTextModal = () => { showEditTextModal.value = false; editingSegmentIndex.value = null; editedText.value = ''; }; const saveEditedText = async () => { if (editingSegmentIndex.value === null || !selectedRecording.value?.transcription) return; try { const transcriptionData = JSON.parse(selectedRecording.value.transcription); if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[editingSegmentIndex.value]) { transcriptionData[editingSegmentIndex.value].sentence = editedText.value; editedTranscriptData.value = transcriptionData; // Update the recording's transcription temporarily for UI update selectedRecording.value.transcription = JSON.stringify(transcriptionData); closeEditTextModal(); // Immediately persist the change showToast(t('help.savingProgress'), 'fa-spinner fa-spin'); await saveTranscriptImmediately(transcriptionData); } } catch (e) { console.error('Error saving text:', e); showToast(t('help.errorSavingText'), 'fa-exclamation-circle', 3000, 'error'); } }; // ========================================= // Change Speaker in Segment // ========================================= const openSpeakerChangeDropdown = (segmentIndex) => { editingSpeakerIndex.value = editingSpeakerIndex.value === segmentIndex ? null : segmentIndex; }; const changeSpeaker = async (segmentIndex, newSpeakerId) => { if (!selectedRecording.value?.transcription) return; try { const transcriptionData = JSON.parse(selectedRecording.value.transcription); if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) { transcriptionData[segmentIndex].speaker = newSpeakerId; editedTranscriptData.value = transcriptionData; // Update the recording's transcription temporarily for UI update selectedRecording.value.transcription = JSON.stringify(transcriptionData); editingSpeakerIndex.value = null; // Immediately persist the change showToast(t('help.savingProgress'), 'fa-spinner fa-spin'); await saveTranscriptImmediately(transcriptionData); } } catch (e) { console.error('Error changing speaker:', e); showToast(t('help.errorChangingSpeaker'), 'fa-exclamation-circle', 3000, 'error'); } }; return { // Speaker modal openSpeakerModal, closeSpeakerModal, saveSpeakerNames, // Suggestions loadVoiceSuggestions, applyVoiceSuggestion, handleIsMeChange, shouldShowVoiceSuggestionPill, searchSpeakers, selectSpeakerSuggestion, closeSpeakerSuggestionsOnClick, // Navigation findSpeakerGroups, highlightSpeakerInTranscript, selectSpeakerForNavigation, navigateToNextSpeakerGroup, navigateToPrevSpeakerGroup, focusSpeaker, blurSpeaker, clearSpeakerHighlight, // Auto-identify autoIdentifySpeakers, showAutoIdDropdown, autoIdSplitBtn, hasAnySuggestions, applySuggestedNames, // Add speaker openAddSpeakerModal, closeAddSpeakerModal, addNewSpeaker, searchNewSpeaker, selectNewSpeakerSuggestion, hideNewSpeakerSuggestionsDelayed, // Edit speakers modal openEditSpeakersModal, closeEditSpeakersModal, addEditingSpeaker, removeEditingSpeaker, filterEditingSpeakerSuggestions, selectEditingSpeakerSuggestion, closeEditingSpeakerSuggestions, onEditSpeakerBlur, getEditSpeakerDropdownPosition, saveEditingSpeakers, // Edit text openEditTextModal, closeEditTextModal, saveEditedText, // Change speaker openSpeakerChangeDropdown, changeSpeaker }; }