Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View File

@@ -0,0 +1,895 @@
/**
* Audio recording composable
* Handles microphone/system audio recording with visualizers and wake lock
*/
import * as RecordingDB from '../db/recording-persistence.js';
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useAudio(state, utils) {
const {
isRecording, mediaRecorder, audioContext, analyser, micAnalyser, systemAnalyser,
audioChunks, recordingTime, recordingInterval, recordingMode, audioBlobURL,
estimatedFileSize, actualBitrate, recordingNotes, recordingQuality,
maxRecordingMB, fileSizeWarningShown, sizeCheckInterval, recordingDisclaimer,
showRecordingDisclaimerModal, pendingRecordingMode, currentView, isDarkMode, wakeLock, animationFrameId,
activeStreams, visualizer, micVisualizer, systemVisualizer, canRecordAudio,
canRecordSystemAudio, systemAudioSupported, systemAudioError, globalError,
selectedTagIds, selectedFolderId, asrLanguage, asrMinSpeakers, asrMaxSpeakers, uploadQueue,
progressPopupMinimized, progressPopupClosed,
// Incognito mode
enableIncognitoMode, incognitoMode, incognitoRecording, incognitoProcessing,
processingMessage, processingProgress, selectedRecording
} = state;
const { showToast, setGlobalError, formatFileSize, startUploadQueue } = utils;
// Local state for pending streams and chunk tracking
let pendingDisplayStream = null;
let currentChunkIndex = 0;
// iOS detection
const isiOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
// Silent audio for iOS wake lock alternative
let silentAudio = null;
// Create silent audio using data URL (1 second of silence)
const createSilentAudio = () => {
if (!silentAudio) {
// Base64 encoded 1-second silent MP3
const silentMp3 = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhAC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7v////////////////////////////////////////////////////////////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQCgAAAAAAAAAOEfxVqYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQZDwP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=';
silentAudio = new Audio(silentMp3);
silentAudio.loop = true;
silentAudio.volume = 0.01; // Very low volume, almost silent
}
return silentAudio;
};
// Start iOS wake lock (play silent audio)
const startiOSWakeLock = async () => {
try {
const audio = createSilentAudio();
await audio.play();
console.log('[iOS Wake Lock] Silent audio playing to prevent sleep');
return true;
} catch (error) {
console.warn('[iOS Wake Lock] Failed to start silent audio:', error);
showToast('iOS wake lock may not work - keep screen active', 'warning');
return false;
}
};
// Stop iOS wake lock (stop silent audio)
const stopiOSWakeLock = () => {
if (silentAudio) {
silentAudio.pause();
silentAudio.currentTime = 0;
console.log('[iOS Wake Lock] Silent audio stopped');
}
};
// Acquire wake lock to prevent screen from sleeping during recording
const acquireWakeLock = async () => {
// iOS doesn't support Wake Lock API - use silent audio instead
if (isiOS()) {
return await startiOSWakeLock();
}
// Android/Desktop: use native Wake Lock API
try {
if ('wakeLock' in navigator) {
wakeLock.value = await navigator.wakeLock.request('screen');
console.log('[WakeLock] Acquired - screen will stay awake during recording');
// Listen for wake lock release
wakeLock.value.addEventListener('release', () => {
console.log('[WakeLock] Released');
});
return true;
} else {
console.warn('[WakeLock] Wake Lock API not supported');
showToast('Screen may sleep during recording', 'info');
return false;
}
} catch (err) {
console.warn('[WakeLock] Could not acquire:', err.message);
if (err.name === 'NotAllowedError') {
showToast('Screen lock permission denied', 'warning');
} else if (err.name === 'NotSupportedError') {
showToast('Wake lock not supported on this device', 'info');
}
return false;
}
};
// Release wake lock
const releaseWakeLock = async () => {
// iOS: stop silent audio
if (isiOS()) {
stopiOSWakeLock();
return;
}
// Android/Desktop: release native wake lock
if (wakeLock.value) {
try {
await wakeLock.value.release();
wakeLock.value = null;
console.log('[WakeLock] Released');
} catch (err) {
console.warn('[WakeLock] Could not release:', err.message);
}
}
};
// Show recording notification
const showRecordingNotification = async () => {
if ('Notification' in window && Notification.permission === 'granted') {
// Notifications handled by service worker
}
};
// Note: System audio capability detection is now handled by computed property
// canRecordSystemAudio = computed(() => navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
// Hide recording notification
const hideRecordingNotification = async () => {
// Notifications cleared when recording stops
};
// Handle visibility change (for wake lock re-acquisition)
const handleVisibilityChange = async () => {
if (document.visibilityState === 'visible' && isRecording.value) {
console.log('[Visibility] Page visible, re-acquiring wake lock');
const acquired = await acquireWakeLock();
if (acquired) {
showToast('Recording resumed - screen will stay awake', 'success');
}
} else if (document.visibilityState === 'hidden' && isRecording.value) {
console.log('[Visibility] Page hidden, wake lock may be released by browser');
}
};
// Start recording
// IMPORTANT: For Firefox, getDisplayMedia MUST be the first async call from user gesture
const startRecording = async (mode = 'microphone') => {
const needsDisplayMedia = mode === 'system' || mode === 'both';
// For system audio modes, get display media FIRST before any other operations
// This is required for Firefox's "transient activation" security model
if (needsDisplayMedia) {
try {
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
// Check if we got an audio track
const audioTrack = displayStream.getAudioTracks()[0];
if (!audioTrack) {
displayStream.getTracks().forEach(track => track.stop());
showToast('No audio track - check "Share audio" option', 'error');
return;
}
// Store stream for use after disclaimer (if any)
pendingDisplayStream = displayStream;
} catch (error) {
console.error('[Recording] Failed to get display media:', error);
if (error.name === 'NotAllowedError') {
showToast('Screen sharing was cancelled', 'error');
} else {
showToast(`Failed to capture: ${error.message}`, 'error');
}
return;
}
}
// Now check for disclaimer (after we've secured the display stream)
if (recordingDisclaimer.value && recordingDisclaimer.value.trim() !== '') {
showRecordingDisclaimerModal.value = true;
pendingRecordingMode.value = mode;
return;
}
await startRecordingInternal(mode);
};
// Accept recording disclaimer and start recording
const acceptRecordingDisclaimer = async () => {
showRecordingDisclaimerModal.value = false;
await startRecordingInternal(pendingRecordingMode.value || 'microphone');
};
// Cancel recording disclaimer
const cancelRecordingDisclaimer = () => {
showRecordingDisclaimerModal.value = false;
// Clean up pending display stream if user cancels
if (pendingDisplayStream) {
pendingDisplayStream.getTracks().forEach(track => track.stop());
pendingDisplayStream = null;
}
pendingRecordingMode.value = null;
};
// Internal start recording function
const startRecordingInternal = async (mode) => {
try {
recordingMode.value = mode;
audioChunks.value = [];
recordingTime.value = 0;
estimatedFileSize.value = 0;
fileSizeWarningShown.value = false;
// Initialize IndexedDB session
currentChunkIndex = 0;
let stream;
let combinedStream;
if (mode === 'microphone') {
if (!canRecordAudio.value) {
throw new Error('Microphone recording is not available. Make sure you are using HTTPS.');
}
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000
}
});
activeStreams.value = [stream];
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.value.createMediaStreamSource(stream);
analyser.value = audioContext.value.createAnalyser();
analyser.value.fftSize = 256;
source.connect(analyser.value);
} else if (mode === 'system') {
if (!canRecordSystemAudio.value) {
throw new Error('System audio recording is not available. Make sure you are using HTTPS.');
}
// Use pre-obtained display stream (required for Firefox user gesture)
// or get it now for browsers that don't require immediate call
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
if (pendingDisplayStream) {
stream = pendingDisplayStream;
pendingDisplayStream = null;
} else {
const displayMediaConstraints = {
video: true,
audio: isFirefox ? true : {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
};
stream = await navigator.mediaDevices.getDisplayMedia(displayMediaConstraints);
}
const audioTrack = stream.getAudioTracks()[0];
if (!audioTrack) {
stream.getTracks().forEach(track => track.stop());
const browserName = isFirefox ? 'Firefox' : 'your browser';
throw new Error(
`No system audio track available. In ${browserName}, please:\n` +
`1. Share a BROWSER TAB that is actively playing audio\n` +
`2. Make sure "Share tab audio" checkbox is checked\n` +
`3. The audio must be playing when you start sharing`
);
}
// Stop video track
stream.getVideoTracks().forEach(track => track.stop());
stream = new MediaStream([audioTrack]);
activeStreams.value = [stream];
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.value.createMediaStreamSource(stream);
analyser.value = audioContext.value.createAnalyser();
analyser.value.fftSize = 256;
source.connect(analyser.value);
} else if (mode === 'both') {
if (!canRecordAudio.value || !canRecordSystemAudio.value) {
throw new Error('Recording is not available. Make sure you are using HTTPS.');
}
const micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000
}
});
// Use pre-obtained display stream or get it now
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
let displayStream;
if (pendingDisplayStream) {
displayStream = pendingDisplayStream;
pendingDisplayStream = null;
} else {
displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: isFirefox ? true : {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
}
const systemAudioTrack = displayStream.getAudioTracks()[0];
if (!systemAudioTrack) {
micStream.getTracks().forEach(track => track.stop());
displayStream.getTracks().forEach(track => track.stop());
const browserName = isFirefox ? 'Firefox' : 'your browser';
throw new Error(
`No system audio track available. In ${browserName}, please:\n` +
`1. Share a BROWSER TAB that is actively playing audio\n` +
`2. Make sure "Share tab audio" checkbox is checked\n` +
`3. The audio must be playing when you start sharing`
);
}
// Stop video tracks
displayStream.getVideoTracks().forEach(track => track.stop());
// Create audio context and combine streams
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
const destination = audioContext.value.createMediaStreamDestination();
const micSource = audioContext.value.createMediaStreamSource(micStream);
const systemSource = audioContext.value.createMediaStreamSource(new MediaStream([systemAudioTrack]));
// Create analysers for each source
micAnalyser.value = audioContext.value.createAnalyser();
micAnalyser.value.fftSize = 256;
systemAnalyser.value = audioContext.value.createAnalyser();
systemAnalyser.value.fftSize = 256;
micSource.connect(micAnalyser.value);
micSource.connect(destination);
systemSource.connect(systemAnalyser.value);
systemSource.connect(destination);
combinedStream = destination.stream;
activeStreams.value = [micStream, displayStream];
stream = combinedStream;
}
// Determine best mime type
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType });
// Start IndexedDB recording session - convert Vue reactive objects to plain objects
try {
await RecordingDB.startRecordingSession({
mode,
notes: recordingNotes.value || '',
tags: selectedTagIds.value ? [...selectedTagIds.value] : [], // Convert reactive array to plain array
asrOptions: {
language: asrLanguage.value || '',
min_speakers: asrMinSpeakers.value || '',
max_speakers: asrMaxSpeakers.value || ''
},
mimeType
});
} catch (dbError) {
console.warn('[Recording] IndexedDB persistence failed, continuing without persistence:', dbError);
}
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
audioChunks.value.push(event.data);
// Save chunk to IndexedDB for crash recovery
try {
await RecordingDB.saveChunk(event.data, currentChunkIndex);
await RecordingDB.updateRecordingMetadata({
duration: recordingTime.value,
notes: recordingNotes.value || ''
});
currentChunkIndex++;
} catch (dbError) {
// Don't spam console - recording continues in memory regardless
}
}
};
recorder.onstop = () => {
const blob = new Blob(audioChunks.value, { type: mimeType });
audioBlobURL.value = URL.createObjectURL(blob);
stopSizeMonitoring();
};
mediaRecorder.value = recorder;
recorder.start(5000); // 5-second chunks for less overhead while still enabling crash recovery
isRecording.value = true;
// Switch to recording view immediately so pending wake-lock/notification awaits don't block Safari rendering
currentView.value = 'recording';
// Start timer
recordingInterval.value = setInterval(() => {
recordingTime.value++;
}, 1000);
// Start size monitoring
startSizeMonitoring();
// Acquire wake lock
await acquireWakeLock();
// Show notification
await showRecordingNotification();
// Start visualizers
drawVisualizers();
// Notify service worker
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'RECORDING_STATE',
isRecording: true
});
}
} catch (error) {
console.error('Recording error:', error);
setGlobalError(`Failed to start recording: ${error.message}`);
// Clean up any started streams
if (activeStreams.value.length > 0) {
activeStreams.value.forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
activeStreams.value = [];
}
}
};
// Stop recording
const stopRecording = async () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop();
isRecording.value = false;
// Clear the recording timer
if (recordingInterval.value) {
clearInterval(recordingInterval.value);
recordingInterval.value = null;
}
stopSizeMonitoring();
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
// Stop all active media streams (mic, screen share, etc.)
if (activeStreams.value.length > 0) {
activeStreams.value.forEach(stream => {
stream.getTracks().forEach(track => track.stop());
});
activeStreams.value = [];
}
// Release wake lock
await releaseWakeLock();
// Hide recording notification
await hideRecordingNotification();
// Notify service worker
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'RECORDING_STATE',
isRecording: false,
duration: recordingTime.value
});
}
}
};
// Upload recorded audio
const uploadRecordedAudio = async () => {
if (!audioBlobURL.value) {
setGlobalError("No recorded audio to upload.");
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
// Get selected tags as objects and create a DEEP copy to prevent reactivity issues
const selectedTagsTemp = selectedTagIds.value.map(tagId => {
const tag = state.availableTags.value.find(t => t.id == tagId);
return tag || null;
}).filter(Boolean);
// Deep clone to completely break reactivity chain - JSON parse/stringify removes all proxies
const selectedTags = JSON.parse(JSON.stringify(selectedTagsTemp));
// Add to upload queue
uploadQueue.value.push({
file: recordedFile,
notes: recordingNotes.value,
tags: selectedTags, // Completely non-reactive deep copy
folder_id: selectedFolderId.value,
preserveOptions: true, // Prevents startUpload from overwriting recording's options
asrOptions: {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value
},
status: 'queued',
recordingId: null,
clientId: `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
error: null,
willAutoSummarize: false, // Server will tell us via SUMMARIZING status
// Callback to clear IndexedDB session AFTER successful upload (not before)
onUploadSuccess: async () => {
try {
await RecordingDB.clearRecordingSession();
console.log('[Recording] IndexedDB session cleared after successful upload');
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
}
});
discardRecording();
// Return to upload view (main UI)
currentView.value = 'upload';
// Start upload immediately
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
if (startUploadQueue) {
startUploadQueue();
}
};
// Upload recorded audio in incognito mode
const uploadRecordedAudioIncognito = async () => {
if (!audioBlobURL.value) {
setGlobalError("No recorded audio to upload.");
return;
}
// Check if incognito state is available
if (!incognitoProcessing || !incognitoRecording) {
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
uploadRecordedAudio();
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const recordedFile = new File(audioChunks.value, `recording-${timestamp}.webm`, { type: 'audio/webm' });
incognitoProcessing.value = true;
processingMessage.value = 'Processing recording in incognito mode...';
processingProgress.value = 10;
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
try {
const formData = new FormData();
formData.append('file', recordedFile);
// Add ASR options
if (asrLanguage.value) {
formData.append('language', asrLanguage.value);
}
if (asrMinSpeakers.value && asrMinSpeakers.value !== '') {
formData.append('min_speakers', asrMinSpeakers.value.toString());
}
if (asrMaxSpeakers.value && asrMaxSpeakers.value !== '') {
formData.append('max_speakers', asrMaxSpeakers.value.toString());
}
// Request auto-summarization
formData.append('auto_summarize', 'true');
processingMessage.value = 'Uploading recording for incognito processing...';
processingProgress.value = 20;
console.log('[Incognito] Uploading recorded audio');
const response = await fetch('/api/recordings/incognito', {
method: 'POST',
body: formData
});
processingProgress.value = 50;
// Parse response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
}
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `Processing failed with status ${response.status}`);
}
processingProgress.value = 80;
processingMessage.value = 'Processing complete!';
// Store result in sessionStorage
const incognitoData = {
id: 'incognito',
incognito: true,
title: data.title || 'Incognito Recording',
transcription: data.transcription,
summary: data.summary,
summary_html: data.summary_html,
created_at: data.created_at,
original_filename: data.original_filename,
file_size: data.file_size,
audio_duration_seconds: data.audio_duration_seconds,
processing_time_seconds: data.processing_time_seconds,
status: 'COMPLETED'
};
IncognitoStorage.saveIncognitoRecording(incognitoData);
incognitoRecording.value = incognitoData;
// Clear IndexedDB session
try {
await RecordingDB.clearRecordingSession();
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
// Clear recording state (must await so currentView='upload' completes
// before we override it with 'detail', otherwise the deferred
// currentView='upload' fires after 'detail' and the view watcher
// clears incognito data thinking we navigated away)
await discardRecording();
processingProgress.value = 100;
processingMessage.value = 'Incognito recording ready!';
// Auto-select the incognito recording and switch to detail view
selectedRecording.value = incognitoData;
currentView.value = 'detail';
// Reset incognito mode toggle
incognitoMode.value = false;
// Show toast
showToast('Incognito recording processed - data will be lost when tab closes', 'fa-user-secret');
console.log('[Incognito] Recording processing complete');
} catch (error) {
console.error('[Incognito] Recording processing failed:', error);
setGlobalError(`Incognito processing failed: ${error.message}`);
} finally {
incognitoProcessing.value = false;
}
};
// Discard recording
const discardRecording = async () => {
if (audioBlobURL.value) {
URL.revokeObjectURL(audioBlobURL.value);
}
audioBlobURL.value = null;
audioChunks.value = [];
isRecording.value = false;
recordingTime.value = 0;
if (recordingInterval.value) clearInterval(recordingInterval.value);
recordingNotes.value = '';
selectedTagIds.value = [];
asrLanguage.value = '';
asrMinSpeakers.value = '';
asrMaxSpeakers.value = '';
// Clear IndexedDB session
try {
await RecordingDB.clearRecordingSession();
} catch (dbError) {
console.warn('[Recording] Failed to clear IndexedDB session:', dbError);
}
await releaseWakeLock();
await hideRecordingNotification();
// Return to upload view
currentView.value = 'upload';
};
// Draw single visualizer
const drawSingleVisualizer = (analyserNode, canvasElement) => {
if (!analyserNode || !canvasElement) return;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyserNode.getByteFrequencyData(dataArray);
const canvasCtx = canvasElement.getContext('2d');
const WIDTH = canvasElement.width;
const HEIGHT = canvasElement.height;
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
const barWidth = (WIDTH / bufferLength) * 1.5;
let barHeight;
let x = 0;
const buttonColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button').trim();
const buttonHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-button-hover').trim();
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
if (isDarkMode.value) {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.6, buttonHoverColor);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
} else {
gradient.addColorStop(0, buttonColor);
gradient.addColorStop(0.5, buttonHoverColor);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)');
}
for (let i = 0; i < bufferLength; i++) {
barHeight = dataArray[i] / 2.5;
canvasCtx.fillStyle = gradient;
canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 2;
}
};
// Draw visualizers
const drawVisualizers = () => {
if (!isRecording.value) {
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
return;
}
animationFrameId.value = requestAnimationFrame(drawVisualizers);
if (recordingMode.value === 'both') {
drawSingleVisualizer(micAnalyser.value, micVisualizer.value);
drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value);
} else {
drawSingleVisualizer(analyser.value, visualizer.value);
}
};
// Update file size estimate
const updateFileSizeEstimate = () => {
if (!isRecording.value || !audioChunks.value.length) return;
const totalSize = audioChunks.value.reduce((sum, chunk) => sum + chunk.size, 0);
estimatedFileSize.value = totalSize;
if (recordingTime.value > 0) {
actualBitrate.value = (totalSize * 8) / recordingTime.value;
}
// Check for size warning
const sizeMB = totalSize / (1024 * 1024);
const warningThresholdMB = maxRecordingMB.value * 0.8;
if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) {
fileSizeWarningShown.value = true;
showToast(
`Recording size is ${formatFileSize(totalSize)}. Consider stopping soon.`,
'fa-exclamation-triangle',
5000
);
}
// Auto-stop if max size reached
if (sizeMB > maxRecordingMB.value) {
stopRecording();
showToast(
`Recording automatically stopped at ${formatFileSize(totalSize)}`,
'fa-stop-circle',
7000
);
}
};
// Start size monitoring
const startSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
}
sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 2000);
};
// Stop size monitoring
const stopSizeMonitoring = () => {
if (sizeCheckInterval.value) {
clearInterval(sizeCheckInterval.value);
sizeCheckInterval.value = null;
}
};
// Check if there's an unsaved recording
const hasUnsavedRecording = () => {
return isRecording.value || audioBlobURL.value;
};
// Recover recording from IndexedDB
const recoverRecordingFromDB = async () => {
try {
const recovered = await RecordingDB.recoverRecording();
if (!recovered) {
return null;
}
// Restore chunks
audioChunks.value = recovered.chunks;
// Create blob URL
const blob = new Blob(recovered.chunks, { type: recovered.metadata.mimeType });
audioBlobURL.value = URL.createObjectURL(blob);
// Restore metadata
recordingMode.value = recovered.metadata.mode;
recordingNotes.value = recovered.metadata.notes;
selectedTagIds.value = recovered.metadata.tags;
recordingTime.value = recovered.metadata.duration;
if (recovered.metadata.asrOptions) {
asrLanguage.value = recovered.metadata.asrOptions.language || '';
asrMinSpeakers.value = recovered.metadata.asrOptions.min_speakers || '';
asrMaxSpeakers.value = recovered.metadata.asrOptions.max_speakers || '';
}
console.log('[Recording] Successfully recovered recording from IndexedDB');
return recovered.metadata;
} catch (error) {
console.error('[Recording] Failed to recover recording:', error);
return null;
}
};
// No initialization needed - system audio detection is handled by computed property
const initializeAudio = async () => {
// Placeholder for future initialization if needed
};
return {
startRecording,
stopRecording,
discardRecording,
uploadRecordedAudio,
uploadRecordedAudioIncognito,
acceptRecordingDisclaimer,
cancelRecordingDisclaimer,
updateFileSizeEstimate,
startSizeMonitoring,
stopSizeMonitoring,
drawVisualizers,
drawSingleVisualizer,
handleVisibilityChange,
hasUnsavedRecording,
acquireWakeLock,
releaseWakeLock,
initializeAudio,
recoverRecordingFromDB,
checkForRecoverableRecording: RecordingDB.checkForRecoverableRecording,
clearRecordingSession: RecordingDB.clearRecordingSession
};
}

View File

@@ -0,0 +1,338 @@
/**
* Audio Player Composable
*
* Centralized audio playback functionality for consistent behavior across the app.
* This module handles:
* - Playback state (playing, paused, loading)
* - Time tracking (current time, duration)
* - Volume/mute control
* - Seeking with progress bar support
* - Server-side duration support (for formats like WebM that don't report duration)
*
* Usage:
* const player = useAudioPlayer(ref, computed);
* // In template: @loadedmetadata="player.handleLoadedMetadata"
* // When recording changes: player.setServerDuration(recording.audio_duration)
*/
export function useAudioPlayer(ref, computed) {
// --- State ---
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const isMuted = ref(false);
const isLoading = ref(false);
const volume = ref(1.0);
// Progress bar drag state
const isDragging = ref(false);
const dragPreviewPercent = ref(0);
// Track if we have a reliable server-side duration
let hasServerDuration = false;
// --- Computed ---
const progressPercent = computed(() => {
// Use preview position while dragging for smooth UI
if (isDragging.value) {
return dragPreviewPercent.value;
}
if (!duration.value) return 0;
return (currentTime.value / duration.value) * 100;
});
// Preview time display while dragging
const displayCurrentTime = computed(() => {
if (isDragging.value && duration.value) {
return (dragPreviewPercent.value / 100) * duration.value;
}
return currentTime.value;
});
// --- Duration Management ---
/**
* Set duration from server-side ffprobe value.
* This is more reliable than browser metadata for some formats (WebM, etc.)
*/
const setServerDuration = (serverDuration) => {
if (serverDuration && isFinite(serverDuration) && serverDuration > 0) {
duration.value = serverDuration;
hasServerDuration = true;
} else {
hasServerDuration = false;
}
};
/**
* Try to set duration from browser, only if we don't have a server-side value.
* Browser duration can be Infinity for some WebM files.
*/
const trySetBrowserDuration = (browserDuration) => {
if (hasServerDuration) {
// Don't overwrite reliable server-side duration
return;
}
if (browserDuration && isFinite(browserDuration) && browserDuration > 0) {
duration.value = browserDuration;
}
};
// --- Event Handlers ---
const handlePlayPause = (event) => {
isPlaying.value = !event.target.paused;
};
const handleLoadedMetadata = (event) => {
trySetBrowserDuration(event.target.duration);
isLoading.value = false;
};
const handleDurationChange = (event) => {
// WebM and some formats may initially report Infinity duration
// This handler catches when the actual duration becomes available
trySetBrowserDuration(event.target.duration);
};
const handleTimeUpdate = (event) => {
currentTime.value = event.target.currentTime;
// Fallback: if duration wasn't set yet, try to get it now
if (!duration.value || duration.value === 0) {
trySetBrowserDuration(event.target.duration);
}
};
const handleEnded = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const handleWaiting = () => {
isLoading.value = true;
};
const handleCanPlay = (event) => {
isLoading.value = false;
// Fallback: try to get duration if not set yet
if (!duration.value || duration.value === 0) {
trySetBrowserDuration(event.target.duration);
}
};
const handleVolumeChange = (event) => {
volume.value = event.target.volume;
isMuted.value = event.target.muted;
};
// --- Actions ---
/**
* Get the audio element. Override this for custom element selection.
*/
let getAudioElement = () => {
return document.querySelector('audio[ref="audioPlayerElement"]') ||
document.querySelector('video[ref="audioPlayerElement"]') ||
document.querySelector('audio') ||
document.querySelector('video');
};
/**
* Set custom audio element getter.
*/
const setAudioElementGetter = (getter) => {
getAudioElement = getter;
};
const play = () => {
const audio = getAudioElement();
if (audio) {
audio.play().catch(err => console.warn('Play failed:', err));
}
};
const pause = () => {
const audio = getAudioElement();
if (audio) {
audio.pause();
}
};
const togglePlayback = () => {
const audio = getAudioElement();
if (!audio) return;
if (audio.paused) {
audio.play().catch(err => console.warn('Play failed:', err));
} else {
audio.pause();
}
};
const seekTo = (time) => {
const audio = getAudioElement();
if (!audio || !isFinite(time)) return;
const maxTime = isFinite(audio.duration) ? audio.duration : time;
audio.currentTime = Math.max(0, Math.min(time, maxTime));
};
const seekByPercent = (percent) => {
const audio = getAudioElement();
if (!audio || !duration.value || !isFinite(duration.value)) return;
const time = (percent / 100) * duration.value;
audio.currentTime = time;
};
const setVolume = (value) => {
const audio = getAudioElement();
if (audio) {
audio.volume = Math.max(0, Math.min(1, value));
volume.value = audio.volume;
}
};
const toggleMute = () => {
const audio = getAudioElement();
if (!audio) return;
if (audio.muted || audio.volume === 0) {
audio.muted = false;
if (audio.volume === 0) {
audio.volume = 0.5;
}
isMuted.value = false;
} else {
audio.muted = true;
isMuted.value = true;
}
};
// --- Progress Bar Drag Support ---
const startProgressDrag = (event) => {
const bar = 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));
};
// Start dragging - show preview
isDragging.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 = () => {
document.removeEventListener(isTouch ? 'touchmove' : 'mousemove', onMove);
document.removeEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
// Seek to final position on release
seekByPercent(dragPreviewPercent.value);
isDragging.value = false;
};
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove, { passive: false });
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onUp);
};
// --- Utility ---
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
/**
* Reset all player state (call when changing recordings)
*/
const reset = () => {
isPlaying.value = false;
currentTime.value = 0;
duration.value = 0;
isMuted.value = false;
isLoading.value = false;
hasServerDuration = false;
};
/**
* Initialize with a recording object.
* Automatically uses server-side duration if available.
*/
const initWithRecording = (recording) => {
reset();
if (recording && recording.audio_duration) {
setServerDuration(recording.audio_duration);
}
};
return {
// State
isPlaying,
currentTime,
duration,
isMuted,
isLoading,
volume,
isDragging,
dragPreviewPercent,
// Computed
progressPercent,
displayCurrentTime,
// Duration management
setServerDuration,
trySetBrowserDuration,
// Event handlers (wire these to <audio> element)
handlePlayPause,
handleLoadedMetadata,
handleDurationChange,
handleTimeUpdate,
handleEnded,
handleWaiting,
handleCanPlay,
handleVolumeChange,
// Actions
play,
pause,
togglePlayback,
seekTo,
seekByPercent,
setVolume,
toggleMute,
startProgressDrag,
setAudioElementGetter,
// Utility
formatTime,
reset,
initWithRecording
};
}
/**
* Create a standalone audio player instance.
* Use this for pages that don't have Vue's ref/computed (like share.html).
*/
export function createStandalonePlayer(Vue) {
const { ref, computed } = Vue;
return useAudioPlayer(ref, computed);
}

View File

@@ -0,0 +1,475 @@
/**
* Bulk Operations Composable
* Handles bulk API operations for multiple recordings
*/
const { ref, computed } = Vue;
export function useBulkOperations({
selectedRecordingIds,
selectedRecordings,
recordings,
selectedRecording,
bulkActionInProgress,
availableTags,
availableFolders,
showToast,
setGlobalError,
startReprocessingPoll
}) {
// Modal state
const showBulkDeleteModal = ref(false);
const showBulkTagModal = ref(false);
const showBulkReprocessModal = ref(false);
const showBulkFolderModal = ref(false);
const bulkTagAction = ref('add'); // 'add' or 'remove'
const bulkTagSelectedId = ref('');
const bulkReprocessType = ref('summary'); // 'transcription' or 'summary'
// Get CSRF token
const getCsrfToken = () => {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
};
// Helper to get selected IDs as array
const getSelectedIds = () => {
return Array.from(selectedRecordingIds.value);
};
// =========================================
// Bulk Delete
// =========================================
const openBulkDeleteModal = () => {
showBulkDeleteModal.value = true;
};
const closeBulkDeleteModal = () => {
showBulkDeleteModal.value = false;
};
const executeBulkDelete = async () => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
closeBulkDeleteModal();
try {
const response = await fetch('/api/recordings/bulk', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({ recording_ids: ids })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to delete recordings');
}
// Remove deleted recordings from local state
const deletedIds = new Set(data.deleted_ids || ids);
recordings.value = recordings.value.filter(r => !deletedIds.has(r.id));
// Clear selected recording if it was deleted
if (selectedRecording.value && deletedIds.has(selectedRecording.value.id)) {
selectedRecording.value = null;
}
// Remove deleted IDs from selection
deletedIds.forEach(id => selectedRecordingIds.value.delete(id));
const count = deletedIds.size;
showToast(`${count} recording${count !== 1 ? 's' : ''} deleted`, 'fa-trash', 3000, 'success');
} catch (error) {
console.error('Bulk delete error:', error);
setGlobalError(`Failed to delete recordings: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Tag Operations
// =========================================
const openBulkTagModal = (action = 'add') => {
bulkTagAction.value = action;
bulkTagSelectedId.value = '';
showBulkTagModal.value = true;
};
const closeBulkTagModal = () => {
showBulkTagModal.value = false;
bulkTagSelectedId.value = '';
};
const executeBulkTag = async () => {
const ids = getSelectedIds();
const tagId = bulkTagSelectedId.value;
const action = bulkTagAction.value;
// Validate before making API call
if (ids.length === 0) {
console.warn('No recordings selected for bulk tag operation');
return;
}
if (!tagId && tagId !== 0) {
console.warn('No tag selected for bulk tag operation');
return;
}
bulkActionInProgress.value = true;
closeBulkTagModal();
try {
const response = await fetch('/api/recordings/bulk-tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
tag_id: parseInt(tagId),
action: action
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Failed to ${action} tag`);
}
// Update local state
const tag = availableTags.value.find(t => t.id == tagId);
if (tag) {
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
if (!recording.tags) recording.tags = [];
if (action === 'add') {
// Add tag if not already present
if (!recording.tags.find(t => t.id === tag.id)) {
recording.tags.push(tag);
}
} else {
// Remove tag
recording.tags = recording.tags.filter(t => t.id !== tag.id);
}
}
});
// Update selected recording if affected
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
if (!selectedRecording.value.tags) selectedRecording.value.tags = [];
if (action === 'add') {
if (!selectedRecording.value.tags.find(t => t.id === tag.id)) {
selectedRecording.value.tags.push(tag);
}
} else {
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tag.id);
}
}
}
const count = data.affected_ids?.length || ids.length;
const actionText = action === 'add' ? 'added to' : 'removed from';
showToast(`Tag ${actionText} ${count} recording${count !== 1 ? 's' : ''}`, 'fa-tags', 3000, 'success');
} catch (error) {
console.error('Bulk tag error:', error);
setGlobalError(`Failed to ${action} tag: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Reprocess
// =========================================
const openBulkReprocessModal = () => {
bulkReprocessType.value = 'summary';
showBulkReprocessModal.value = true;
};
const closeBulkReprocessModal = () => {
showBulkReprocessModal.value = false;
};
const executeBulkReprocess = async () => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
closeBulkReprocessModal();
try {
const response = await fetch('/api/recordings/bulk-reprocess', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
type: bulkReprocessType.value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to queue reprocessing');
}
// Update status for queued recordings
const queuedIds = new Set(data.queued_ids || ids);
const newStatus = bulkReprocessType.value === 'transcription' ? 'PROCESSING' : 'SUMMARIZING';
recordings.value.forEach(recording => {
if (queuedIds.has(recording.id)) {
recording.status = newStatus;
// Start polling for each
if (startReprocessingPoll) {
startReprocessingPoll(recording.id);
}
}
});
if (selectedRecording.value && queuedIds.has(selectedRecording.value.id)) {
selectedRecording.value.status = newStatus;
}
const count = queuedIds.size;
const typeText = bulkReprocessType.value === 'transcription' ? 'Transcription' : 'Summary';
showToast(`${typeText} reprocessing queued for ${count} recording${count !== 1 ? 's' : ''}`, 'fa-sync-alt', 3000, 'success');
} catch (error) {
console.error('Bulk reprocess error:', error);
setGlobalError(`Failed to queue reprocessing: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Toggle (Inbox/Highlight)
// =========================================
const bulkToggleInbox = async (value = null) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
// If no value specified, toggle based on majority
if (value === null) {
const inboxCount = selectedRecordings.value.filter(r => r.is_inbox).length;
value = inboxCount < ids.length / 2;
}
bulkActionInProgress.value = true;
try {
const response = await fetch('/api/recordings/bulk-toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
field: 'inbox',
value: value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update inbox status');
}
// Update local state
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
recording.is_inbox = value;
}
});
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
selectedRecording.value.is_inbox = value;
}
const count = affectedIds.size;
const actionText = value ? 'added to' : 'removed from';
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText} inbox`, 'fa-inbox', 3000, 'success');
} catch (error) {
console.error('Bulk toggle inbox error:', error);
setGlobalError(`Failed to update inbox status: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
const bulkToggleHighlight = async (value = null) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
// If no value specified, toggle based on majority
if (value === null) {
const highlightCount = selectedRecordings.value.filter(r => r.is_highlighted).length;
value = highlightCount < ids.length / 2;
}
bulkActionInProgress.value = true;
try {
const response = await fetch('/api/recordings/bulk-toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
field: 'highlight',
value: value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update highlight status');
}
// Update local state
const affectedIds = new Set(data.affected_ids || ids);
recordings.value.forEach(recording => {
if (affectedIds.has(recording.id)) {
recording.is_highlighted = value;
}
});
if (selectedRecording.value && affectedIds.has(selectedRecording.value.id)) {
selectedRecording.value.is_highlighted = value;
}
const count = affectedIds.size;
const actionText = value ? 'highlighted' : 'unhighlighted';
showToast(`${count} recording${count !== 1 ? 's' : ''} ${actionText}`, 'fa-star', 3000, 'success');
} catch (error) {
console.error('Bulk toggle highlight error:', error);
setGlobalError(`Failed to update highlight status: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
// =========================================
// Bulk Folder Assignment
// =========================================
const bulkAssignFolder = async (folderId) => {
const ids = getSelectedIds();
if (ids.length === 0) return;
bulkActionInProgress.value = true;
showBulkFolderModal.value = false;
try {
const response = await fetch('/api/recordings/bulk/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
recording_ids: ids,
folder_id: folderId
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update folders');
}
// Update local state
const folder = folderId ? availableFolders.value.find(f => f.id === folderId) : null;
recordings.value.forEach(recording => {
if (ids.includes(recording.id)) {
recording.folder_id = folderId;
recording.folder = folder;
}
});
// Update selected recording if affected
if (selectedRecording.value && ids.includes(selectedRecording.value.id)) {
selectedRecording.value.folder_id = folderId;
selectedRecording.value.folder = folder;
}
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
const count = data.updated_count || ids.length;
if (folderId) {
showToast(`${count} recording${count !== 1 ? 's' : ''} moved to "${folder?.name || 'folder'}"`, 'fa-folder', 3000, 'success');
} else {
showToast(`${count} recording${count !== 1 ? 's' : ''} removed from folder`, 'fa-folder-minus', 3000, 'success');
}
} catch (error) {
console.error('Bulk folder assignment error:', error);
setGlobalError(`Failed to update folders: ${error.message}`);
} finally {
bulkActionInProgress.value = false;
}
};
return {
// Modal state
showBulkDeleteModal,
showBulkTagModal,
showBulkReprocessModal,
showBulkFolderModal,
bulkTagAction,
bulkTagSelectedId,
bulkReprocessType,
// Bulk Delete
openBulkDeleteModal,
closeBulkDeleteModal,
executeBulkDelete,
// Bulk Tag
openBulkTagModal,
closeBulkTagModal,
executeBulkTag,
// Bulk Reprocess
openBulkReprocessModal,
closeBulkReprocessModal,
executeBulkReprocess,
// Bulk Toggle
bulkToggleInbox,
bulkToggleHighlight,
// Bulk Folder
bulkAssignFolder
};
}

View File

@@ -0,0 +1,111 @@
/**
* Bulk Selection Composable
* Handles multi-select functionality for recordings
*/
const { computed } = Vue;
export function useBulkSelection({
selectionMode,
selectedRecordingIds,
recordings,
selectedRecording,
currentView
}) {
// Computed
const selectedCount = computed(() => selectedRecordingIds.value.size);
const selectedRecordings = computed(() => {
return recordings.value.filter(r => selectedRecordingIds.value.has(r.id));
});
const allVisibleSelected = computed(() => {
if (recordings.value.length === 0) return false;
return recordings.value.every(r => selectedRecordingIds.value.has(r.id));
});
const isSelected = (id) => {
return selectedRecordingIds.value.has(id);
};
// Methods
const enterSelectionMode = () => {
selectionMode.value = true;
selectedRecordingIds.value = new Set();
};
const exitSelectionMode = () => {
selectionMode.value = false;
selectedRecordingIds.value = new Set();
};
const toggleSelection = (id) => {
const newSet = new Set(selectedRecordingIds.value);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
selectedRecordingIds.value = newSet;
};
const selectAll = () => {
const newSet = new Set();
recordings.value.forEach(r => newSet.add(r.id));
selectedRecordingIds.value = newSet;
};
const clearSelection = () => {
selectedRecordingIds.value = new Set();
};
// Keyboard handler for selection mode
const handleSelectionKeyboard = (event) => {
if (!selectionMode.value) return;
// Escape to exit selection mode
if (event.key === 'Escape') {
exitSelectionMode();
event.preventDefault();
}
// Ctrl/Cmd + A to select all
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
// Only if not in an input field
if (document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA' &&
!document.activeElement.isContentEditable) {
event.preventDefault();
selectAll();
}
}
};
// Initialize keyboard listener
const initSelectionKeyboardListeners = () => {
document.addEventListener('keydown', handleSelectionKeyboard);
};
const cleanupSelectionKeyboardListeners = () => {
document.removeEventListener('keydown', handleSelectionKeyboard);
};
return {
// Computed
selectedCount,
selectedRecordings,
allVisibleSelected,
// Methods
isSelected,
enterSelectionMode,
exitSelectionMode,
toggleSelection,
selectAll,
clearSelection,
// Keyboard
initSelectionKeyboardListeners,
cleanupSelectionKeyboardListeners
};
}

View File

@@ -0,0 +1,380 @@
/**
* Chat composable
* Handles AI chat functionality with streaming responses
*/
export function useChat(state, utils) {
const {
showChat, isChatMaximized, chatMessages, chatInput,
isChatLoading, chatMessagesRef, chatInputRef, selectedRecording, csrfToken
} = state;
const { showToast, setGlobalError, onChatComplete, t } = utils;
// Helper function to check if chat is scrolled to bottom (within bottom 5%)
const isChatScrolledToBottom = () => {
if (!chatMessagesRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
const scrollableHeight = scrollHeight - clientHeight;
if (scrollableHeight <= 0) return true;
const scrollPercentage = scrollTop / scrollableHeight;
return scrollPercentage >= 0.95;
};
// Helper function to scroll chat to bottom
const scrollChatToBottom = () => {
if (chatMessagesRef.value) {
requestAnimationFrame(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
}
});
}
};
const focusChatInput = () => {
Vue.nextTick(() => {
if (chatInputRef.value) {
chatInputRef.value.focus();
}
});
};
const toggleChatMaximize = () => {
if (isChatMaximized.value) {
isChatMaximized.value = false;
} else {
isChatMaximized.value = true;
if (!showChat.value) {
showChat.value = true;
}
}
};
const sendChatMessage = async () => {
if (!chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== 'COMPLETED') {
return;
}
const message = chatInput.value.trim();
if (!Array.isArray(chatMessages.value)) {
chatMessages.value = [];
}
chatMessages.value.push({ role: 'user', content: message });
chatInput.value = '';
isChatLoading.value = true;
focusChatInput();
await Vue.nextTick();
scrollChatToBottom();
let assistantMessage = null;
try {
const messageHistory = chatMessages.value
.slice(0, -1)
.map(msg => ({ role: msg.role, content: msg.content }));
// Check if this is an incognito recording
const isIncognito = selectedRecording.value.incognito === true;
let response;
if (isIncognito) {
// Use incognito chat endpoint - pass transcription directly
response = await fetch('/api/recordings/incognito/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcription: selectedRecording.value.transcription,
participants: selectedRecording.value.participants || '',
notes: selectedRecording.value.notes || '',
message: message,
message_history: messageHistory
})
});
} else {
// Use regular chat endpoint
response = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recording_id: selectedRecording.value.id,
message: message,
message_history: messageHistory
})
});
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to get chat response');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = async () => {
let isFirstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6);
// Handle [DONE] marker from incognito endpoint
if (jsonStr === '[DONE]') {
return;
}
if (jsonStr) {
try {
const data = JSON.parse(jsonStr);
if (data.thinking) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = Vue.reactive({
role: 'assistant',
content: '',
html: '',
thinking: data.thinking,
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
} else if (assistantMessage) {
if (assistantMessage.thinking) {
assistantMessage.thinking += '\n\n' + data.thinking;
} else {
assistantMessage.thinking = data.thinking;
}
}
if (shouldScroll) {
await Vue.nextTick();
scrollChatToBottom();
}
}
// Handle both 'delta' (regular) and 'content' (incognito) formats
const textContent = data.delta || data.content;
if (textContent) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = Vue.reactive({
role: 'assistant',
content: '',
html: '',
thinking: '',
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
}
assistantMessage.content += textContent;
assistantMessage.html = marked.parse(assistantMessage.content);
if (shouldScroll) {
await Vue.nextTick();
scrollChatToBottom();
}
}
if (data.end_of_stream) {
return;
}
if (data.error) {
if (data.budget_exceeded) {
throw new Error(t('adminDashboard.tokenBudgetExceeded'));
}
throw new Error(data.error);
}
} catch (e) {
console.error('Error parsing stream data:', e);
}
}
}
}
}
};
await processStream();
} catch (error) {
console.error('Chat Error:', error);
if (assistantMessage) {
assistantMessage.content = `Error: ${error.message}`;
assistantMessage.html = `<span class="text-red-500">Error: ${error.message}</span>`;
} else {
chatMessages.value.push({
role: 'assistant',
content: `Error: ${error.message}`,
html: `<span class="text-red-500">Error: ${error.message}</span>`
});
}
} finally {
isChatLoading.value = false;
await Vue.nextTick();
if (isChatScrolledToBottom()) {
scrollChatToBottom();
}
focusChatInput();
// Refresh token budget after chat completion
if (onChatComplete) {
onChatComplete();
}
}
};
const handleChatKeydown = (event) => {
if (event.key === 'Enter') {
if (event.ctrlKey || event.shiftKey) {
return;
} else {
event.preventDefault();
sendChatMessage();
}
}
};
const clearChat = () => {
if (chatMessages.value.length > 0) {
chatMessages.value = [];
showToast(t('chat.cleared'), 'fa-broom');
}
};
const downloadChat = async () => {
if (!selectedRecording.value || chatMessages.value.length === 0) {
showToast(t('chat.noMessagesToDownload'), 'fa-exclamation-circle');
return;
}
try {
const csrfTokenValue = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/download/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfTokenValue
},
body: JSON.stringify({
messages: chatMessages.value
})
});
if (!response.ok) {
const error = await response.json();
showToast(error.error || t('chat.downloadFailed'), '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 = 'chat.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('chat.downloadSuccess'));
} catch (error) {
console.error('Download failed:', error);
showToast(t('chat.downloadFailed'), 'fa-exclamation-circle');
}
};
const copyMessage = (text, event) => {
const button = event.currentTarget;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => {
showToast(t('messages.copiedSuccessfully'));
animateCopyButton(button);
})
.catch(err => {
console.error('Copy failed:', err);
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
fallbackCopyTextToClipboard(text, button);
});
} else {
fallbackCopyTextToClipboard(text, button);
}
};
const animateCopyButton = (button) => {
button.classList.add('copy-success');
const originalContent = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(() => {
button.classList.remove('copy-success');
button.innerHTML = originalContent;
}, 1500);
};
const fallbackCopyTextToClipboard = (text, button = null) => {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showToast(t('messages.copiedSuccessfully'));
if (button) animateCopyButton(button);
} else {
showToast(t('messages.copyNotSupported'), 'fa-exclamation-circle');
}
} catch (err) {
console.error('Fallback copy failed:', err);
showToast(t('messages.copyFailed') + ': ' + err.message, 'fa-exclamation-circle');
}
};
return {
isChatScrolledToBottom,
scrollChatToBottom,
toggleChatMaximize,
sendChatMessage,
handleChatKeydown,
clearChat,
downloadChat,
copyMessage,
animateCopyButton,
fallbackCopyTextToClipboard
};
}

View File

@@ -0,0 +1,173 @@
/**
* Folders Management Composable
* Handles folder operations for recordings
*/
const { computed, ref } = Vue;
export function useFolders({
recordings,
availableFolders,
selectedRecording,
showToast,
setGlobalError
}) {
// Computed / Helpers
const getRecordingFolder = (recording) => {
if (!recording || !recording.folder_id) return null;
// Try to get from recording.folder first, then lookup
if (recording.folder) return recording.folder;
return availableFolders.value?.find(f => f.id === recording.folder_id) || null;
};
const getFolderById = (folderId) => {
if (!folderId || !availableFolders.value) return null;
// Use == for loose equality to handle string/number type mismatch (e.g., from localStorage)
return availableFolders.value.find(f => f.id == folderId) || null;
};
const getFolderColor = (folderId) => {
const folder = getFolderById(folderId);
return folder?.color || '#10B981';
};
const getFolderName = (folderId) => {
const folder = getFolderById(folderId);
return folder?.name || 'Folder';
};
const getAvailableFoldersForRecording = () => {
if (!availableFolders.value) return [];
return availableFolders.value;
};
// Methods
const assignFolderToRecording = async (recordingId, folderId) => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${recordingId}/folder`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ folder_id: folderId || null })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update folder');
}
const updatedRecording = await response.json();
// Update local recording data
const recordingInList = recordings.value.find(r => r.id === recordingId);
if (recordingInList) {
recordingInList.folder_id = updatedRecording.folder_id;
recordingInList.folder = updatedRecording.folder;
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
selectedRecording.value.folder_id = updatedRecording.folder_id;
selectedRecording.value.folder = updatedRecording.folder;
}
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
if (folderId) {
const folder = availableFolders.value?.find(f => f.id === folderId);
showToast(`Moved to folder "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
} else {
showToast('Removed from folder', 'fa-folder-minus', 2000, 'success');
}
return updatedRecording;
} catch (error) {
console.error('Error updating folder:', error);
setGlobalError(`Failed to update folder: ${error.message}`);
return null;
}
};
const removeRecordingFromFolder = async (recordingId) => {
return assignFolderToRecording(recordingId, null);
};
const bulkAssignFolder = async (recordingIds, folderId) => {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/recordings/bulk/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
recording_ids: recordingIds,
folder_id: folderId || null
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update folders');
}
const result = await response.json();
// Update local recording data
recordingIds.forEach(id => {
const recording = recordings.value.find(r => r.id === id);
if (recording) {
recording.folder_id = folderId || null;
recording.folder = folderId ? availableFolders.value?.find(f => f.id === folderId) : null;
}
});
// Update folder recording counts
if (availableFolders.value) {
availableFolders.value.forEach(f => {
const count = recordings.value.filter(r => r.folder_id === f.id).length;
f.recording_count = count;
});
}
if (folderId) {
const folder = availableFolders.value?.find(f => f.id === folderId);
showToast(`${result.updated_count} recording(s) moved to "${folder?.name || 'Unknown'}"`, 'fa-folder', 2000, 'success');
} else {
showToast(`${result.updated_count} recording(s) removed from folder`, 'fa-folder-minus', 2000, 'success');
}
return result;
} catch (error) {
console.error('Error bulk updating folders:', error);
setGlobalError(`Failed to update folders: ${error.message}`);
return null;
}
};
return {
// Methods
getRecordingFolder,
getFolderById,
getFolderColor,
getFolderName,
getAvailableFoldersForRecording,
assignFolderToRecording,
removeRecordingFromFolder,
bulkAssignFolder
};
}

View File

@@ -0,0 +1,33 @@
/**
* Composables module exports
*
* Each composable encapsulates related functionality:
* - recordings: Loading, selecting, filtering recordings
* - upload: File upload queue management
* - audio: Microphone/system audio recording
* - ui: Dark mode, color schemes, sidebar
* - transcription: Transcription editing (ASR editor, text editor)
* - speakers: Speaker identification and management
* - reprocess: Reprocessing transcription/summary
* - sharing: Public/internal sharing
* - modals: Modal dialog management
* - chat: AI chat functionality
* - pwa: PWA features (install prompt, notifications, badging, media session)
* - tokens: API token management
*/
export { useRecordings } from './recordings.js';
export { useUpload } from './upload.js';
export { useAudio } from './audio.js';
export { useUI } from './ui.js';
export { useModals } from './modals.js';
export { useSharing } from './sharing.js';
export { useReprocess } from './reprocess.js';
export { useTranscription } from './transcription.js';
export { useSpeakers } from './speakers.js';
export { useChat } from './chat.js';
export { usePWA } from './pwa.js';
export { useTokens } from './tokens.js';
export { useBulkSelection } from './bulk-selection.js';
export { useBulkOperations } from './bulk-operations.js';
export { useFolders } from './folders.js';

View File

@@ -0,0 +1,659 @@
/**
* Modal management composable
* Handles opening, closing, and saving modal dialogs
*/
export function useModals(state, utils) {
const {
showEditModal, showDeleteModal, showEditTagsModal,
showReprocessModal, showResetModal, showShareModal,
showSharesListModal, showTextEditorModal, showAsrEditorModal,
showEditSpeakersModal, showAddSpeakerModal, showEditTextModal,
showShareDeleteModal, showUnifiedShareModal, showColorSchemeModal,
showSystemAudioHelpModal, editingRecording, recordingToDelete, recordingToReset,
selectedRecording, recordings, selectedNewTagId, tagSearchFilter,
availableTags, currentView, totalRecordings, toasts, uploadQueue, allJobs,
// DateTime picker state
showDateTimePicker, pickerMonth, pickerYear, pickerHour, pickerMinute,
pickerAmPm, pickerSelectedDate, dateTimePickerTarget, dateTimePickerCallback
} = state;
const { showToast, setGlobalError } = utils;
const { computed } = Vue;
// =========================================
// Edit Recording Modal
// =========================================
const openEditModal = (recording) => {
editingRecording.value = { ...recording };
showEditModal.value = true;
};
const cancelEdit = () => {
showEditModal.value = false;
editingRecording.value = null;
};
const saveEdit = async () => {
if (!editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
title: editingRecording.value.title,
participants: editingRecording.value.participants,
meeting_date: editingRecording.value.meeting_date,
notes: editingRecording.value.notes
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to save changes');
// Update local data
const index = recordings.value.findIndex(r => r.id === editingRecording.value.id);
if (index !== -1) {
recordings.value[index] = { ...recordings.value[index], ...editingRecording.value };
}
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
selectedRecording.value = { ...selectedRecording.value, ...editingRecording.value };
}
showToast('Recording updated!', 'fa-check-circle');
showEditModal.value = false;
editingRecording.value = null;
} catch (error) {
setGlobalError(`Failed to save changes: ${error.message}`);
}
};
// =========================================
// Delete Recording Modal
// =========================================
const confirmDelete = (recording) => {
recordingToDelete.value = recording;
showDeleteModal.value = true;
};
const cancelDelete = () => {
showDeleteModal.value = false;
recordingToDelete.value = null;
};
const deleteRecording = async () => {
if (!recordingToDelete.value) return;
const deletedId = recordingToDelete.value.id;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${deletedId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to delete recording');
// Remove from recordings list
recordings.value = recordings.value.filter(r => r.id !== deletedId);
totalRecordings.value--;
// Remove from upload queue if present (frontend tracking)
if (uploadQueue?.value) {
uploadQueue.value = uploadQueue.value.filter(item => item.recordingId !== deletedId);
}
// Remove from backend job queue if present (backend processing tracking)
// This is critical - without this, deleted recordings remain in processing queue
if (allJobs?.value) {
allJobs.value = allJobs.value.filter(job => job.recording_id !== deletedId);
}
// Clear selected recording if it's the one being deleted
if (selectedRecording.value?.id === deletedId) {
selectedRecording.value = null;
currentView.value = 'upload';
}
showToast('Recording deleted.', 'fa-trash');
showDeleteModal.value = false;
recordingToDelete.value = null;
} catch (error) {
setGlobalError(`Failed to delete recording: ${error.message}`);
}
};
// =========================================
// Archive Recording
// =========================================
const archiveRecording = async (recording) => {
if (!recording) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${recording.id}/archive`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to archive recording');
recording.is_archived = true;
recording.audio_deleted_at = data.audio_deleted_at;
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_archived = true;
recordings.value[index].audio_deleted_at = data.audio_deleted_at;
}
showToast('Recording archived (audio deleted)', 'fa-archive');
} catch (error) {
setGlobalError(`Failed to archive recording: ${error.message}`);
}
};
// =========================================
// Edit Tags Modal
// =========================================
const openEditTagsModal = () => {
selectedNewTagId.value = '';
tagSearchFilter.value = '';
showEditTagsModal.value = true;
};
const closeEditTagsModal = () => {
showEditTagsModal.value = false;
};
const addTagToRecording = async (tagId) => {
if (!selectedRecording.value || !tagId) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_id: tagId })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to add tag');
// Find the tag object
const tag = availableTags.value.find(t => t.id === tagId);
if (tag) {
if (!selectedRecording.value.tags) {
selectedRecording.value.tags = [];
}
selectedRecording.value.tags.push(tag);
}
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1 && tag) {
if (!recordings.value[index].tags) {
recordings.value[index].tags = [];
}
recordings.value[index].tags.push(tag);
}
selectedNewTagId.value = '';
showToast('Tag added!', 'fa-tag');
} catch (error) {
setGlobalError(`Failed to add tag: ${error.message}`);
}
};
const removeTagFromRecording = async (tagId) => {
if (!selectedRecording.value || !tagId) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}/tags/${tagId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to remove tag');
// Remove from selected recording
if (selectedRecording.value.tags) {
selectedRecording.value.tags = selectedRecording.value.tags.filter(t => t.id !== tagId);
}
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1 && recordings.value[index].tags) {
recordings.value[index].tags = recordings.value[index].tags.filter(t => t.id !== tagId);
}
showToast('Tag removed!', 'fa-tag');
} catch (error) {
setGlobalError(`Failed to remove tag: ${error.message}`);
}
};
// =========================================
// Reset Modal
// =========================================
const openResetModal = (recording) => {
recordingToReset.value = recording;
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
recordingToReset.value = null;
};
const resetRecording = async () => {
if (!recordingToReset.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${recordingToReset.value.id}/reset_status`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to reset recording');
// Update recording status
const index = recordings.value.findIndex(r => r.id === recordingToReset.value.id);
if (index !== -1) {
recordings.value[index].status = 'PENDING';
recordings.value[index].transcription = '';
recordings.value[index].summary = '';
}
if (selectedRecording.value?.id === recordingToReset.value.id) {
selectedRecording.value.status = 'PENDING';
selectedRecording.value.transcription = '';
selectedRecording.value.summary = '';
}
showToast('Recording reset for reprocessing.', 'fa-redo');
showResetModal.value = false;
recordingToReset.value = null;
} catch (error) {
setGlobalError(`Failed to reset recording: ${error.message}`);
}
};
// =========================================
// System Audio Help Modal
// =========================================
const openSystemAudioHelpModal = () => {
showSystemAudioHelpModal.value = true;
};
const closeSystemAudioHelpModal = () => {
showSystemAudioHelpModal.value = false;
};
// =========================================
// Toast Management
// =========================================
const dismissToast = (id) => {
toasts.value = toasts.value.filter(t => t.id !== id);
};
// Aliases for template compatibility
const editRecording = openEditModal;
const editRecordingTags = openEditTagsModal;
// =========================================
// DateTime Picker
// =========================================
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Generate available years (10 years before and after current year)
const availableYears = computed(() => {
const currentYear = new Date().getFullYear();
const years = [];
for (let y = currentYear - 10; y <= currentYear + 10; y++) {
years.push(y);
}
return years;
});
// Generate hours for 12-hour format
const hours12 = computed(() => {
const hours = [];
for (let h = 1; h <= 12; h++) {
hours.push({ value: h, label: h.toString() });
}
return hours;
});
// Generate minutes
const minutes = computed(() => {
const mins = [];
for (let m = 0; m < 60; m++) {
mins.push(m);
}
return mins;
});
// Generate calendar days for current month view
const calendarDays = computed(() => {
const days = [];
const year = pickerYear.value;
const month = pickerMonth.value;
// First day of the month
const firstDay = new Date(year, month, 1);
const startingDay = firstDay.getDay();
// Last day of the month
const lastDay = new Date(year, month + 1, 0);
const totalDays = lastDay.getDate();
// Previous month days to fill the grid
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startingDay - 1; i >= 0; i--) {
days.push({
day: prevMonthLastDay - i,
date: new Date(year, month - 1, prevMonthLastDay - i),
inMonth: false,
isToday: false,
isSelected: false
});
}
// Current month days
const today = new Date();
for (let d = 1; d <= totalDays; d++) {
const date = new Date(year, month, d);
const isToday = date.toDateString() === today.toDateString();
const isSelected = pickerSelectedDate.value &&
date.toDateString() === pickerSelectedDate.value.toDateString();
days.push({
day: d,
date: date,
inMonth: true,
isToday: isToday,
isSelected: isSelected
});
}
// Next month days to fill the grid (6 rows * 7 days = 42 total)
const remainingDays = 42 - days.length;
for (let d = 1; d <= remainingDays; d++) {
days.push({
day: d,
date: new Date(year, month + 1, d),
inMonth: false,
isToday: false,
isSelected: false
});
}
return days;
});
const openDateTimePicker = (target, currentValue, callback) => {
dateTimePickerTarget.value = target;
dateTimePickerCallback.value = callback;
// Parse current value if exists
if (currentValue) {
const date = new Date(currentValue);
if (!isNaN(date.getTime())) {
pickerSelectedDate.value = date;
pickerMonth.value = date.getMonth();
pickerYear.value = date.getFullYear();
let hours = date.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours === 0 ? 12 : hours;
pickerHour.value = hours;
pickerMinute.value = date.getMinutes();
pickerAmPm.value = ampm;
} else {
setPickerToNow();
}
} else {
setPickerToNow();
}
showDateTimePicker.value = true;
};
const setPickerToNow = () => {
const now = new Date();
pickerSelectedDate.value = now;
pickerMonth.value = now.getMonth();
pickerYear.value = now.getFullYear();
let hours = now.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours === 0 ? 12 : hours;
pickerHour.value = hours;
pickerMinute.value = now.getMinutes();
pickerAmPm.value = ampm;
};
const closeDateTimePicker = () => {
showDateTimePicker.value = false;
dateTimePickerTarget.value = null;
dateTimePickerCallback.value = null;
};
const prevMonth = () => {
if (pickerMonth.value === 0) {
pickerMonth.value = 11;
pickerYear.value--;
} else {
pickerMonth.value--;
}
};
const nextMonth = () => {
if (pickerMonth.value === 11) {
pickerMonth.value = 0;
pickerYear.value++;
} else {
pickerMonth.value++;
}
};
const updatePickerView = () => {
// Called when month/year dropdowns change
// The computed calendarDays will automatically update
};
const selectDate = (date) => {
pickerSelectedDate.value = date;
};
const setToNow = () => {
setPickerToNow();
};
const setToToday = () => {
const today = new Date();
pickerSelectedDate.value = today;
pickerMonth.value = today.getMonth();
pickerYear.value = today.getFullYear();
// Keep the current time
};
const clearDateTime = () => {
pickerSelectedDate.value = null;
const now = new Date();
pickerMonth.value = now.getMonth();
pickerYear.value = now.getFullYear();
pickerHour.value = 12;
pickerMinute.value = 0;
pickerAmPm.value = 'PM';
};
const formatPickerPreview = () => {
if (!pickerSelectedDate.value) return '';
const date = pickerSelectedDate.value;
const monthName = monthNames[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
const hour = pickerHour.value;
const minute = pickerMinute.value.toString().padStart(2, '0');
const ampm = pickerAmPm.value;
return `${monthName} ${day}, ${year} at ${hour}:${minute} ${ampm}`;
};
const applyDateTime = () => {
if (!pickerSelectedDate.value) {
// If no date selected, just close
closeDateTimePicker();
return;
}
// Build the full datetime
const date = new Date(pickerSelectedDate.value);
let hours = pickerHour.value;
// Convert 12-hour to 24-hour
if (pickerAmPm.value === 'AM') {
hours = hours === 12 ? 0 : hours;
} else {
hours = hours === 12 ? 12 : hours + 12;
}
date.setHours(hours);
date.setMinutes(pickerMinute.value);
date.setSeconds(0);
date.setMilliseconds(0);
// Format as ISO string for storage (YYYY-MM-DDTHH:mm:ss)
const isoString = date.toISOString().slice(0, 19);
// Call the callback with the result
if (dateTimePickerCallback.value) {
dateTimePickerCallback.value(isoString, date);
}
closeDateTimePicker();
};
// Helper to open datetime picker for meeting date
const openMeetingDatePicker = () => {
if (!selectedRecording.value) return;
openDateTimePicker(
'meeting_date',
selectedRecording.value.meeting_date,
(isoString) => {
selectedRecording.value.meeting_date = isoString;
// Auto-save the change
saveInlineMeetingDate();
}
);
};
// Save meeting date inline (similar to other inline edits)
const saveInlineMeetingDate = async () => {
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 meeting date');
showToast('Meeting date updated!', 'fa-calendar-check');
} catch (error) {
showToast(`Failed to save: ${error.message}`, 'fa-exclamation-circle', 3000, 'error');
}
};
return {
// Edit modal
openEditModal,
editRecording,
cancelEdit,
saveEdit,
// Delete modal
confirmDelete,
cancelDelete,
deleteRecording,
// Archive
archiveRecording,
// Tags modal
openEditTagsModal,
editRecordingTags,
closeEditTagsModal,
addTagToRecording,
removeTagFromRecording,
// Reset modal
openResetModal,
cancelReset,
resetRecording,
// System audio help
openSystemAudioHelpModal,
closeSystemAudioHelpModal,
// Toast
dismissToast,
// DateTime picker
monthNames,
dayNames,
availableYears,
hours12,
minutes,
calendarDays,
openDateTimePicker,
closeDateTimePicker,
prevMonth,
nextMonth,
updatePickerView,
selectDate,
setToNow,
setToToday,
clearDateTime,
formatPickerPreview,
applyDateTime,
openMeetingDatePicker
};
}

View File

@@ -0,0 +1,518 @@
/**
* PWA Features Composable
* Handles install prompt, push notifications, badging, and other PWA APIs
*/
import { isPushEnabled, getPublicKey, urlBase64ToUint8Array } from '../../config/push-config.js';
export function usePWA(state, utils) {
const {
deferredInstallPrompt,
showInstallButton,
isPWAInstalled,
notificationPermission,
pushSubscription,
appBadgeCount
} = state;
const { showToast } = utils;
// --- Install Prompt ---
/**
* Handle beforeinstallprompt event
* This event is fired when the browser detects the app can be installed
*/
const handleBeforeInstallPrompt = (e) => {
console.log('[PWA] beforeinstallprompt event fired');
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Stash the event so it can be triggered later
deferredInstallPrompt.value = e;
// Show our custom install button
showInstallButton.value = true;
};
/**
* Prompt user to install the PWA
*/
const promptInstall = async () => {
if (!deferredInstallPrompt.value) {
console.log('[PWA] No deferred install prompt available');
return;
}
// Show the install prompt
deferredInstallPrompt.value.prompt();
// Wait for the user's response
const { outcome } = await deferredInstallPrompt.value.userChoice;
console.log(`[PWA] User response to install prompt: ${outcome}`);
if (outcome === 'accepted') {
showToast('Installing Speakr...', 'success');
}
// Clear the deferred prompt since it can only be used once
deferredInstallPrompt.value = null;
showInstallButton.value = false;
};
/**
* Check if app is already installed
*/
const checkIfInstalled = () => {
// Check if running in standalone mode (installed PWA)
if (window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true) {
isPWAInstalled.value = true;
showInstallButton.value = false;
console.log('[PWA] App is installed and running in standalone mode');
}
};
/**
* Handle appinstalled event
*/
const handleAppInstalled = () => {
console.log('[PWA] App was installed');
isPWAInstalled.value = true;
showInstallButton.value = false;
showToast('Speakr installed successfully!', 'success');
};
// --- Push Notifications ---
/**
* Request notification permission
*/
const requestNotificationPermission = async () => {
if (!('Notification' in window)) {
console.warn('[PWA] This browser does not support notifications');
return false;
}
try {
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
console.log(`[PWA] Notification permission: ${permission}`);
if (permission === 'granted') {
showToast('Notifications enabled', 'success');
return true;
} else if (permission === 'denied') {
showToast('Notification permission denied', 'error');
return false;
}
} catch (error) {
console.error('[PWA] Error requesting notification permission:', error);
return false;
}
};
/**
* Subscribe to push notifications
*/
const subscribeToPushNotifications = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('[PWA] Push notifications not supported');
showToast('Push notifications not supported in this browser', 'warning');
return null;
}
// Check if push is enabled on server
const enabled = await isPushEnabled();
if (!enabled) {
console.warn('[PWA] Push notifications not configured on server');
showToast('Push notifications not available. Install pywebpush on server.', 'warning');
return null;
}
// Get public key from server
const publicKey = await getPublicKey();
if (!publicKey) {
console.error('[PWA] Failed to get VAPID public key from server');
showToast('Failed to configure push notifications', 'error');
return null;
}
try {
const registration = await navigator.serviceWorker.ready;
// Check if already subscribed
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Subscribe to push notifications
console.log('[PWA] Creating new push subscription...');
const applicationServerKey = urlBase64ToUint8Array(publicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
// Send subscription to server
const success = await sendSubscriptionToServer(subscription);
if (success) {
pushSubscription.value = subscription;
showToast('Push notifications enabled', 'success');
console.log('[PWA] Push subscription successful:', subscription);
} else {
console.warn('[PWA] Failed to save subscription on server');
showToast('Failed to enable push notifications', 'error');
return null;
}
} else {
pushSubscription.value = subscription;
console.log('[PWA] Already subscribed to push notifications');
}
return subscription;
} catch (error) {
console.error('[PWA] Failed to subscribe to push notifications:', error);
if (error.name === 'NotAllowedError') {
showToast('Push notification permission denied', 'error');
} else {
showToast('Failed to enable push notifications', 'error');
}
return null;
}
};
/**
* Send subscription to server for storage
*/
const sendSubscriptionToServer = async (subscription) => {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
credentials: 'same-origin'
});
if (!response.ok) {
console.error('[PWA] Server rejected push subscription:', response.status);
return false;
}
const data = await response.json();
console.log('[PWA] Subscription saved on server:', data);
return true;
} catch (error) {
console.error('[PWA] Failed to send subscription to server:', error);
return false;
}
};
/**
* Unsubscribe from push notifications
*/
const unsubscribeFromPushNotifications = async () => {
if (!pushSubscription.value) {
console.log('[PWA] No active push subscription to unsubscribe');
return true;
}
try {
// Unsubscribe on client
await pushSubscription.value.unsubscribe();
// Remove from server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(pushSubscription.value),
credentials: 'same-origin'
});
pushSubscription.value = null;
showToast('Push notifications disabled', 'info');
console.log('[PWA] Unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PWA] Failed to unsubscribe from push notifications:', error);
showToast('Failed to disable push notifications', 'error');
return false;
}
};
/**
* Show a local notification
*/
const showNotification = async (title, options = {}) => {
if (!('Notification' in window)) {
console.warn('[PWA] Notifications not supported');
return;
}
if (Notification.permission !== 'granted') {
const granted = await requestNotificationPermission();
if (!granted) return;
}
try {
const registration = await navigator.serviceWorker.ready;
const defaultOptions = {
icon: '/static/img/icon-192x192.png',
badge: '/static/img/icon-192x192.png',
vibrate: [200, 100, 200],
tag: 'speakr-notification',
renotify: true,
...options
};
await registration.showNotification(title, defaultOptions);
} catch (error) {
console.error('[PWA] Error showing notification:', error);
}
};
// --- Badging API ---
/**
* Set app badge count
*/
const setAppBadge = async (count) => {
if (!('setAppBadge' in navigator)) {
console.log('[PWA] Badging API not supported');
return;
}
try {
if (count > 0) {
await navigator.setAppBadge(count);
appBadgeCount.value = count;
console.log(`[PWA] App badge set to ${count}`);
} else {
await navigator.clearAppBadge();
appBadgeCount.value = 0;
console.log('[PWA] App badge cleared');
}
} catch (error) {
console.error('[PWA] Error setting app badge:', error);
}
};
/**
* Clear app badge
*/
const clearAppBadge = async () => {
await setAppBadge(0);
};
/**
* Update badge with unread count
*/
const updateBadgeCount = async (audioFiles) => {
if (!audioFiles || !Array.isArray(audioFiles)) return;
// Count unread recordings (those still in inbox)
const unreadCount = audioFiles.filter(file => file.in_inbox).length;
await setAppBadge(unreadCount);
};
// --- Media Session API ---
/**
* Set up Media Session for audio playback control
* @param {Object} metadata - Track metadata { title, artist, album, artwork }
* @param {Object} handlers - Action handlers { play, pause, seekbackward, seekforward, previoustrack, nexttrack }
*/
const setupMediaSession = (metadata, handlers = {}) => {
if (!('mediaSession' in navigator)) {
console.log('[PWA] Media Session API not supported');
return false;
}
try {
// Set metadata
if (metadata) {
navigator.mediaSession.metadata = new MediaMetadata({
title: metadata.title || 'Untitled Recording',
artist: metadata.artist || 'Speakr',
album: metadata.album || 'Recordings',
artwork: metadata.artwork || [
{ src: '/static/img/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/static/img/icon-512x512.png', sizes: '512x512', type: 'image/png' }
]
});
currentMediaMetadata.value = metadata;
}
// Set action handlers
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
actions.forEach(action => {
if (handlers[action]) {
try {
navigator.mediaSession.setActionHandler(action, handlers[action]);
} catch (error) {
console.warn(`[PWA] The ${action} action is not supported`);
}
}
});
// Set position state if provided
if (handlers.setPositionState) {
try {
navigator.mediaSession.setPositionState(handlers.setPositionState);
} catch (error) {
console.warn('[PWA] setPositionState not supported:', error);
}
}
isMediaSessionActive.value = true;
console.log('[PWA] Media Session configured successfully');
return true;
} catch (error) {
console.error('[PWA] Error setting up Media Session:', error);
return false;
}
};
/**
* Update Media Session position state
* @param {Object} state - { duration, playbackRate, position }
*/
const updateMediaSessionPosition = (state) => {
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
try {
navigator.mediaSession.setPositionState({
duration: state.duration || 0,
playbackRate: state.playbackRate || 1.0,
position: state.position || 0
});
} catch (error) {
console.warn('[PWA] Error updating position state:', error);
}
};
/**
* Update Media Session playback state
* @param {string} state - 'playing' | 'paused' | 'none'
*/
const updateMediaSessionPlaybackState = (state) => {
if (!('mediaSession' in navigator) || !isMediaSessionActive.value) return;
try {
navigator.mediaSession.playbackState = state;
} catch (error) {
console.warn('[PWA] Error updating playback state:', error);
}
};
/**
* Clear Media Session
*/
const clearMediaSession = () => {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.metadata = null;
const actions = ['play', 'pause', 'seekbackward', 'seekforward', 'previoustrack', 'nexttrack', 'stop'];
actions.forEach(action => {
try {
navigator.mediaSession.setActionHandler(action, null);
} catch (e) { /* ignore */ }
});
isMediaSessionActive.value = false;
currentMediaMetadata.value = null;
console.log('[PWA] Media Session cleared');
} catch (error) {
console.error('[PWA] Error clearing Media Session:', error);
}
};
// --- Background Sync ---
/**
* Register background sync for upload retry
*/
const registerBackgroundSync = async (tag = 'sync-uploads') => {
if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) {
console.log('[PWA] Background sync not supported');
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
console.log(`[PWA] Background sync registered: ${tag}`);
return true;
} catch (error) {
console.error('[PWA] Failed to register background sync:', error);
return false;
}
};
/**
* Initialize PWA features
*/
const initPWA = () => {
// Check if already installed
checkIfInstalled();
// Listen for beforeinstallprompt event
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// Listen for appinstalled event
window.addEventListener('appinstalled', handleAppInstalled);
// Check notification permission status
if ('Notification' in window) {
notificationPermission.value = Notification.permission;
}
console.log('[PWA] PWA features initialized');
};
/**
* Cleanup PWA event listeners
*/
const cleanupPWA = () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
return {
// Install prompt
promptInstall,
checkIfInstalled,
// Notifications
requestNotificationPermission,
subscribeToPushNotifications,
unsubscribeFromPushNotifications,
showNotification,
// Badging
setAppBadge,
clearAppBadge,
updateBadgeCount,
// Media Session
setupMediaSession,
updateMediaSessionPosition,
updateMediaSessionPlaybackState,
clearMediaSession,
// Background sync
registerBackgroundSync,
// Initialization
initPWA,
cleanupPWA
};
}

View File

@@ -0,0 +1,482 @@
/**
* Recording management composable
* Handles loading, selecting, filtering, and managing recordings
*/
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useRecordings(state, utils, reprocessComposable) {
const {
recordings, selectedRecording, isLoadingRecordings, isLoadingMore,
currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage,
showSharedWithMe, showArchivedRecordings, searchQuery, searchDebounceTimer,
filterTags, filterSpeakers, filterDatePreset, filterDateRange, filterTextQuery,
filterStarred, filterInbox, filterFolder, sortBy,
availableTags, availableSpeakers, availableFolders, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
useAsrEndpoint, connectorSupportsDiarization, globalError, uploadQueue, isProcessingActive, currentView,
isMobileScreen, isSidebarCollapsed, isRecording, audioBlobURL,
speakerColorMap,
// Incognito mode
incognitoRecording
} = state;
const { setGlobalError, showToast } = utils;
// Load recordings from API
const loadRecordings = async (page = 1, append = false, searchQueryParam = '') => {
globalError.value = null;
if (!append) {
isLoadingRecordings.value = true;
} else {
isLoadingMore.value = true;
}
try {
const endpoint = '/api/recordings';
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.value.toString()
});
if (searchQueryParam.trim()) {
params.set('q', searchQueryParam.trim());
}
// Add sort parameter
if (sortBy.value) {
params.set('sort_by', sortBy.value);
}
// Add archived/shared/starred/inbox filters as query params (ANDed with other filters)
if (showArchivedRecordings.value) {
params.set('archived', 'true');
}
if (showSharedWithMe.value) {
params.set('shared', 'true');
}
if (filterStarred.value) {
params.set('starred', 'true');
}
if (filterInbox.value) {
params.set('inbox', 'true');
}
// Add folder filter
if (filterFolder && filterFolder.value) {
params.set('folder', filterFolder.value);
}
const response = await fetch(`${endpoint}?${params}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load recordings');
const recordingsList = data.recordings;
const pagination = data.pagination;
if (!Array.isArray(recordingsList)) {
console.error('Unexpected response format:', data);
throw new Error('Invalid response format from server');
}
if (pagination) {
currentPage.value = pagination.page;
totalRecordings.value = pagination.total;
totalPages.value = pagination.total_pages;
hasNextPage.value = pagination.has_next;
hasPrevPage.value = pagination.has_prev;
} else {
currentPage.value = 1;
totalRecordings.value = recordingsList.length;
totalPages.value = 1;
hasNextPage.value = false;
hasPrevPage.value = false;
}
if (append) {
recordings.value = [...recordings.value, ...recordingsList];
} else {
recordings.value = recordingsList;
const lastRecordingId = localStorage.getItem('lastSelectedRecordingId');
if (lastRecordingId && recordingsList.length > 0) {
const recordingToSelect = recordingsList.find(r => r.id == lastRecordingId);
if (recordingToSelect) {
selectRecording(recordingToSelect);
}
}
}
// NOTE: Removed auto-queueing of incomplete recordings.
// Backend processing recordings are now shown via backendProcessingRecordings
// computed property, which filters recordings by status (PENDING, PROCESSING, etc.)
// The job queue system (ProcessingJob) handles background processing.
} catch (error) {
console.error('Load Recordings Error:', error);
setGlobalError(`Failed to load recordings: ${error.message}`);
if (!append) {
recordings.value = [];
}
} finally {
isLoadingRecordings.value = false;
isLoadingMore.value = false;
}
};
const loadMoreRecordings = async () => {
if (!hasNextPage.value || isLoadingMore.value) return;
await loadRecordings(currentPage.value + 1, true, searchQuery.value);
};
const performSearch = async (query = '') => {
currentPage.value = 1;
await loadRecordings(1, false, query);
};
const debouncedSearch = (query) => {
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
searchDebounceTimer.value = setTimeout(() => {
performSearch(query);
}, 300);
};
const loadTags = async () => {
try {
const response = await fetch('/api/tags');
if (response.ok) {
availableTags.value = await response.json();
} else {
availableTags.value = [];
}
} catch (error) {
console.warn('Error loading tags:', error);
availableTags.value = [];
}
};
const loadFolders = async () => {
try {
const response = await fetch('/api/folders');
if (response.ok) {
availableFolders.value = await response.json();
} else {
availableFolders.value = [];
}
} catch (error) {
console.warn('Error loading folders:', error);
availableFolders.value = [];
}
};
const loadSpeakers = async () => {
try {
const response = await fetch('/speakers');
if (response.ok) {
availableSpeakers.value = await response.json();
} else {
availableSpeakers.value = [];
}
} catch (error) {
console.warn('Error loading speakers:', error);
availableSpeakers.value = [];
}
};
const selectRecording = async (recording) => {
if (hasUnsavedRecording()) {
if (!confirm('You have an unsaved recording. Are you sure you want to leave?')) {
return;
}
}
// Check if switching away from incognito recording to a regular recording
if (incognitoRecording && incognitoRecording.value &&
selectedRecording.value?.id === 'incognito' &&
recording?.id !== 'incognito') {
if (!confirm('Switching to another recording will discard your incognito recording. Continue?')) {
return;
}
// Clear incognito recording immediately - this is the "incognito" promise
IncognitoStorage.clearIncognitoRecording();
incognitoRecording.value = null;
}
// Also clear any orphaned incognito data when selecting a non-incognito recording
// This handles edge cases like page refresh where the above check doesn't trigger
if (recording?.id !== 'incognito' && IncognitoStorage.hasIncognitoRecording()) {
console.log('[Incognito] Clearing orphaned incognito data');
IncognitoStorage.clearIncognitoRecording();
if (incognitoRecording) {
incognitoRecording.value = null;
}
}
// Reset modal audio state when switching recordings
if (utils.resetModalAudioState) {
utils.resetModalAudioState();
}
// Clear speaker color map when switching recordings - new colors will be assigned on first render
if (speakerColorMap) {
speakerColorMap.value = {};
}
selectedRecording.value = recording;
if (recording && recording.id) {
localStorage.setItem('lastSelectedRecordingId', recording.id);
try {
const response = await fetch(`/api/recordings/${recording.id}`);
if (response.ok) {
const fullRecording = await response.json();
selectedRecording.value = fullRecording;
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index] = fullRecording;
}
// Auto-start polling if recording is still processing or summarizing
if (['PROCESSING', 'SUMMARIZING'].includes(fullRecording.status)) {
console.log(`[AUTO-POLL] Recording ${fullRecording.id} is in ${fullRecording.status} state, starting auto-polling`);
if (reprocessComposable && reprocessComposable.startReprocessingPoll) {
reprocessComposable.startReprocessingPoll(fullRecording.id);
} else {
console.warn('[AUTO-POLL] reprocessComposable.startReprocessingPoll not available');
}
}
}
} catch (error) {
console.error('Error loading full recording:', error);
}
}
if (isMobileScreen.value) {
isSidebarCollapsed.value = true;
}
currentView.value = 'detail';
if (isRecording.value) {
// Don't interrupt recording
}
if (audioBlobURL.value) {
// Don't discard recorded audio
}
};
const hasUnsavedRecording = () => {
return isRecording.value || audioBlobURL.value;
};
const toggleInbox = async (recording) => {
if (!recording || !recording.id) return;
try {
const response = await fetch(`/recording/${recording.id}/toggle_inbox`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to toggle inbox status');
// Update the recording in the UI
recording.is_inbox = data.is_inbox;
// Update in the recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_inbox = data.is_inbox;
}
showToast(`Recording ${data.is_inbox ? 'moved to inbox' : 'marked as read'}`);
} catch (error) {
console.error('Toggle Inbox Error:', error);
setGlobalError(`Failed to toggle inbox status: ${error.message}`);
}
};
const toggleHighlight = async (recording) => {
if (!recording || !recording.id) return;
try {
const response = await fetch(`/recording/${recording.id}/toggle_highlight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to toggle highlighted status');
// Update the recording in the UI
recording.is_highlighted = data.is_highlighted;
// Update in the recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
recordings.value[index].is_highlighted = data.is_highlighted;
}
showToast(`Recording ${data.is_highlighted ? 'highlighted' : 'unhighlighted'}`);
} catch (error) {
console.error('Toggle Highlight Error:', error);
setGlobalError(`Failed to toggle highlighted status: ${error.message}`);
}
};
const getRecordingTags = (recording) => {
if (!recording || !recording.tags) return [];
return recording.tags || [];
};
const getAvailableTagsForRecording = (recording) => {
if (!recording || !availableTags.value) return [];
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
};
const filterByTag = (tag) => {
filterTags.value = [tag.id];
applyAdvancedFilters();
};
const buildSearchQuery = () => {
let query = [];
if (filterTextQuery.value.trim()) {
query.push(filterTextQuery.value.trim());
}
if (filterTags.value.length > 0) {
const tagNames = filterTags.value.map(tagId => {
const tag = availableTags.value.find(t => t.id === tagId);
return tag ? `tag:${tag.name.replace(/\s+/g, '_')}` : '';
}).filter(Boolean);
query.push(...tagNames);
}
if (filterSpeakers.value.length > 0) {
const speakerNames = filterSpeakers.value.map(name =>
`speaker:${name.replace(/\s+/g, '_')}`
);
query.push(...speakerNames);
}
if (filterDatePreset.value) {
query.push(`date:${filterDatePreset.value}`);
} else if (filterDateRange.value.start || filterDateRange.value.end) {
if (filterDateRange.value.start) {
query.push(`date_from:${filterDateRange.value.start}`);
}
if (filterDateRange.value.end) {
query.push(`date_to:${filterDateRange.value.end}`);
}
}
return query.join(' ');
};
const applyAdvancedFilters = () => {
searchQuery.value = buildSearchQuery();
};
const clearAllFilters = () => {
filterTags.value = [];
filterSpeakers.value = [];
filterDateRange.value = { start: '', end: '' };
filterDatePreset.value = '';
filterTextQuery.value = '';
filterStarred.value = false;
filterInbox.value = false;
// Note: filterFolder is NOT cleared here - it's a navigation element, not a filter
searchQuery.value = '';
};
const clearTagFilter = () => {
searchQuery.value = '';
clearAllFilters();
};
const addTagToSelection = (tagId) => {
if (!selectedTagIds.value.includes(tagId)) {
selectedTagIds.value.push(tagId);
applyTagDefaults();
}
};
const removeTagFromSelection = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId);
if (index > -1) {
selectedTagIds.value.splice(index, 1);
applyTagDefaults();
}
};
const applyTagDefaults = () => {
const selectedTags = selectedTagIds.value.map(tagId =>
availableTags.value.find(tag => tag.id == tagId)
).filter(Boolean);
const firstTag = selectedTags[0];
if (firstTag && connectorSupportsDiarization.value) {
if (firstTag.default_language) {
uploadLanguage.value = firstTag.default_language;
}
if (firstTag.default_min_speakers) {
uploadMinSpeakers.value = firstTag.default_min_speakers;
}
if (firstTag.default_max_speakers) {
uploadMaxSpeakers.value = firstTag.default_max_speakers;
}
}
if (firstTag) {
if (firstTag.default_hotwords) {
uploadHotwords.value = firstTag.default_hotwords;
}
if (firstTag.default_initial_prompt) {
uploadInitialPrompt.value = firstTag.default_initial_prompt;
}
}
};
const pollInboxRecordings = async () => {
try {
const response = await fetch('/api/recordings/inbox-count');
if (response.ok) {
const data = await response.json();
// Update inbox count in UI if needed
}
} catch (error) {
// Silent fail for polling
}
};
return {
loadRecordings,
loadMoreRecordings,
performSearch,
debouncedSearch,
loadTags,
loadFolders,
loadSpeakers,
selectRecording,
hasUnsavedRecording,
toggleInbox,
toggleHighlight,
getRecordingTags,
getAvailableTagsForRecording,
filterByTag,
buildSearchQuery,
applyAdvancedFilters,
clearAllFilters,
clearTagFilter,
addTagToSelection,
removeTagFromSelection,
applyTagDefaults,
pollInboxRecordings
};
}

View File

@@ -0,0 +1,450 @@
/**
* Reprocessing composable
* Handles reprocessing transcription and summary
*/
import * as IncognitoStorage from '../db/incognito-storage.js';
export function useReprocess(state, utils) {
const { nextTick } = Vue;
const {
showReprocessModal, showResetModal, reprocessType,
reprocessRecording, recordingToReset, selectedRecording,
recordings, asrReprocessOptions, summaryReprocessPromptSource,
summaryReprocessSelectedTagId, summaryReprocessCustomPrompt,
availableTags, processingProgress, processingMessage,
currentlyProcessingFile, uploadQueue
} = state;
const { showToast, setGlobalError, onChatComplete } = utils;
// Store for active polling intervals
const reprocessingPolls = new Map();
// =========================================
// Reprocess Modal
// =========================================
const openReprocessModal = (type, recording = null) => {
reprocessType.value = type;
reprocessRecording.value = recording || selectedRecording.value;
showReprocessModal.value = true;
// Reset options
if (type === 'transcription') {
asrReprocessOptions.language = '';
asrReprocessOptions.min_speakers = '';
asrReprocessOptions.max_speakers = '';
} else {
summaryReprocessPromptSource.value = 'default';
summaryReprocessSelectedTagId.value = '';
summaryReprocessCustomPrompt.value = '';
}
};
const closeReprocessModal = () => {
showReprocessModal.value = false;
reprocessRecording.value = null;
reprocessType.value = null;
};
const confirmReprocess = openReprocessModal;
const cancelReprocess = closeReprocessModal;
// =========================================
// Reset Status
// =========================================
const confirmReset = (recording) => {
recordingToReset.value = recording;
showResetModal.value = true;
};
const cancelReset = () => {
showResetModal.value = false;
recordingToReset.value = null;
};
const executeReset = async () => {
if (!recordingToReset.value) return;
const recordingId = recordingToReset.value.id;
// Close the modal first
cancelReset();
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${recordingId}/reset_status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to reset recording status');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'FAILED';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'FAILED';
}
showToast('Recording status reset to FAILED', 'fa-undo');
} catch (error) {
setGlobalError(`Failed to reset status: ${error.message}`);
}
};
const executeReprocess = async () => {
if (!reprocessRecording.value || !reprocessType.value) return;
const recordingId = reprocessRecording.value.id;
const type = reprocessType.value;
closeReprocessModal();
if (type === 'transcription') {
await reprocessTranscription(
recordingId,
asrReprocessOptions.language,
asrReprocessOptions.min_speakers,
asrReprocessOptions.max_speakers
);
} else {
await reprocessSummary(
recordingId,
summaryReprocessPromptSource.value,
summaryReprocessSelectedTagId.value,
summaryReprocessCustomPrompt.value
);
}
};
// =========================================
// Transcription Reprocessing
// =========================================
const reprocessTranscription = async (recordingId, language, minSpeakers, maxSpeakers) => {
if (!recordingId) {
setGlobalError('No recording ID provided for reprocessing.');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const requestBody = {
language: language || '' // Always send language - empty string means auto-detect
};
if (minSpeakers && minSpeakers !== '') requestBody.min_speakers = parseInt(minSpeakers);
if (maxSpeakers && maxSpeakers !== '') requestBody.max_speakers = parseInt(maxSpeakers);
const response = await fetch(`/recording/${recordingId}/reprocess_transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start transcription reprocessing');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'PROCESSING';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'PROCESSING';
}
showToast('Transcription reprocessing started', 'fa-sync-alt');
// Start polling for progress
startReprocessingPoll(recordingId);
} catch (error) {
setGlobalError(`Failed to start transcription reprocessing: ${error.message}`);
}
};
// =========================================
// Summary Reprocessing
// =========================================
const reprocessSummary = async (recordingId, promptSource, selectedTagId, customPrompt) => {
if (!recordingId) {
setGlobalError('No recording ID provided for reprocessing.');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const requestBody = { reprocess_summary: true };
if (promptSource === 'tag' && selectedTagId) {
const selectedTag = availableTags.value.find(t => t.id == selectedTagId);
if (selectedTag && selectedTag.custom_prompt) {
requestBody.custom_prompt = selectedTag.custom_prompt;
}
} else if (promptSource === 'custom' && customPrompt) {
requestBody.custom_prompt = customPrompt;
}
const response = await fetch(`/recording/${recordingId}/reprocess_summary`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start summary reprocessing');
// Update recording status in list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
recordings.value[index].status = 'SUMMARIZING';
}
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value.status = 'SUMMARIZING';
}
showToast('Summary reprocessing started', 'fa-sync-alt');
// Start polling for progress
startReprocessingPoll(recordingId);
} catch (error) {
setGlobalError(`Failed to start summary reprocessing: ${error.message}`);
}
};
// =========================================
// Generate Summary
// =========================================
const generateSummary = async () => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
// Check if this is an incognito recording
if (selectedRecording.value.incognito === true) {
// Use incognito summary endpoint - generate synchronously
const response = await fetch('/api/recordings/incognito/summary', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
transcription: selectedRecording.value.transcription
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to generate summary');
// Update the incognito recording with the new summary
selectedRecording.value.summary = data.summary;
selectedRecording.value.summary_html = data.summary_html;
// Update sessionStorage
IncognitoStorage.updateIncognitoRecording({
summary: data.summary,
summary_html: data.summary_html
});
showToast('Summary generated', 'fa-file-alt');
return;
}
// Regular recording - use existing flow
const response = await fetch(`/recording/${selectedRecording.value.id}/generate_summary`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to start summary generation');
selectedRecording.value.status = 'SUMMARIZING';
const recordingInList = recordings.value.find(r => r.id === selectedRecording.value.id);
if (recordingInList) {
recordingInList.status = 'SUMMARIZING';
}
showToast('Summary generation started', 'fa-file-alt');
// Start polling for progress
startReprocessingPoll(selectedRecording.value.id);
} catch (error) {
setGlobalError(`Failed to generate summary: ${error.message}`);
}
};
// =========================================
// Progress Polling
// =========================================
const startReprocessingPoll = (recordingId) => {
// Stop existing poll if any
stopReprocessingPoll(recordingId);
// Track if we've already fetched full data for SUMMARIZING status
let hasFetchedForSummarizing = false;
const pollInterval = setInterval(async () => {
try {
// Use lightweight status-only endpoint
const response = await fetch(`/recording/${recordingId}/status`);
if (!response.ok) throw new Error('Status check failed');
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?.id === recordingId) {
selectedRecording.value = {
...selectedRecording.value,
status: statusData.status
};
}
// Check if summarization has started (fetch transcript) or processing is complete
if (statusData.status === 'SUMMARIZING' || statusData.status === 'COMPLETED') {
// Only fetch once when status first becomes SUMMARIZING
const shouldFetch = (statusData.status === 'SUMMARIZING' && !hasFetchedForSummarizing) ||
statusData.status === 'COMPLETED';
if (shouldFetch) {
// Mark that we've fetched for SUMMARIZING
if (statusData.status === 'SUMMARIZING') {
hasFetchedForSummarizing = true;
}
// Only stop polling if COMPLETED, keep polling during SUMMARIZING
if (statusData.status === 'COMPLETED') {
stopReprocessingPoll(recordingId);
}
// Fetch the full recording with updated data
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?.id === recordingId) {
selectedRecording.value = fullData;
await nextTick();
}
}
if (statusData.status === 'COMPLETED') {
showToast('Processing completed!', 'fa-check-circle');
// Refresh token budget after LLM operations complete
if (onChatComplete) onChatComplete();
}
}
} else if (statusData.status === 'FAILED') {
stopReprocessingPoll(recordingId);
// Fetch full recording data to get error details for display
try {
const failedResponse = await fetch(`/api/recordings/${recordingId}`);
if (failedResponse.ok) {
const failedData = await failedResponse.json();
// Update in recordings list
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
if (currentIndex !== -1) {
recordings.value[currentIndex] = failedData;
}
// Update selectedRecording to show error in transcription panel
if (selectedRecording.value?.id === recordingId) {
selectedRecording.value = failedData;
await nextTick();
}
}
} catch (err) {
console.error('Failed to fetch error details:', err);
}
showToast('Processing failed', 'fa-exclamation-circle');
}
} catch (error) {
console.error('Polling error:', error);
stopReprocessingPoll(recordingId);
}
}, 3000);
reprocessingPolls.set(recordingId, pollInterval);
};
const stopReprocessingPoll = (recordingId) => {
if (reprocessingPolls.has(recordingId)) {
clearInterval(reprocessingPolls.get(recordingId));
reprocessingPolls.delete(recordingId);
}
};
return {
// Reprocess modal
openReprocessModal,
closeReprocessModal,
confirmReprocess,
cancelReprocess,
executeReprocess,
// Reset status
confirmReset,
cancelReset,
executeReset,
// Transcription
reprocessTranscription,
// Summary
reprocessSummary,
generateSummary,
// Polling
startReprocessingPoll,
stopReprocessingPoll
};
}

View File

@@ -0,0 +1,659 @@
/**
* Sharing composable
* Handles public and internal sharing of recordings
*/
export function useSharing(state, utils) {
const {
showShareModal, showSharesListModal, showShareDeleteModal,
showUnifiedShareModal, recordingToShare, shareOptions,
generatedShareLink, existingShareDetected, recordingPublicShares, isLoadingPublicShares,
userShares, isLoadingShares, copiedShareId, shareToDelete, selectedRecording, recordings,
internalShareUserSearch, internalShareSearchResults,
internalShareRecording, internalSharePermissions, internalShareMaxPermissions,
recordingInternalShares, isLoadingInternalShares,
isSearchingUsers, allUsers, isLoadingAllUsers,
enableInternalSharing, showUsernamesInUI
} = state;
const { showToast, setGlobalError } = utils;
let userSearchTimeout = null;
// Helper function to format share dates
const formatShareDate = (dateString) => {
if (!dateString) return 'Unknown date';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// If today
if (diffDays === 0) {
return 'Today at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// If yesterday
else if (diffDays === 1) {
return 'Yesterday at ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// If within last week
else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', hour12: true });
}
// Otherwise show full date
else {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
}
} catch (e) {
console.error('Error formatting date:', e);
return dateString;
}
};
// Helper function to get color class for username (like speaker colors)
const getUserColorClass = (username) => {
if (!username) return 'speaker-color-1';
// Simple hash function to generate consistent color from username
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = ((hash << 5) - hash) + username.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
// Map to color classes 1-16
const colorNum = (Math.abs(hash) % 16) + 1;
return `speaker-color-${colorNum}`;
};
// =========================================
// Public Sharing
// =========================================
const openShareModal = async (recording) => {
recordingToShare.value = recording;
shareOptions.share_summary = true;
shareOptions.share_notes = true;
generatedShareLink.value = '';
existingShareDetected.value = false;
recordingPublicShares.value = [];
showShareModal.value = true;
// Load all public shares for this recording
isLoadingPublicShares.value = true;
try {
const response = await fetch(`/api/shares`);
if (response.ok) {
const allShares = await response.json();
// Filter to only shares for this recording and add share_url
recordingPublicShares.value = allShares
.filter(share => share.recording_id === recording.id)
.map(share => ({
...share,
share_url: `${window.location.origin}/share/${share.public_id}`
}));
}
} catch (error) {
console.error('Error loading public shares:', error);
recordingPublicShares.value = [];
} finally {
isLoadingPublicShares.value = false;
}
};
const closeShareModal = () => {
showShareModal.value = false;
recordingToShare.value = null;
existingShareDetected.value = false;
recordingPublicShares.value = [];
};
const createShare = async (forceNew = false) => {
const recording = recordingToShare.value || internalShareRecording.value;
if (!recording) return;
try {
const response = await fetch(`/api/recording/${recording.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...shareOptions,
force_new: forceNew
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to create share link');
generatedShareLink.value = data.share_url;
existingShareDetected.value = data.existing && !forceNew;
// Add to the shares list (works for both share modal and unified modal)
if (!data.existing) {
recordingPublicShares.value.push({
...data.share,
share_url: `${window.location.origin}/share/${data.share.public_id}`
});
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} else if (data.existing && !recordingPublicShares.value.find(s => s.id === data.share.id)) {
// If existing but not in list, add it
recordingPublicShares.value.push({
...data.share,
share_url: `${window.location.origin}/share/${data.share.public_id}`
});
}
if (data.existing && !forceNew) {
showToast('Using existing share link', 'fa-link');
} else {
showToast('Share link created successfully!', 'fa-check-circle');
}
} catch (error) {
setGlobalError(`Failed to create share link: ${error.message}`);
}
};
const confirmDeletePublicShare = (share) => {
shareToDelete.value = share;
showShareDeleteModal.value = true;
};
const deletePublicShare = async () => {
if (!shareToDelete.value) return;
const shareId = shareToDelete.value.id;
try {
const response = await fetch(`/api/share/${shareId}`, { method: 'DELETE' });
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to delete share');
// Remove from the shares list (both modals use different arrays)
recordingPublicShares.value = recordingPublicShares.value.filter(s => s.id !== shareId);
userShares.value = userShares.value.filter(s => s.id !== shareId);
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
showToast('Share link deleted successfully.', 'fa-check-circle');
showShareDeleteModal.value = false;
shareToDelete.value = null;
} catch (error) {
setGlobalError(`Failed to delete share: ${error.message}`);
}
};
const copyPublicShareLink = (shareUrl) => {
navigator.clipboard.writeText(shareUrl).then(() => {
showToast('Share link copied to clipboard!', 'fa-check-circle');
}).catch(() => {
setGlobalError('Failed to copy link to clipboard');
});
};
const copyPublicShareLinkWithFeedback = (shareUrl, shareId) => {
navigator.clipboard.writeText(shareUrl).then(() => {
copiedShareId.value = shareId;
showToast('Share link copied to clipboard!', 'fa-check-circle');
// Reset after delay
setTimeout(() => {
copiedShareId.value = null;
}, 1500);
}).catch(() => {
setGlobalError('Failed to copy link to clipboard');
});
};
const refreshRecordingShareCounts = async () => {
// Refresh the current recording if one is selected
const recording = recordingToShare.value || internalShareRecording.value || selectedRecording.value;
if (!recording) return;
try {
const response = await fetch(`/api/recordings/${recording.id}`);
if (response.ok) {
const updatedRecording = await response.json();
// Update in recordings list
const index = recordings.value.findIndex(r => r.id === recording.id);
if (index !== -1) {
// Preserve reactivity by updating specific fields
recordings.value[index].public_share_count = updatedRecording.public_share_count || 0;
recordings.value[index].shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update selected recording if it's the same one
if (selectedRecording.value && selectedRecording.value.id === recording.id) {
selectedRecording.value.public_share_count = updatedRecording.public_share_count || 0;
selectedRecording.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update internal share recording if it's the same one
if (internalShareRecording.value && internalShareRecording.value.id === recording.id) {
internalShareRecording.value.public_share_count = updatedRecording.public_share_count || 0;
internalShareRecording.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
// Update recording to share if it's the same one
if (recordingToShare.value && recordingToShare.value.id === recording.id) {
recordingToShare.value.public_share_count = updatedRecording.public_share_count || 0;
recordingToShare.value.shared_with_count = updatedRecording.shared_with_count || 0;
}
}
} catch (error) {
console.error('Failed to refresh recording share counts:', error);
}
};
const copyShareLink = () => {
if (!generatedShareLink.value) return;
navigator.clipboard.writeText(generatedShareLink.value).then(() => {
showToast('Share link copied to clipboard!');
});
};
const copyIndividualShareLink = (shareId) => {
const input = document.getElementById(`share-link-${shareId}`);
if (!input) return;
const button = input.nextElementSibling;
if (!button) return;
navigator.clipboard.writeText(input.value).then(() => {
copiedShareId.value = shareId;
showToast('Share link copied to clipboard!', 'fa-check');
// Apply success state
button.style.transition = 'background-color 0.2s ease';
button.style.backgroundColor = 'var(--bg-success, #10b981)';
// Revert after delay
setTimeout(() => {
button.style.backgroundColor = '';
copiedShareId.value = null;
setTimeout(() => {
button.style.transition = '';
}, 200);
}, 1500);
}).catch(err => {
console.error('Failed to copy share link:', err);
});
};
// =========================================
// Shares List
// =========================================
const openSharesList = async () => {
isLoadingShares.value = true;
showSharesListModal.value = true;
try {
const response = await fetch('/api/shares');
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to load shared items');
userShares.value = data;
} catch (error) {
setGlobalError(`Failed to load shared items: ${error.message}`);
} finally {
isLoadingShares.value = false;
}
};
const closeSharesList = () => {
showSharesListModal.value = false;
userShares.value = [];
};
const updateShare = async (share) => {
try {
const response = await fetch(`/api/share/${share.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
share_summary: share.share_summary,
share_notes: share.share_notes
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update share');
showToast('Share permissions updated.', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to update share: ${error.message}`);
}
};
const confirmDeleteShare = (share) => {
shareToDelete.value = share;
showShareDeleteModal.value = true;
};
const cancelDeleteShare = () => {
shareToDelete.value = null;
showShareDeleteModal.value = false;
};
// =========================================
// Internal Sharing
// =========================================
const loadAllUsers = async () => {
if (!showUsernamesInUI.value) return;
isLoadingAllUsers.value = true;
try {
const response = await fetch('/api/users/search?q=');
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to load users');
}
const data = await response.json();
allUsers.value = data;
} catch (error) {
setGlobalError(`Failed to load users: ${error.message}`);
allUsers.value = [];
} finally {
isLoadingAllUsers.value = false;
}
};
const searchInternalShareUsers = async () => {
const query = internalShareUserSearch.value.trim();
// If SHOW_USERNAMES_IN_UI is enabled, filter allUsers locally
if (showUsernamesInUI.value) {
// Get list of user IDs that already have access
const sharedUserIds = new Set(recordingInternalShares.value.map(share => share.user_id));
// Filter out already-shared users
const availableUsers = allUsers.value.filter(user => !sharedUserIds.has(user.id));
if (query.length === 0) {
internalShareSearchResults.value = availableUsers;
} else {
internalShareSearchResults.value = availableUsers.filter(user =>
user.username.toLowerCase().includes(query.toLowerCase()) ||
(user.email && user.email.toLowerCase().includes(query.toLowerCase()))
);
}
return;
}
// Otherwise, use server-side search
if (query.length < 2) {
internalShareSearchResults.value = [];
return;
}
clearTimeout(userSearchTimeout);
userSearchTimeout = setTimeout(async () => {
isSearchingUsers.value = true;
try {
const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to search users');
}
const data = await response.json();
internalShareSearchResults.value = data;
} catch (error) {
setGlobalError(`Failed to search users: ${error.message}`);
internalShareSearchResults.value = [];
} finally {
isSearchingUsers.value = false;
}
}, 300);
};
const openUnifiedShareModal = async (recording) => {
internalShareRecording.value = recording;
internalShareUserSearch.value = '';
internalShareSearchResults.value = [];
internalSharePermissions.value = { can_edit: false, can_reshare: false };
recordingPublicShares.value = [];
shareOptions.share_summary = true;
shareOptions.share_notes = true;
// PERMISSION CEILING: Calculate maximum permissions current user can grant
// If viewing a shared recording (not owner), constrain to their permissions
if (recording.is_shared && recording.share_info) {
internalShareMaxPermissions.value = {
can_edit: recording.share_info.can_edit || false,
can_reshare: recording.share_info.can_reshare || false
};
} else {
// Owner has unlimited permissions
internalShareMaxPermissions.value = {
can_edit: true,
can_reshare: true
};
}
showUnifiedShareModal.value = true;
// Load all public shares for this recording
isLoadingPublicShares.value = true;
try {
const response = await fetch(`/api/shares`);
if (response.ok) {
const allShares = await response.json();
// Filter to only shares for this recording and add share_url
recordingPublicShares.value = allShares
.filter(share => share.recording_id === recording.id)
.map(share => ({
...share,
share_url: `${window.location.origin}/share/${share.public_id}`
}));
}
} catch (error) {
console.error('Error loading public shares:', error);
recordingPublicShares.value = [];
} finally {
isLoadingPublicShares.value = false;
}
// Load existing internal shares
isLoadingInternalShares.value = true;
try {
const response = await fetch(`/api/recordings/${recording.id}/shares-internal`);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to load shares');
}
const data = await response.json();
recordingInternalShares.value = data.shares || [];
} catch (error) {
setGlobalError(`Failed to load shares: ${error.message}`);
recordingInternalShares.value = [];
} finally {
isLoadingInternalShares.value = false;
}
// If SHOW_USERNAMES_IN_UI is enabled, load all users
if (showUsernamesInUI.value) {
await loadAllUsers();
internalShareSearchResults.value = allUsers.value;
}
};
const closeUnifiedShareModal = () => {
showUnifiedShareModal.value = false;
internalShareRecording.value = null;
internalShareUserSearch.value = '';
internalShareSearchResults.value = [];
recordingInternalShares.value = [];
recordingPublicShares.value = [];
allUsers.value = [];
};
// Legacy function names for backward compatibility
const openInternalShareModal = openUnifiedShareModal;
const openManageInternalSharesModal = openUnifiedShareModal;
const closeInternalShareModal = closeUnifiedShareModal;
const closeManageInternalSharesModal = closeUnifiedShareModal;
const reloadInternalShares = async () => {
if (!internalShareRecording.value) return;
isLoadingInternalShares.value = true;
try {
const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/shares-internal`);
if (!response.ok) {
throw new Error('Failed to load shares');
}
const data = await response.json();
recordingInternalShares.value = data.shares || [];
} catch (error) {
setGlobalError(`Failed to reload shares: ${error.message}`);
} finally {
isLoadingInternalShares.value = false;
}
};
const shareWithUsername = async () => {
if (!internalShareRecording.value) return;
const username = internalShareUserSearch.value.trim();
if (!username) {
setGlobalError('Please enter a username');
return;
}
isSearchingUsers.value = true;
try {
// Search for the exact username
const searchResponse = await fetch(`/api/users/search?q=${encodeURIComponent(username)}`);
if (!searchResponse.ok) {
if (searchResponse.status === 403) {
throw new Error('Internal sharing is not enabled');
}
throw new Error('Failed to find user');
}
const users = await searchResponse.json();
if (users.length === 0) {
setGlobalError(`User "${username}" not found`);
return;
}
// Use the first matching user (should be exact match from backend)
const user = users[0];
await createInternalShare(user.id, user.username);
// Clear input on success
internalShareUserSearch.value = '';
} catch (error) {
setGlobalError(error.message || 'Failed to share with user');
} finally {
isSearchingUsers.value = false;
}
};
const createInternalShare = async (userId, username) => {
if (!internalShareRecording.value) return;
try {
const response = await fetch(`/api/recordings/${internalShareRecording.value.id}/share-internal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
can_edit: internalSharePermissions.value.can_edit,
can_reshare: internalSharePermissions.value.can_reshare
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to share recording');
}
const displayName = showUsernamesInUI.value ? username : `User #${userId}`;
showToast(`Recording shared with ${displayName}`, 'fa-share-alt');
// Reset permissions for next share
internalSharePermissions.value = { can_edit: false, can_reshare: false };
// Reload shares to show the new share in the list
await reloadInternalShares();
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} catch (error) {
setGlobalError(`Failed to share recording: ${error.message}`);
}
};
const revokeInternalShare = async (shareId, username) => {
if (!internalShareRecording.value) return;
try {
const response = await fetch(`/api/internal-shares/${shareId}`, {
method: 'DELETE'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to revoke share');
}
recordingInternalShares.value = recordingInternalShares.value.filter(s => s.id !== shareId);
const displayName = showUsernamesInUI.value ? username : 'User';
showToast(`Access revoked for ${displayName}`, 'fa-user-times');
// Update the recording's share count in the UI
await refreshRecordingShareCounts();
} catch (error) {
setGlobalError(`Failed to revoke share: ${error.message}`);
}
};
return {
// Utilities
formatShareDate,
getUserColorClass,
// Public sharing
openShareModal,
closeShareModal,
createShare,
copyShareLink,
copyPublicShareLink,
copyPublicShareLinkWithFeedback,
copyIndividualShareLink,
confirmDeletePublicShare,
deletePublicShare,
refreshRecordingShareCounts,
// Shares list
openSharesList,
closeSharesList,
updateShare,
confirmDeleteShare,
cancelDeleteShare,
deleteShare: deletePublicShare, // Alias for template compatibility
copiedShareId,
// Internal sharing
loadAllUsers,
searchInternalShareUsers,
openUnifiedShareModal,
closeUnifiedShareModal,
openInternalShareModal,
closeInternalShareModal,
openManageInternalSharesModal,
closeManageInternalSharesModal,
reloadInternalShares,
shareWithUsername,
createInternalShare,
revokeInternalShare
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
/**
* Tags Management Composable
* Handles tag operations for recordings
*/
const { computed, ref } = Vue;
export function useTags({
recordings,
availableTags,
selectedRecording,
showEditTagsModal,
editingRecording,
tagSearchFilter,
showToast,
setGlobalError
}) {
// State (using passed refs from parent)
// --- Tag Drag-and-Drop State for Edit Modal ---
const modalDraggedTagIndex = ref(null);
const modalDragOverTagIndex = ref(null);
// Computed
const getRecordingTags = (recording) => {
if (!recording || !recording.tags) return [];
return recording.tags;
};
const getAvailableTagsForRecording = (recording) => {
if (!recording || !availableTags.value) return [];
const recordingTagIds = getRecordingTags(recording).map(tag => tag.id);
return availableTags.value.filter(tag => !recordingTagIds.includes(tag.id));
};
const filteredAvailableTagsForModal = computed(() => {
if (!editingRecording.value) return [];
const availableTagsForRec = getAvailableTagsForRecording(editingRecording.value);
if (!tagSearchFilter.value) return availableTagsForRec;
const filter = tagSearchFilter.value.toLowerCase();
return availableTagsForRec.filter(tag =>
tag.name.toLowerCase().includes(filter)
);
});
// Methods
const editRecordingTags = (recording) => {
editingRecording.value = recording;
tagSearchFilter.value = '';
showEditTagsModal.value = true;
};
const closeEditTagsModal = () => {
showEditTagsModal.value = false;
editingRecording.value = null;
tagSearchFilter.value = '';
};
const addTagToRecording = async (tagId) => {
if (!tagId || !editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_id: tagId })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to add tag');
}
// Update local recording data
const tagToAdd = availableTags.value.find(tag => tag.id == tagId);
if (tagToAdd) {
// Check if tag already exists to prevent duplicates
const tagExists = editingRecording.value.tags?.some(t => t.id === tagToAdd.id);
if (!tagExists) {
if (!editingRecording.value.tags) {
editingRecording.value.tags = [];
}
editingRecording.value.tags.push(tagToAdd);
}
// Also update in recordings list (only if different object)
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value) {
const tagExistsInList = recordingInList.tags?.some(t => t.id === tagToAdd.id);
if (!tagExistsInList) {
if (!recordingInList.tags) {
recordingInList.tags = [];
}
recordingInList.tags.push(tagToAdd);
}
}
// Update selectedRecording if it matches (only if different object)
if (selectedRecording.value &&
selectedRecording.value.id === editingRecording.value.id &&
selectedRecording.value !== editingRecording.value) {
const tagExistsInSelected = selectedRecording.value.tags?.some(t => t.id === tagToAdd.id);
if (!tagExistsInSelected) {
if (!selectedRecording.value.tags) {
selectedRecording.value.tags = [];
}
selectedRecording.value.tags.push(tagToAdd);
}
}
}
showToast('Tag added successfully', 'fa-check-circle', 2000, 'success');
} catch (error) {
console.error('Error adding tag to recording:', error);
setGlobalError(`Failed to add tag: ${error.message}`);
}
};
const removeTagFromRecording = async (tagId) => {
if (!editingRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/${tagId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to remove tag');
}
// Update local recording data
editingRecording.value.tags = editingRecording.value.tags.filter(tag => tag.id !== tagId);
// Also update in recordings list
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value && recordingInList.tags) {
recordingInList.tags = recordingInList.tags.filter(tag => tag.id !== tagId);
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id && selectedRecording.value.tags) {
selectedRecording.value.tags = selectedRecording.value.tags.filter(tag => tag.id !== tagId);
}
showToast('Tag removed successfully', 'fa-check-circle', 2000, 'success');
} catch (error) {
console.error('Error removing tag from recording:', error);
setGlobalError(`Failed to remove tag: ${error.message}`);
}
};
// --- Modal Tag Reordering ---
const reorderModalTags = async (fromIndex, toIndex) => {
if (!editingRecording.value || !editingRecording.value.tags) return;
// Reorder locally first for immediate visual feedback
const tags = [...editingRecording.value.tags];
const [removed] = tags.splice(fromIndex, 1);
tags.splice(toIndex, 0, removed);
editingRecording.value.tags = tags;
// Update in recordings list
const recordingInList = recordings.value.find(r => r.id === editingRecording.value.id);
if (recordingInList && recordingInList !== editingRecording.value) {
recordingInList.tags = [...tags];
}
// Update selectedRecording if it matches
if (selectedRecording.value && selectedRecording.value.id === editingRecording.value.id) {
selectedRecording.value.tags = [...tags];
}
// Persist to backend
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const tagIds = tags.map(t => t.id);
const response = await fetch(`/api/recordings/${editingRecording.value.id}/tags/reorder`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ tag_ids: tagIds })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to reorder tags');
}
showToast('Tags reordered', 'fa-arrows-alt', 1500, 'success');
} catch (error) {
console.error('Error reordering tags:', error);
setGlobalError(`Failed to save tag order: ${error.message}`);
}
};
// === MOUSE DRAG HANDLERS (Modal) ===
const handleModalTagDragStart = (index, event) => {
modalDraggedTagIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index.toString());
};
const handleModalTagDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
modalDragOverTagIndex.value = index;
};
const handleModalTagDrop = (targetIndex, event) => {
event.preventDefault();
if (modalDraggedTagIndex.value !== null && modalDraggedTagIndex.value !== targetIndex) {
reorderModalTags(modalDraggedTagIndex.value, targetIndex);
}
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
const handleModalTagDragEnd = () => {
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
// === TOUCH HANDLERS (Modal - Mobile) ===
let modalTouchStartIndex = null;
const handleModalTagTouchStart = (index, event) => {
modalTouchStartIndex = index;
modalDraggedTagIndex.value = index;
};
const handleModalTagTouchMove = (event) => {
if (modalTouchStartIndex === null) return;
event.preventDefault();
const touch = event.touches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
const tagElement = elementBelow?.closest('[data-modal-tag-index]');
if (tagElement) {
const targetIndex = parseInt(tagElement.dataset.modalTagIndex);
modalDragOverTagIndex.value = targetIndex;
}
};
const handleModalTagTouchEnd = () => {
if (modalTouchStartIndex !== null && modalDragOverTagIndex.value !== null &&
modalTouchStartIndex !== modalDragOverTagIndex.value) {
reorderModalTags(modalTouchStartIndex, modalDragOverTagIndex.value);
}
modalTouchStartIndex = null;
modalDraggedTagIndex.value = null;
modalDragOverTagIndex.value = null;
};
return {
// Computed
filteredAvailableTagsForModal,
// Methods
getRecordingTags,
getAvailableTagsForRecording,
editRecordingTags,
closeEditTagsModal,
addTagToRecording,
removeTagFromRecording,
// Modal Tag Drag-and-Drop
modalDraggedTagIndex,
modalDragOverTagIndex,
handleModalTagDragStart,
handleModalTagDragOver,
handleModalTagDrop,
handleModalTagDragEnd,
handleModalTagTouchStart,
handleModalTagTouchMove,
handleModalTagTouchEnd
};
}

View File

@@ -0,0 +1,286 @@
/**
* API Tokens Management Composable
* Handles API token operations for user authentication
*/
const { ref, computed } = Vue;
export function useTokens({ showToast, setGlobalError }) {
// State
const tokens = ref([]);
const isLoadingTokens = ref(false);
const showCreateTokenModal = ref(false);
const showTokenSecretModal = ref(false);
const newTokenSecret = ref('');
const newTokenData = ref(null);
const tokenForm = ref({
name: '',
expires_in_days: 0 // 0 = no expiration
});
// Computed
const hasTokens = computed(() => tokens.value.length > 0);
const activeTokens = computed(() => {
return tokens.value.filter(token => !token.revoked && !isTokenExpired(token));
});
const expiredOrRevokedTokens = computed(() => {
return tokens.value.filter(token => token.revoked || isTokenExpired(token));
});
// Helper methods
const isTokenExpired = (token) => {
if (!token.expires_at) return false;
const expiryDate = new Date(token.expires_at);
return expiryDate < new Date();
};
const formatTokenDate = (dateString) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
const getTokenStatus = (token) => {
if (token.revoked) return 'revoked';
if (isTokenExpired(token)) return 'expired';
return 'active';
};
const getTokenStatusClass = (token) => {
const status = getTokenStatus(token);
const baseClasses = 'px-2 py-1 text-xs font-semibold rounded';
switch (status) {
case 'active':
return `${baseClasses} bg-green-100 text-green-800`;
case 'expired':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
case 'revoked':
return `${baseClasses} bg-red-100 text-red-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
// API methods
const loadTokens = async () => {
isLoadingTokens.value = true;
try {
const response = await fetch('/api/tokens', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to load tokens');
}
const data = await response.json();
tokens.value = data.tokens || [];
} catch (error) {
console.error('Error loading tokens:', error);
setGlobalError('Failed to load API tokens: ' + error.message);
} finally {
isLoadingTokens.value = false;
}
};
const createToken = async () => {
if (!tokenForm.value.name || tokenForm.value.name.trim() === '') {
showToast('Please enter a token name', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
name: tokenForm.value.name,
expires_in_days: parseInt(tokenForm.value.expires_in_days) || 0
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create token');
}
const data = await response.json();
// Store the plaintext token to show to user (only shown once)
newTokenSecret.value = data.token;
newTokenData.value = {
id: data.id,
name: data.name,
created_at: data.created_at,
expires_at: data.expires_at
};
// Add to tokens list (without the plaintext token)
tokens.value.unshift({
id: data.id,
name: data.name,
created_at: data.created_at,
last_used_at: data.last_used_at,
expires_at: data.expires_at,
revoked: data.revoked
});
// Reset form
tokenForm.value = {
name: '',
expires_in_days: 0
};
// Close create modal and show secret modal
showCreateTokenModal.value = false;
showTokenSecretModal.value = true;
showToast('API token created successfully', 'success');
} catch (error) {
console.error('Error creating token:', error);
showToast('Failed to create token: ' + error.message, 'error');
}
};
const revokeToken = async (tokenId, tokenName) => {
if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone and any applications using this token will lose access.`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to revoke token');
}
// Remove from local list
tokens.value = tokens.value.filter(t => t.id !== tokenId);
showToast('Token revoked successfully', 'success');
} catch (error) {
console.error('Error revoking token:', error);
showToast('Failed to revoke token: ' + error.message, 'error');
}
};
const updateTokenName = async (tokenId, newName) => {
if (!newName || newName.trim() === '') {
showToast('Token name cannot be empty', 'error');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ name: newName })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update token');
}
const data = await response.json();
// Update local token
const token = tokens.value.find(t => t.id === tokenId);
if (token) {
token.name = data.name;
}
showToast('Token name updated', 'success');
} catch (error) {
console.error('Error updating token:', error);
showToast('Failed to update token: ' + error.message, 'error');
}
};
const copyTokenToClipboard = async (token) => {
try {
await navigator.clipboard.writeText(token);
showToast('Token copied to clipboard', 'success');
} catch (error) {
console.error('Error copying token:', error);
showToast('Failed to copy token to clipboard', 'error');
}
};
const openCreateTokenModal = () => {
tokenForm.value = {
name: '',
expires_in_days: 0
};
showCreateTokenModal.value = true;
};
const closeCreateTokenModal = () => {
showCreateTokenModal.value = false;
tokenForm.value = {
name: '',
expires_in_days: 0
};
};
const closeTokenSecretModal = () => {
showTokenSecretModal.value = false;
newTokenSecret.value = '';
newTokenData.value = null;
};
return {
// State
tokens,
isLoadingTokens,
showCreateTokenModal,
showTokenSecretModal,
newTokenSecret,
newTokenData,
tokenForm,
// Computed
hasTokens,
activeTokens,
expiredOrRevokedTokens,
// Methods
isTokenExpired,
formatTokenDate,
getTokenStatus,
getTokenStatusClass,
loadTokens,
createToken,
revokeToken,
updateTokenName,
copyTokenToClipboard,
openCreateTokenModal,
closeCreateTokenModal,
closeTokenSecretModal
};
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,824 @@
/**
* Upload management composable
* Handles file uploads, queue processing, and progress tracking
*/
import * as FailedUploads from '../db/failed-uploads.js';
import * as IncognitoStorage from '../db/incognito-storage.js';
// Parse error message and return friendly error info
function getFriendlyError(errorMessage, t) {
const _t = t || ((key) => key);
if (!errorMessage) return { title: _t('errors.processingError'), message: _t('errors.processingErrorMessage') };
const lowerText = errorMessage.toLowerCase();
const patterns = [
{ patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], title: _t('errors.fileTooLargeTitle'), guidance: _t('errors.enableChunkingGuidance') },
{ patterns: ['timed out', 'timeout', 'deadline exceeded'], title: _t('errors.processingTimeout'), guidance: _t('errors.splitAudioGuidance') },
{ patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], title: _t('errors.authenticationError'), guidance: _t('errors.checkApiKeyGuidance') },
{ patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], title: _t('errors.rateLimitExceeded'), guidance: _t('errors.waitAndRetryGuidance') },
{ patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], title: _t('errors.connectionError'), guidance: _t('errors.checkNetworkGuidance') },
{ patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], title: _t('errors.serviceUnavailable'), guidance: _t('errors.tryAgainLaterGuidance') },
{ patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt'], title: _t('errors.invalidAudioFormat'), guidance: _t('errors.convertFormatGuidance') },
{ patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], title: _t('errors.audioExtractionFailed'), guidance: _t('errors.convertStandardGuidance') },
];
for (const pattern of patterns) {
for (const p of pattern.patterns) {
if (lowerText.includes(p)) return { title: pattern.title, guidance: pattern.guidance };
}
}
return { title: _t('errors.processingError'), guidance: _t('errors.processingErrorFallbackGuidance') };
}
export function useUpload(state, utils) {
const {
uploadQueue, currentlyProcessingFile, processingProgress, processingMessage,
isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed,
maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, maxConcurrentUploads,
recordings, selectedRecording, totalRecordings, globalError,
selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt,
useAsrEndpoint, connectorSupportsDiarization, asrLanguage, asrMinSpeakers, asrMaxSpeakers,
dragover, availableTags, uploadTagSearchFilter,
// Folder state
availableFolders, selectedFolderId,
// Incognito mode state
incognitoMode, incognitoRecording, incognitoProcessing,
// View state
currentView,
// Upload disclaimer state
uploadDisclaimer, showUploadDisclaimerModal
} = state;
const { computed, nextTick, ref } = Vue;
const { setGlobalError, showToast, formatFileSize, onChatComplete, t } = utils;
// Compute selected tags from IDs
const selectedTags = computed(() => {
return selectedTagIds.value.map(id =>
availableTags.value.find(t => t.id === id)
).filter(Boolean);
});
// --- Tag Drag-and-Drop State ---
const draggedTagIndex = ref(null);
const dragOverTagIndex = ref(null);
// Reorder selectedTagIds array
const reorderSelectedTags = (fromIndex, toIndex) => {
const tagIds = [...selectedTagIds.value];
const [removed] = tagIds.splice(fromIndex, 1);
tagIds.splice(toIndex, 0, removed);
selectedTagIds.value = tagIds;
applyTagDefaults(); // Re-apply defaults since first tag may have changed
};
// === MOUSE DRAG HANDLERS ===
const handleTagDragStart = (index, event) => {
draggedTagIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index.toString());
};
const handleTagDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
dragOverTagIndex.value = index;
};
const handleTagDrop = (targetIndex, event) => {
event.preventDefault();
if (draggedTagIndex.value !== null && draggedTagIndex.value !== targetIndex) {
reorderSelectedTags(draggedTagIndex.value, targetIndex);
}
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
const handleTagDragEnd = () => {
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
// === TOUCH HANDLERS (Mobile) ===
let touchStartIndex = null;
const handleTagTouchStart = (index, event) => {
touchStartIndex = index;
draggedTagIndex.value = index;
};
const handleTagTouchMove = (event) => {
if (touchStartIndex === null) return;
event.preventDefault();
const touch = event.touches[0];
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
const tagElement = elementBelow?.closest('[data-tag-index]');
if (tagElement) {
const targetIndex = parseInt(tagElement.dataset.tagIndex);
dragOverTagIndex.value = targetIndex;
}
};
const handleTagTouchEnd = () => {
if (touchStartIndex !== null && dragOverTagIndex.value !== null &&
touchStartIndex !== dragOverTagIndex.value) {
reorderSelectedTags(touchStartIndex, dragOverTagIndex.value);
}
touchStartIndex = null;
draggedTagIndex.value = null;
dragOverTagIndex.value = null;
};
// Handle drag events
const handleDragOver = (e) => {
e.preventDefault();
dragover.value = true;
};
const handleDragLeave = (e) => {
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) {
return;
}
dragover.value = false;
};
const handleDrop = (e) => {
e.preventDefault();
dragover.value = false;
addFilesToQueue(e.dataTransfer.files);
};
const handleFileSelect = (e) => {
addFilesToQueue(e.target.files);
e.target.value = null;
};
// Add files to the upload queue
const addFilesToQueue = (files) => {
let filesAdded = 0;
for (const file of files) {
const fileObject = file.file ? file.file : file;
const notes = file.notes || null;
const tags = file.tags || selectedTags.value || [];
const asrOptions = file.asrOptions || {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value
};
// Check if it's an audio file or video container with audio
const isAudioFile = fileObject && (
fileObject.type.startsWith('audio/') ||
fileObject.type === 'video/mp4' ||
fileObject.type === 'video/quicktime' ||
fileObject.type === 'video/x-msvideo' ||
fileObject.type === 'video/webm' ||
fileObject.name.toLowerCase().endsWith('.amr') ||
fileObject.name.toLowerCase().endsWith('.3gp') ||
fileObject.name.toLowerCase().endsWith('.3gpp') ||
fileObject.name.toLowerCase().endsWith('.mp4') ||
fileObject.name.toLowerCase().endsWith('.mov') ||
fileObject.name.toLowerCase().endsWith('.avi') ||
fileObject.name.toLowerCase().endsWith('.mkv') ||
fileObject.name.toLowerCase().endsWith('.webm') ||
fileObject.name.toLowerCase().endsWith('.weba')
);
if (isAudioFile) {
// Only check general file size limit
if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) {
setGlobalError(t('upload.fileExceedsMaxSize', { name: fileObject.name, size: maxFileSizeMB.value }));
continue;
}
const clientId = `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
uploadQueue.value.push({
file: fileObject,
notes: notes,
tags: tags,
asrOptions: asrOptions,
status: 'queued',
recordingId: null,
clientId: clientId,
error: null,
willAutoSummarize: false // Server will tell us via SUMMARIZING status
});
filesAdded++;
} else if (fileObject) {
setGlobalError(t('upload.invalidFileType', { name: fileObject.name }));
}
}
if (filesAdded > 0) {
console.log(`Added ${filesAdded} file(s) to the queue.`);
}
};
// Remove a file from the queue before processing starts
const removeFromQueue = (clientId) => {
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
if (index !== -1 && (uploadQueue.value[index].status === 'queued' || uploadQueue.value[index].status === 'ready')) {
uploadQueue.value.splice(index, 1);
console.log(`Removed file from queue: ${clientId}`);
}
};
// Cancel a waiting file from the upload progress queue
const cancelWaitingFile = (clientId) => {
const index = uploadQueue.value.findIndex(item => item.clientId === clientId);
if (index !== -1 && uploadQueue.value[index].status === 'ready') {
uploadQueue.value.splice(index, 1);
console.log(`Cancelled waiting file: ${clientId}`);
showToast(t('upload.fileRemovedFromQueue'), 'fa-trash');
}
};
// Clear completed uploads from queue
const clearCompletedUploads = () => {
uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status));
};
// Start processing all queued files
const startUpload = () => {
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
if (pendingFiles.length === 0) {
return;
}
// Show upload disclaimer if configured
if (uploadDisclaimer.value && uploadDisclaimer.value.trim() !== '') {
showUploadDisclaimerModal.value = true;
return;
}
// Update all queued files with current tags and ASR options
// AND change their status to 'ready' so they move to upload progress immediately
for (const item of uploadQueue.value) {
if (item.status === 'queued') {
if (!item.preserveOptions) {
// For file uploads: use current UI selection (user may have changed tags after dropping)
item.tags = [...selectedTags.value];
item.asrOptions = {
language: asrLanguage.value,
min_speakers: asrMinSpeakers.value,
max_speakers: asrMaxSpeakers.value,
hotwords: uploadHotwords.value,
initial_prompt: uploadInitialPrompt.value,
};
item.folder_id = selectedFolderId.value;
}
// Change status to 'ready' to remove from upload view but keep in queue
item.status = 'ready';
}
}
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
startProcessingQueue();
};
// --- Parallel Upload System ---
// Concurrency limiter: configurable via MAX_CONCURRENT_UPLOADS env var (default 3)
let activeUploadCount = 0;
const pendingUploadQueue = []; // Functions waiting for a slot
const acquireUploadSlot = () => {
return new Promise(resolve => {
if (activeUploadCount < (maxConcurrentUploads?.value || 3)) {
activeUploadCount++;
resolve();
} else {
pendingUploadQueue.push(resolve);
}
});
};
const releaseUploadSlot = () => {
activeUploadCount--;
if (pendingUploadQueue.length > 0) {
activeUploadCount++;
const next = pendingUploadQueue.shift();
next();
}
// When all uploads are done, clear processing active flag
const stillUploading = uploadQueue.value.some(item =>
['uploading', 'ready'].includes(item.status)
);
if (!stillUploading) {
isProcessingActive.value = false;
}
};
const resetCurrentFileProcessingState = () => {
if (pollInterval.value) clearInterval(pollInterval.value);
pollInterval.value = null;
currentlyProcessingFile.value = null;
processingProgress.value = 0;
processingMessage.value = '';
};
/**
* Upload a single file to the server.
* Acquires a concurrency slot, uploads, then releases.
* Status updates are per-item (no global processingProgress).
*/
const uploadSingleFile = async (fileItem) => {
await acquireUploadSlot();
fileItem.status = 'uploading';
fileItem.progress = 5;
try {
const formData = new FormData();
formData.append('file', fileItem.file);
// Send file's lastModified timestamp for meeting_date
if (fileItem.file.lastModified) {
const lastModified = fileItem.file.lastModified;
formData.append('file_last_modified', lastModified.toString());
}
if (fileItem.notes) {
formData.append('notes', fileItem.notes);
}
// Add tags if selected
const tagsToUse = fileItem.tags || selectedTags.value || [];
tagsToUse.forEach((tag, index) => {
const tagId = tag.id || tag;
formData.append(`tag_ids[${index}]`, tagId);
});
// Add folder if selected
const folderToUse = fileItem.folder_id || selectedFolderId.value;
if (folderToUse) {
formData.append('folder_id', folderToUse);
}
// Add ASR options
const asrOpts = fileItem.asrOptions || {};
const language = asrOpts.language || uploadLanguage.value;
if (language) {
formData.append('language', language);
}
if (connectorSupportsDiarization.value) {
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
if (minSpeakers && minSpeakers !== '') {
formData.append('min_speakers', minSpeakers.toString());
}
if (maxSpeakers && maxSpeakers !== '') {
formData.append('max_speakers', maxSpeakers.toString());
}
}
// Add hotwords and initial prompt
const hotwords = asrOpts.hotwords || uploadHotwords.value;
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
if (hotwords && hotwords.trim()) {
formData.append('hotwords', hotwords.trim());
}
if (initialPrompt && initialPrompt.trim()) {
formData.append('initial_prompt', initialPrompt.trim());
}
// Refresh CSRF token before upload (prevents stale token after sleep/idle)
let csrfToken;
if (window.csrfManager) {
try {
csrfToken = await window.csrfManager.refreshToken();
} catch (e) {
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}
} else {
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}
// Use XMLHttpRequest for per-file upload progress
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
// Map upload progress to 5-90% range
fileItem.progress = Math.round(5 + (e.loaded / e.total) * 85);
}
};
xhr.onload = () => {
const contentType = xhr.getResponseHeader('content-type') || '';
if (!contentType.includes('application/json')) {
const titleMatch = xhr.responseText.match(/<title>([^<]+)<\/title>/i);
const h1Match = xhr.responseText.match(/<h1>([^<]+)<\/h1>/i);
reject(new Error(titleMatch?.[1] || h1Match?.[1] ||
`Server error (${xhr.status}): Response was not JSON`));
return;
}
let parsed;
try {
parsed = JSON.parse(xhr.responseText);
} catch {
reject(new Error(`Invalid JSON response (${xhr.status})`));
return;
}
if (xhr.status === 202 && parsed.id) {
resolve(parsed);
} else if (!String(xhr.status).startsWith('2')) {
let errorMsg = parsed.error || `Upload failed with status ${xhr.status}`;
if (xhr.status === 413) errorMsg = parsed.error || `File too large. Max: ${parsed.max_size_mb?.toFixed(0) || maxFileSizeMB.value} MB.`;
reject(new Error(errorMsg));
} else {
reject(new Error('Unexpected success response from server after upload.'));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.ontimeout = () => reject(new Error('Upload timed out'));
// Store abort controller on item for cancellation
fileItem._xhr = xhr;
xhr.open('POST', '/upload');
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
// Upload succeeded - recording is now on the server
console.log(`File ${fileItem.file.name} uploaded. Recording ID: ${data.id}. Server will process via job queue.`);
fileItem.status = 'pending';
fileItem.recordingId = data.id;
fileItem.progress = 100;
// Add to recordings list
recordings.value.unshift(data);
totalRecordings.value++;
// Clear recording session only after confirmed upload
if (fileItem.onUploadSuccess) {
await fileItem.onUploadSuccess();
}
// Handle duplicate warning
if (data.duplicate_warning) {
const warning = data.duplicate_warning;
const existingDate = warning.existing_created_at
? new Date(warning.existing_created_at).toLocaleDateString()
: '';
const existingName = warning.existing_title || 'Unknown';
showToast(
`⚠️ ${existingName} (${existingDate})`,
'fa-copy'
);
fileItem.duplicateWarning = warning;
}
} catch (error) {
console.error(`Upload Error for ${fileItem.file.name} (Client ID: ${fileItem.clientId}):`, error);
fileItem.status = 'failed';
fileItem.error = error.message;
fileItem.progress = 0;
// Show friendly error message
const friendlyErr = getFriendlyError(error.message, t);
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
// Store failed upload in IndexedDB for background sync retry
try {
await FailedUploads.storeFailedUpload({
file: fileItem.file,
fileName: fileItem.file.name,
fileSize: fileItem.file.size,
clientId: fileItem.clientId,
notes: fileItem.notes,
tags: fileItem.tags,
asrOptions: fileItem.asrOptions,
error: error.message
});
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-uploads');
console.log('[Upload] Registered background sync for failed upload');
}
} catch (syncError) {
console.warn('[Upload] Failed to register background sync:', syncError);
}
} finally {
fileItem._xhr = null;
releaseUploadSlot();
}
};
/**
* Start uploading all ready files in parallel (with concurrency limit).
* Processing status is tracked via allJobs polling in app.modular.js.
*/
const startProcessingQueue = async () => {
const readyItems = uploadQueue.value.filter(item => item.status === 'ready');
if (readyItems.length === 0) {
console.log("No files ready to upload.");
return;
}
isProcessingActive.value = true;
console.log(`Starting parallel upload of ${readyItems.length} file(s) (max ${maxConcurrentUploads?.value || 3} concurrent)...`);
// Fire off all uploads concurrently (semaphore handles limiting)
const uploadPromises = readyItems.map(item => uploadSingleFile(item));
// Don't await - let them run in background. isProcessingActive is cleared by releaseUploadSlot.
Promise.allSettled(uploadPromises).then(() => {
console.log('All uploads settled.');
});
};
// Keep backward-compat aliases
const startStatusPolling = (fileItem, recordingId) => {
// No longer needed - allJobs polling handles status tracking
fileItem.recordingId = recordingId;
};
const pollProcessingStatus = () => {
// No-op: status tracking is now handled by allJobs polling in app.modular.js
};
// Tag selection helpers
const addTagToSelection = (tagId) => {
if (!selectedTagIds.value.includes(tagId)) {
selectedTagIds.value.push(tagId);
applyTagDefaults();
}
};
const removeTagFromSelection = (tagId) => {
const index = selectedTagIds.value.indexOf(tagId);
if (index > -1) {
selectedTagIds.value.splice(index, 1);
applyTagDefaults();
}
};
const applyTagDefaults = () => {
const selectedTagsObjects = selectedTagIds.value.map(tagId =>
availableTags.value.find(tag => tag.id == tagId)
).filter(Boolean);
const firstTag = selectedTagsObjects[0];
if (firstTag && connectorSupportsDiarization.value) {
if (firstTag.default_language) {
uploadLanguage.value = firstTag.default_language;
}
if (firstTag.default_min_speakers) {
uploadMinSpeakers.value = firstTag.default_min_speakers;
}
if (firstTag.default_max_speakers) {
uploadMaxSpeakers.value = firstTag.default_max_speakers;
}
}
// Apply hotwords/initial_prompt from first tag (works for all connectors)
if (firstTag) {
if (firstTag.default_hotwords) {
uploadHotwords.value = firstTag.default_hotwords;
}
if (firstTag.default_initial_prompt) {
uploadInitialPrompt.value = firstTag.default_initial_prompt;
}
}
};
// Computed property for filtered available tags in upload view
const filteredAvailableTagsForUpload = computed(() => {
const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id));
if (!uploadTagSearchFilter.value) return availableForSelection;
const filter = uploadTagSearchFilter.value.toLowerCase();
return availableForSelection.filter(tag =>
tag.name.toLowerCase().includes(filter)
);
});
// === INCOGNITO MODE FUNCTIONS ===
/**
* Upload and process a file in incognito mode.
* The file is processed synchronously and no data is saved to the database.
* Results are stored only in sessionStorage.
*/
const startIncognitoUpload = async () => {
const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued');
if (pendingFiles.length === 0) {
return;
}
// Only process the first file for incognito mode
const fileItem = pendingFiles[0];
// Check if incognito mode state is available
if (!incognitoMode || !incognitoProcessing || !incognitoRecording) {
console.warn('[Incognito] Incognito state not available, falling back to normal upload');
startUpload();
return;
}
incognitoProcessing.value = true;
processingMessage.value = t('incognito.processingInProgress');
processingProgress.value = 10;
progressPopupMinimized.value = false;
progressPopupClosed.value = false;
try {
const formData = new FormData();
formData.append('file', fileItem.file);
// Add ASR options
const asrOpts = fileItem.asrOptions || {};
const language = asrOpts.language || uploadLanguage.value;
const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value;
const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value;
if (language) {
formData.append('language', language);
}
if (minSpeakers && minSpeakers !== '') {
formData.append('min_speakers', minSpeakers.toString());
}
if (maxSpeakers && maxSpeakers !== '') {
formData.append('max_speakers', maxSpeakers.toString());
}
const hotwords = asrOpts.hotwords || uploadHotwords.value;
const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value;
if (hotwords && hotwords.trim()) {
formData.append('hotwords', hotwords.trim());
}
if (initialPrompt && initialPrompt.trim()) {
formData.append('initial_prompt', initialPrompt.trim());
}
// Request auto-summarization
formData.append('auto_summarize', 'true');
processingMessage.value = t('incognito.uploadingFile');
processingProgress.value = 20;
console.log('[Incognito] Uploading file:', fileItem.file.name);
const response = await fetch('/api/recordings/incognito', {
method: 'POST',
body: formData
});
processingProgress.value = 50;
// Parse response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
throw new Error(titleMatch?.[1] || `Server error (${response.status})`);
}
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `Processing failed with status ${response.status}`);
}
processingProgress.value = 80;
processingMessage.value = t('incognito.processingComplete');
// Store result in sessionStorage
const incognitoData = {
id: 'incognito',
incognito: true,
title: data.title || t('incognito.recordingTitle'),
transcription: data.transcription,
summary: data.summary,
summary_html: data.summary_html,
created_at: data.created_at,
original_filename: data.original_filename,
file_size: data.file_size,
audio_duration_seconds: data.audio_duration_seconds,
processing_time_seconds: data.processing_time_seconds,
status: 'COMPLETED'
};
IncognitoStorage.saveIncognitoRecording(incognitoData);
incognitoRecording.value = incognitoData;
// Remove the processed file from queue
const index = uploadQueue.value.findIndex(item => item.clientId === fileItem.clientId);
if (index !== -1) {
uploadQueue.value.splice(index, 1);
}
processingProgress.value = 100;
processingMessage.value = t('incognito.recordingReady');
// Auto-select the incognito recording and switch to detail view
selectedRecording.value = incognitoData;
currentView.value = 'detail';
// Show toast
showToast(t('incognito.recordingProcessed'), 'fa-user-secret');
console.log('[Incognito] Processing complete');
} catch (error) {
console.error('[Incognito] Processing failed:', error);
const friendlyErr = getFriendlyError(error.message, t);
setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`);
fileItem.status = 'failed';
fileItem.error = error.message;
} finally {
incognitoProcessing.value = false;
processingProgress.value = 0;
processingMessage.value = '';
}
};
/**
* Clear the incognito recording with confirmation
*/
const clearIncognitoRecordingWithConfirm = () => {
if (incognitoRecording && incognitoRecording.value) {
if (confirm(t('incognito.discardConfirm'))) {
IncognitoStorage.clearIncognitoRecording();
incognitoRecording.value = null;
// If the incognito recording was selected, clear selection
if (selectedRecording.value?.id === 'incognito') {
selectedRecording.value = null;
}
showToast(t('incognito.recordingDiscarded'), 'fa-trash');
}
}
};
/**
* Select the incognito recording for viewing
*/
const selectIncognitoRecording = () => {
if (incognitoRecording && incognitoRecording.value) {
selectedRecording.value = incognitoRecording.value;
currentView.value = 'detail';
}
};
/**
* Load incognito recording from sessionStorage on app init
*/
const loadIncognitoRecording = () => {
const stored = IncognitoStorage.getIncognitoRecording();
if (stored && incognitoRecording) {
incognitoRecording.value = stored;
console.log('[Incognito] Loaded recording from sessionStorage');
}
};
/**
* Check if there's an incognito recording (for navigation guards)
*/
const hasIncognitoRecording = () => {
return IncognitoStorage.hasIncognitoRecording();
};
return {
handleDragOver,
handleDragLeave,
handleDrop,
handleFileSelect,
addFilesToQueue,
removeFromQueue,
cancelWaitingFile,
clearCompletedUploads,
startUpload,
startProcessingQueue,
resetCurrentFileProcessingState,
startStatusPolling,
pollProcessingStatus,
addTagToSelection,
removeTagFromSelection,
applyTagDefaults,
filteredAvailableTagsForUpload,
// Tag drag-and-drop
draggedTagIndex,
dragOverTagIndex,
handleTagDragStart,
handleTagDragOver,
handleTagDrop,
handleTagDragEnd,
handleTagTouchStart,
handleTagTouchMove,
handleTagTouchEnd,
// Incognito mode
startIncognitoUpload,
clearIncognitoRecordingWithConfirm,
selectIncognitoRecording,
loadIncognitoRecording,
hasIncognitoRecording
};
}

View File

@@ -0,0 +1,204 @@
/**
* Virtual Scrolling Composable
*
* Renders only visible items plus a buffer for smooth scrolling.
* Critical for handling long transcriptions (4500+ segments) without UI lag.
*
* Usage:
* const { visibleItems, spacerBefore, spacerAfter, onScroll, scrollToIndex } = useVirtualScroll({
* items: segmentsRef,
* itemHeight: 48,
* containerRef: scrollContainerRef,
* overscan: 5
* });
*/
export function useVirtualScroll(options) {
const { ref, computed, watch, onMounted, onUnmounted } = Vue;
const {
items, // Ref to the full array of items
itemHeight = 48, // Height of each item in pixels (fixed height mode)
containerRef, // Ref to the scrollable container element
overscan = 5, // Number of items to render outside viewport
keyField = null // Optional field to use for unique keys
} = options;
// Internal state
const scrollTop = ref(0);
const containerHeight = ref(0);
const isInitialized = ref(false);
// Calculate visible range based on scroll position
const visibleRange = computed(() => {
if (!isInitialized.value || !items.value) {
return { start: 0, end: Math.min(20, items.value?.length || 0) };
}
const totalItems = items.value.length;
if (totalItems === 0) {
return { start: 0, end: 0 };
}
// Calculate first visible item
const firstVisible = Math.floor(scrollTop.value / itemHeight);
// Calculate number of items that fit in viewport
const visibleCount = Math.ceil(containerHeight.value / itemHeight);
// Add overscan for smooth scrolling
const start = Math.max(0, firstVisible - overscan);
const end = Math.min(totalItems, firstVisible + visibleCount + overscan);
return { start, end };
});
// Slice of items to actually render
const visibleItems = computed(() => {
if (!items.value || items.value.length === 0) {
return [];
}
const { start, end } = visibleRange.value;
// Map items with their original indices for proper data binding
return items.value.slice(start, end).map((item, localIndex) => ({
...item,
_virtualIndex: start + localIndex,
_originalIndex: start + localIndex
}));
});
// Spacer height before visible items (for scroll position)
const spacerBefore = computed(() => {
return visibleRange.value.start * itemHeight;
});
// Spacer height after visible items
const spacerAfter = computed(() => {
if (!items.value) return 0;
const remainingItems = items.value.length - visibleRange.value.end;
return Math.max(0, remainingItems * itemHeight);
});
// Total height of all items (for scroll container)
const totalHeight = computed(() => {
if (!items.value) return 0;
return items.value.length * itemHeight;
});
// Handle scroll events
const onScroll = (event) => {
scrollTop.value = event.target.scrollTop;
};
// Initialize container height observer
let resizeObserver = null;
const initializeContainer = () => {
if (!containerRef.value) return;
// Get initial height
containerHeight.value = containerRef.value.clientHeight;
isInitialized.value = true;
// Watch for container size changes
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight.value = entry.contentRect.height;
}
});
resizeObserver.observe(containerRef.value);
};
// Scroll to a specific index
const scrollToIndex = (index, behavior = 'smooth') => {
if (!containerRef.value || !items.value) return;
const targetIndex = Math.max(0, Math.min(index, items.value.length - 1));
const targetScrollTop = targetIndex * itemHeight;
containerRef.value.scrollTo({
top: targetScrollTop,
behavior
});
};
// Scroll to make an index visible (centered if possible)
const scrollToIndexIfNeeded = (index) => {
if (!containerRef.value || !items.value) return;
const { start, end } = visibleRange.value;
// Check if index is already visible (with some margin)
if (index >= start + overscan && index < end - overscan) {
return; // Already visible
}
// Center the index in the viewport
const targetIndex = Math.max(0, index - Math.floor(containerHeight.value / itemHeight / 2));
scrollToIndex(targetIndex, 'smooth');
};
// Reset scroll state (call when modal opens or items change completely)
const reset = () => {
scrollTop.value = 0;
isInitialized.value = false;
// Re-initialize after a tick to allow DOM to render
Vue.nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = 0;
initializeContainer();
}
});
};
// Watch for containerRef changes and initialize
watch(containerRef, (newRef) => {
if (newRef) {
initializeContainer();
}
}, { immediate: true });
// Cleanup on unmount
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
return {
// Data
visibleItems,
visibleRange,
// Spacer heights for virtual scroll container
spacerBefore,
spacerAfter,
totalHeight,
// Event handlers
onScroll,
// Navigation
scrollToIndex,
scrollToIndexIfNeeded,
// Control
reset,
// State (for debugging/testing)
scrollTop,
containerHeight,
isInitialized
};
}
/**
* Helper to generate a unique key for virtual scroll items
*/
export function getVirtualItemKey(item, prefix = 'vs') {
const index = item._originalIndex ?? item._virtualIndex ?? 0;
const time = item.startTime ?? item.start_time ?? '';
return `${prefix}-${index}-${time}`;
}