Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
895
static/js/modules/composables/audio.js
Normal file
895
static/js/modules/composables/audio.js
Normal 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
|
||||
};
|
||||
}
|
||||
338
static/js/modules/composables/audioPlayer.js
Normal file
338
static/js/modules/composables/audioPlayer.js
Normal 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);
|
||||
}
|
||||
475
static/js/modules/composables/bulk-operations.js
Normal file
475
static/js/modules/composables/bulk-operations.js
Normal 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
|
||||
};
|
||||
}
|
||||
111
static/js/modules/composables/bulk-selection.js
Normal file
111
static/js/modules/composables/bulk-selection.js
Normal 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
|
||||
};
|
||||
}
|
||||
380
static/js/modules/composables/chat.js
Normal file
380
static/js/modules/composables/chat.js
Normal 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
|
||||
};
|
||||
}
|
||||
173
static/js/modules/composables/folders.js
Normal file
173
static/js/modules/composables/folders.js
Normal 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
|
||||
};
|
||||
}
|
||||
33
static/js/modules/composables/index.js
Normal file
33
static/js/modules/composables/index.js
Normal 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';
|
||||
659
static/js/modules/composables/modals.js
Normal file
659
static/js/modules/composables/modals.js
Normal 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
|
||||
};
|
||||
}
|
||||
518
static/js/modules/composables/pwa.js
Normal file
518
static/js/modules/composables/pwa.js
Normal 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
|
||||
};
|
||||
}
|
||||
482
static/js/modules/composables/recordings.js
Normal file
482
static/js/modules/composables/recordings.js
Normal 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
|
||||
};
|
||||
}
|
||||
450
static/js/modules/composables/reprocess.js
Normal file
450
static/js/modules/composables/reprocess.js
Normal 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
|
||||
};
|
||||
}
|
||||
659
static/js/modules/composables/sharing.js
Normal file
659
static/js/modules/composables/sharing.js
Normal 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
|
||||
};
|
||||
}
|
||||
1251
static/js/modules/composables/speakers.js
Normal file
1251
static/js/modules/composables/speakers.js
Normal file
File diff suppressed because it is too large
Load Diff
297
static/js/modules/composables/tags.js
Normal file
297
static/js/modules/composables/tags.js
Normal 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
|
||||
};
|
||||
}
|
||||
286
static/js/modules/composables/tokens.js
Normal file
286
static/js/modules/composables/tokens.js
Normal 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
|
||||
};
|
||||
}
|
||||
484
static/js/modules/composables/transcription.js
Normal file
484
static/js/modules/composables/transcription.js
Normal 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
|
||||
};
|
||||
}
|
||||
2110
static/js/modules/composables/ui.js
Normal file
2110
static/js/modules/composables/ui.js
Normal file
File diff suppressed because it is too large
Load Diff
824
static/js/modules/composables/upload.js
Normal file
824
static/js/modules/composables/upload.js
Normal 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
|
||||
};
|
||||
}
|
||||
204
static/js/modules/composables/virtualScroll.js
Normal file
204
static/js/modules/composables/virtualScroll.js
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user