Files
dictia-public/static/js/modules/composables/ui.js

2111 lines
78 KiB
JavaScript

/**
* 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 = `
<div class="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">${t('transcriptTemplates.selectTemplate')}</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
${templates.map(tmpl => `
<button class="template-option w-full text-left p-3 rounded border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] ${tmpl.is_default ? 'ring-2 ring-[var(--ring-focus)]' : ''}" data-template-id="${tmpl.id}">
<div class="font-medium">${tmpl.name}</div>
${tmpl.description ? `<div class="text-sm text-[var(--text-muted)]">${tmpl.description}</div>` : ''}
${tmpl.is_default ? `<div class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>${t('transcriptTemplates.default')}</div>` : ''}
</button>
`).join('')}
</div>
<div class="mt-4 flex gap-2">
<button class="cancel-btn px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-accent-light)]">${t('transcriptTemplates.cancel')}</button>
<button class="download-without-template-btn px-4 py-2 bg-[var(--bg-accent)] text-white rounded hover:bg-[var(--bg-accent-hover)]">${t('transcriptTemplates.downloadWithoutTemplate')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Wait for user selection
await new Promise((resolve) => {
modal.querySelectorAll('.template-option').forEach(btn => {
btn.addEventListener('click', () => {
templateId = btn.dataset.templateId;
modal.remove();
resolve();
});
});
modal.querySelector('.cancel-btn').addEventListener('click', () => {
templateId = 'cancelled';
modal.remove();
resolve();
});
modal.querySelector('.download-without-template-btn').addEventListener('click', () => {
templateId = 'none';
modal.remove();
resolve();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
templateId = 'cancelled';
modal.remove();
resolve();
}
});
});
if (templateId === null || templateId === undefined || templateId === 'cancelled') {
return;
}
}
// If templateId is 'none', download raw transcript without any template
if (templateId === 'none') {
let rawText = '';
try {
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
if (Array.isArray(transcriptionData)) {
rawText = transcriptionData.map(segment => {
const speaker = segment.speaker || 'Unknown';
const text = segment.sentence || '';
return `${speaker}: ${text}`;
}).join('\n');
} else {
rawText = selectedRecording.value.transcription;
}
} catch (e) {
rawText = selectedRecording.value.transcription;
}
const blob = new Blob([rawText], { type: 'text/plain;charset=utf-8' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `${selectedRecording.value.title || 'transcript'}_raw.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
showToast(t('messages.transcriptDownloadSuccess'));
return;
}
// Download the transcript with the selected template
const url = templateId
? `/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}`
: `/recording/${selectedRecording.value.id}/download/transcript`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(t('messages.transcriptDownloadFailed'));
}
const blob = await response.blob();
const contentDisposition = response.headers.get('content-disposition');
let filename = 'transcript.txt';
if (contentDisposition) {
const matches = contentDisposition.match(/filename="([^"]+)"/);
if (matches && matches[1]) {
filename = matches[1];
}
}
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
showToast(t('messages.transcriptDownloadSuccess'));
} catch (error) {
console.error('Error downloading transcript:', error);
showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle');
}
};
// Download with default template (no modal)
const downloadWithDefaultTemplate = async () => {
if (!selectedRecording.value || !selectedRecording.value.transcription) {
showToast(t('messages.noTranscriptionToDownload'), 'fa-exclamation-circle');
return;
}
try {
// Download using the default template (server will use user's default)
const response = await fetch(`/recording/${selectedRecording.value.id}/download/transcript`);
if (!response.ok) {
throw new Error(t('messages.transcriptDownloadFailed'));
}
const blob = await response.blob();
const contentDisposition = response.headers.get('content-disposition');
let filename = 'transcript.txt';
if (contentDisposition) {
const matches = contentDisposition.match(/filename="([^"]+)"/);
if (matches && matches[1]) {
filename = matches[1];
}
}
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
showToast(t('messages.transcriptDownloadSuccess'));
} catch (error) {
console.error('Error downloading transcript:', error);
showToast(t('messages.transcriptDownloadFailed'), 'fa-exclamation-circle');
}
};
// Show template selector modal (reuses the modal from downloadTranscript)
const showTemplateSelector = async () => {
// This calls the full downloadTranscript which shows the modal
await downloadTranscript();
};
// Initialize UI settings from localStorage
const initializeUI = () => {
// Load saved column widths
const savedLeftWidth = localStorage.getItem('transcriptColumnWidth');
const savedRightWidth = localStorage.getItem('summaryColumnWidth');
if (savedLeftWidth && savedRightWidth) {
leftColumnWidth.value = parseFloat(savedLeftWidth);
rightColumnWidth.value = parseFloat(savedRightWidth);
}
// Load saved transcription view mode
const savedViewMode = localStorage.getItem('transcriptionViewMode');
if (savedViewMode) {
transcriptionViewMode.value = savedViewMode;
}
// Load saved player volume
const savedVolume = localStorage.getItem('playerVolume');
if (savedVolume) {
playerVolume.value = parseFloat(savedVolume);
}
// Load saved follow player mode
const savedFollowMode = localStorage.getItem('followPlayerMode');
if (savedFollowMode !== null) {
followPlayerMode.value = savedFollowMode === 'true';
}
// Load saved playback rate
initializePlaybackRate();
// Watch for recording changes to reset active segment and audio player state
watch(selectedRecording, (newRecording) => {
if (videoFullscreen.value) exitVideoFullscreen();
currentPlayingSegmentIndex.value = null;
resetAudioPlayerState();
// Use server-side duration if available (more reliable than browser metadata)
if (newRecording && newRecording.audio_duration) {
audioDuration.value = newRecording.audio_duration;
}
});
// Set up global click handler to close dropdowns when clicking outside
setupGlobalClickHandler();
};
/**
* Set up a global click handler to close all dropdowns when clicking outside
* This provides elegant UX by closing menus when users click elsewhere
*/
const setupGlobalClickHandler = () => {
document.addEventListener('click', (event) => {
const target = event.target;
// Close user menu if clicking outside of it
if (isUserMenuOpen.value) {
const userMenuButton = target.closest('[data-user-menu-toggle]');
const userMenuDropdown = target.closest('[data-user-menu-dropdown]');
if (!userMenuButton && !userMenuDropdown) {
isUserMenuOpen.value = false;
}
}
// Close sort options if clicking outside
if (showSortOptions.value) {
const sortButton = target.closest('[data-sort-toggle]');
const sortDropdown = target.closest('[data-sort-dropdown]');
if (!sortButton && !sortDropdown) {
showSortOptions.value = false;
}
}
// Close download menu if clicking outside
if (showDownloadMenu.value) {
const downloadButton = target.closest('[data-download-toggle]');
const downloadDropdown = target.closest('[data-download-dropdown]');
if (!downloadButton && !downloadDropdown) {
showDownloadMenu.value = false;
}
}
// Close language menu if clicking outside
if (state.showLanguageMenu && state.showLanguageMenu.value) {
const languageButton = target.closest('[data-language-toggle]');
const languageDropdown = target.closest('[data-language-dropdown]');
if (!languageButton && !languageDropdown) {
state.showLanguageMenu.value = false;
}
}
// Close speed menu if clicking outside
if (showSpeedMenu.value) {
const speedButton = target.closest('[data-speed-toggle]');
const speedDropdown = target.closest('[data-speed-dropdown]');
if (!speedButton && !speedDropdown) {
showSpeedMenu.value = false;
}
}
});
};
// Initialize recording notes markdown editor
const initializeRecordingNotesEditor = () => {
if (!recordingNotesEditor.value) return;
// Destroy existing instance if any
if (recordingMarkdownEditorInstance.value) {
recordingMarkdownEditorInstance.value.toTextArea();
recordingMarkdownEditorInstance.value = null;
}
try {
recordingMarkdownEditorInstance.value = new EasyMDE({
element: recordingNotesEditor.value,
spellChecker: false,
autofocus: false,
placeholder: t('form.enterNotesMarkdown'),
initialValue: recordingNotes.value || '',
status: false,
toolbar: [
"bold", "italic", "heading", "|",
"quote", "unordered-list", "ordered-list", "|",
"link", "|",
"preview", "side-by-side", "fullscreen"
],
previewClass: ["editor-preview", "notes-preview"],
theme: isDarkMode.value ? "dark" : "light"
});
// Sync changes back to recordingNotes
recordingMarkdownEditorInstance.value.codemirror.on('change', () => {
recordingNotes.value = recordingMarkdownEditorInstance.value.value();
});
} catch (error) {
console.error('Failed to initialize recording notes markdown editor:', error);
}
};
// Destroy recording notes markdown editor
const destroyRecordingNotesEditor = () => {
if (recordingMarkdownEditorInstance.value) {
// Save current value before destroying
recordingNotes.value = recordingMarkdownEditorInstance.value.value();
recordingMarkdownEditorInstance.value.toTextArea();
recordingMarkdownEditorInstance.value = null;
}
};
// =========================================
// Participants Modal
// =========================================
const openParticipantsModal = async () => {
if (!selectedRecording.value) return;
// Parse current participants into array
const participants = selectedRecording.value.participants
? selectedRecording.value.participants.split(',').map(p => p.trim()).filter(Boolean)
: [];
state.editingParticipantsList.value = participants.map(name => ({ name }));
// Fetch speakers from database for autocomplete
try {
const response = await fetch('/speakers');
if (response.ok) {
const speakers = await response.json();
state.allParticipants.value = speakers.map(s => s.name).sort();
}
} catch (e) {
console.error('Failed to fetch speakers:', e);
state.allParticipants.value = [];
}
state.editingParticipantSuggestions.value = {};
state.showEditParticipantsModal.value = true;
};
const closeEditParticipantsModal = () => {
state.showEditParticipantsModal.value = false;
state.editingParticipantsList.value = [];
};
const addParticipant = () => {
state.editingParticipantsList.value.push({ name: '' });
};
const removeParticipant = (index) => {
state.editingParticipantsList.value.splice(index, 1);
delete state.editingParticipantSuggestions.value[index];
};
const filterParticipantSuggestions = (index) => {
// Close all other dropdowns first
closeAllParticipantSuggestions();
const query = state.editingParticipantsList.value[index]?.name?.toLowerCase().trim() || '';
if (query === '') {
// Show all participants when field is empty/focused
state.editingParticipantSuggestions.value[index] = [...state.allParticipants.value];
} else {
state.editingParticipantSuggestions.value[index] = state.allParticipants.value.filter(
p => p.toLowerCase().includes(query)
);
}
};
const selectParticipantSuggestion = (index, name) => {
state.editingParticipantsList.value[index].name = name;
state.editingParticipantSuggestions.value[index] = [];
};
const closeParticipantSuggestions = (index) => {
state.editingParticipantSuggestions.value[index] = [];
};
const closeParticipantSuggestionsDelayed = (index) => {
setTimeout(() => closeParticipantSuggestions(index), 200);
};
const closeAllParticipantSuggestions = () => {
state.editingParticipantSuggestions.value = {};
};
const getParticipantDropdownPosition = (index) => {
// Find the input element for this index and calculate position
const inputs = document.querySelectorAll('.max-w-md input[placeholder="' + t('form.participantNamePlaceholder') + '"]');
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 saveParticipants = async () => {
if (!selectedRecording.value) return;
// Join participant names with comma
const participantsString = state.editingParticipantsList.value
.map(p => p.name.trim())
.filter(Boolean)
.join(', ');
// Update the recording
selectedRecording.value.participants = participantsString;
// Use the same save endpoint as inline editing
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 response = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken.value
},
body: JSON.stringify(fullPayload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || t('messages.failedToSaveParticipants'));
showToast(t('common.changesSaved'), 'fa-check-circle');
closeEditParticipantsModal();
} catch (error) {
console.error('Save error:', error);
utils.setGlobalError(t('messages.saveParticipantsFailed', { error: error.message }));
}
};
return {
// Initialization
initializeUI,
toggleDarkMode,
initializeDarkMode,
applyColorScheme,
initializeColorScheme,
openColorSchemeModal,
closeColorSchemeModal,
selectColorScheme,
resetColorScheme,
toggleSidebar,
initializeSidebar,
switchToUploadView,
switchToDetailView,
switchToRecordingView,
setGlobalError,
formatFileSize,
formatDisplayDate,
formatShortDate,
formatStatus,
getStatusClass,
formatTime,
formatDuration,
formatProcessingDuration,
// Inline editing
toggleEditTitle,
saveTitle,
cancelEditTitle,
toggleEditParticipants,
toggleEditMeetingDate,
toggleEditSummary,
cancelEditSummary,
saveEditSummary,
initializeSummaryMarkdownEditor,
autoSaveSummary,
toggleEditNotes,
cancelEditNotes,
saveEditNotes,
initializeMarkdownEditor,
autoSaveNotes,
clickToEditNotes,
clickToEditSummary,
// Recording notes editor
initializeRecordingNotesEditor,
destroyRecordingNotesEditor,
downloadNotes,
downloadEventICS,
downloadICS,
deleteEvent,
formatEventDateTime,
// View mode
toggleTranscriptionViewMode,
// Column resizing
startColumnResize,
// Audio player
seekAudio,
seekAudioFromEvent,
onPlayerVolumeChange,
handleAudioTimeUpdate,
toggleFollowPlayerMode,
scrollToActiveSegment,
// Custom audio player
toggleAudioPlayback,
toggleAudioMute,
setAudioVolume,
seekAudioTo,
seekAudioByPercent,
startProgressDrag,
handleAudioPlayPause,
handleAudioLoadedMetadata,
handleAudioEnded,
handleCustomAudioTimeUpdate,
handleAudioWaiting,
handleAudioCanPlay,
handleAudioDurationChange,
formatAudioTime,
audioProgressPercent,
displayCurrentTime,
isDraggingProgress,
audioIsPlaying,
audioCurrentTime,
audioDuration,
audioIsMuted,
audioIsLoading,
resetAudioPlayerState,
// Playback speed
formatPlaybackRate,
setPlaybackRate,
cyclePlaybackRate,
cycleModalPlaybackRate,
updateSpeedMenuPosition,
// Video fullscreen
enterVideoFullscreen,
exitVideoFullscreen,
handleFullscreenMouseMove,
resetFullscreenControlsTimer,
// Copy functions
copyTranscription,
copySummary,
copyNotes,
// Download functions
downloadSummary,
downloadTranscript,
downloadTranscriptWord,
downloadWithDefaultTemplate,
showTemplateSelector,
// Participants modal
openParticipantsModal,
closeEditParticipantsModal,
addParticipant,
removeParticipant,
filterParticipantSuggestions,
selectParticipantSuggestion,
closeParticipantSuggestions,
closeParticipantSuggestionsDelayed,
closeAllParticipantSuggestions,
getParticipantDropdownPosition,
saveParticipants,
// Computed
isMobile
};
}