Initial commit: Live Captions web application
Real-time speech-to-text using OpenAI Whisper (faster-whisper). Features browser audio capture, WebSocket streaming, and customizable display settings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
355
static/js/app.js
Normal file
355
static/js/app.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Live Captions - Main Application
|
||||
* Handles audio capture and WebSocket communication
|
||||
*/
|
||||
|
||||
const App = {
|
||||
// WebSocket connection
|
||||
socket: null,
|
||||
|
||||
// Audio recording
|
||||
mediaRecorder: null,
|
||||
audioStream: null,
|
||||
audioChunks: [],
|
||||
isRecording: false,
|
||||
recordingInterval: null,
|
||||
|
||||
// Continuous caption stream
|
||||
wordBuffer: [],
|
||||
pendingWords: [],
|
||||
wordAnimationTimer: null,
|
||||
|
||||
// Auto-save recording state
|
||||
sessionStartTime: null,
|
||||
sessionTranscript: [],
|
||||
|
||||
// DOM elements
|
||||
elements: {},
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
init() {
|
||||
this.cacheElements();
|
||||
this.bindEvents();
|
||||
this.connectSocket();
|
||||
|
||||
// Initialize settings module
|
||||
Settings.init();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache DOM element references
|
||||
*/
|
||||
cacheElements() {
|
||||
this.elements = {
|
||||
btnStart: document.getElementById('btn-start'),
|
||||
btnStop: document.getElementById('btn-stop'),
|
||||
btnClear: document.getElementById('btn-clear'),
|
||||
autoSaveToggle: document.getElementById('auto-save-toggle'),
|
||||
captions: document.getElementById('captions'),
|
||||
statusDot: document.getElementById('status-dot'),
|
||||
statusText: document.getElementById('status-text'),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.elements.btnStart.addEventListener('click', () => this.startRecording());
|
||||
this.elements.btnStop.addEventListener('click', () => this.stopRecording());
|
||||
this.elements.btnClear.addEventListener('click', () => this.clearCaptions());
|
||||
|
||||
// Load auto-save preference from localStorage
|
||||
const savedPref = localStorage.getItem('autoSaveEnabled');
|
||||
if (savedPref === 'true') {
|
||||
this.elements.autoSaveToggle.checked = true;
|
||||
}
|
||||
|
||||
// Save preference when toggled
|
||||
this.elements.autoSaveToggle.addEventListener('change', (e) => {
|
||||
localStorage.setItem('autoSaveEnabled', e.target.checked);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connectSocket() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.setStatus('connected', 'Connected');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.setStatus('disconnected', 'Disconnected');
|
||||
});
|
||||
|
||||
this.socket.on('transcription', (data) => {
|
||||
this.addWords(data.text);
|
||||
});
|
||||
|
||||
this.socket.on('settings_updated', (settings) => {
|
||||
Settings.applySettings(settings);
|
||||
});
|
||||
|
||||
this.socket.on('error', (data) => {
|
||||
console.error('Server error:', data.message);
|
||||
});
|
||||
|
||||
this.socket.on('recording_saved', (data) => {
|
||||
console.log('Recording saved:', data.filename);
|
||||
});
|
||||
|
||||
this.socket.on('recording_error', (data) => {
|
||||
console.error('Recording error:', data.message);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update status indicator
|
||||
*/
|
||||
setStatus(state, text) {
|
||||
this.elements.statusDot.className = `dot ${state}`;
|
||||
this.elements.statusText.textContent = text;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start audio recording
|
||||
*/
|
||||
async startRecording() {
|
||||
try {
|
||||
this.audioStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 16000,
|
||||
}
|
||||
});
|
||||
|
||||
this.isRecording = true;
|
||||
this.elements.btnStart.disabled = true;
|
||||
this.elements.btnStop.disabled = false;
|
||||
this.setStatus('recording', 'Recording...');
|
||||
|
||||
// Reset session transcript for auto-save
|
||||
this.sessionStartTime = new Date();
|
||||
this.sessionTranscript = [];
|
||||
|
||||
// Start the recording cycle
|
||||
this.startRecordingCycle();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
this.setStatus('error', 'Microphone access denied');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a recording cycle - record for a duration, then send and restart
|
||||
*/
|
||||
startRecordingCycle() {
|
||||
if (!this.isRecording || !this.audioStream) return;
|
||||
|
||||
// Determine best supported MIME type
|
||||
let mimeType = 'audio/webm';
|
||||
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
mimeType = 'audio/webm;codecs=opus';
|
||||
}
|
||||
|
||||
this.audioChunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(this.audioStream, { mimeType });
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
// Create a complete blob from all chunks
|
||||
if (this.audioChunks.length > 0) {
|
||||
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
this.sendAudioBlob(blob);
|
||||
}
|
||||
|
||||
// Start next cycle if still recording
|
||||
if (this.isRecording) {
|
||||
this.startRecordingCycle();
|
||||
}
|
||||
};
|
||||
|
||||
// Start recording
|
||||
this.mediaRecorder.start();
|
||||
|
||||
// Stop after the configured duration to get a complete blob
|
||||
// Using 1.5 seconds for more responsive streaming
|
||||
const chunkDuration = 1500;
|
||||
this.recordingInterval = setTimeout(() => {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
}, chunkDuration);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop audio recording
|
||||
*/
|
||||
stopRecording() {
|
||||
this.isRecording = false;
|
||||
|
||||
// Clear the recording interval
|
||||
if (this.recordingInterval) {
|
||||
clearTimeout(this.recordingInterval);
|
||||
this.recordingInterval = null;
|
||||
}
|
||||
|
||||
// Stop the media recorder
|
||||
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
// Stop all tracks
|
||||
if (this.audioStream) {
|
||||
this.audioStream.getTracks().forEach(track => track.stop());
|
||||
this.audioStream = null;
|
||||
}
|
||||
|
||||
this.elements.btnStart.disabled = false;
|
||||
this.elements.btnStop.disabled = true;
|
||||
this.setStatus('connected', 'Connected');
|
||||
|
||||
// Auto-save if enabled and we have content
|
||||
if (this.elements.autoSaveToggle.checked && this.sessionTranscript.length > 0) {
|
||||
this.saveRecording();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send complete audio blob to server
|
||||
*/
|
||||
sendAudioBlob(blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
// Get base64 data without the data URL prefix
|
||||
const base64 = reader.result.split(',')[1];
|
||||
|
||||
this.socket.emit('audio_data', {
|
||||
audio: base64,
|
||||
format: 'webm'
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add words to the continuous caption stream
|
||||
*/
|
||||
addWords(text) {
|
||||
if (!text.trim()) return;
|
||||
|
||||
// Split incoming text into words
|
||||
const newWords = text.trim().split(/\s+/);
|
||||
|
||||
// Add to pending queue for animated display
|
||||
this.pendingWords.push(...newWords);
|
||||
|
||||
// Accumulate to session transcript for auto-save
|
||||
if (this.isRecording) {
|
||||
this.sessionTranscript.push(...newWords);
|
||||
}
|
||||
|
||||
// Start animation if not already running
|
||||
if (!this.wordAnimationTimer) {
|
||||
this.animateNextWord();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Animate words appearing one by one
|
||||
*/
|
||||
animateNextWord() {
|
||||
if (this.pendingWords.length === 0) {
|
||||
this.wordAnimationTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get next word from queue
|
||||
const word = this.pendingWords.shift();
|
||||
this.wordBuffer.push(word);
|
||||
|
||||
// Get max words from settings
|
||||
const maxWords = Settings.current.max_words || 30;
|
||||
|
||||
// Trim buffer to max words
|
||||
while (this.wordBuffer.length > maxWords) {
|
||||
this.wordBuffer.shift();
|
||||
}
|
||||
|
||||
// Update display
|
||||
this.updateCaptionDisplay();
|
||||
|
||||
// Calculate delay based on pending words
|
||||
// Faster if more words pending, slower if caught up
|
||||
const baseDelay = 80; // ms per word
|
||||
const minDelay = 30;
|
||||
const delay = this.pendingWords.length > 10 ? minDelay : baseDelay;
|
||||
|
||||
// Schedule next word
|
||||
this.wordAnimationTimer = setTimeout(() => {
|
||||
this.animateNextWord();
|
||||
}, delay);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the caption display with current word buffer
|
||||
*/
|
||||
updateCaptionDisplay() {
|
||||
const text = this.wordBuffer.join(' ');
|
||||
this.elements.captions.textContent = text;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all captions
|
||||
*/
|
||||
clearCaptions() {
|
||||
// Clear animation timer
|
||||
if (this.wordAnimationTimer) {
|
||||
clearTimeout(this.wordAnimationTimer);
|
||||
this.wordAnimationTimer = null;
|
||||
}
|
||||
this.wordBuffer = [];
|
||||
this.pendingWords = [];
|
||||
this.elements.captions.textContent = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current recording session
|
||||
*/
|
||||
saveRecording() {
|
||||
if (!this.sessionStartTime) return;
|
||||
|
||||
const endTime = new Date();
|
||||
const transcript = this.sessionTranscript.join(' ');
|
||||
|
||||
this.socket.emit('save_recording', {
|
||||
startTime: this.sessionStartTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
transcript: transcript,
|
||||
wordCount: this.sessionTranscript.length
|
||||
});
|
||||
|
||||
// Reset session state
|
||||
this.sessionStartTime = null;
|
||||
this.sessionTranscript = [];
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
App.init();
|
||||
});
|
||||
204
static/js/recordings.js
Normal file
204
static/js/recordings.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Live Captions - Recordings Panel
|
||||
* Handles viewing and managing saved recordings
|
||||
*/
|
||||
|
||||
const Recordings = {
|
||||
// Current state
|
||||
recordings: [],
|
||||
currentRecording: null,
|
||||
|
||||
// DOM elements
|
||||
elements: {},
|
||||
|
||||
/**
|
||||
* Initialize the recordings panel
|
||||
*/
|
||||
init() {
|
||||
this.cacheElements();
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache DOM element references
|
||||
*/
|
||||
cacheElements() {
|
||||
this.elements = {
|
||||
btnRecordings: document.getElementById('btn-recordings'),
|
||||
btnClose: document.getElementById('btn-close-recordings'),
|
||||
btnBackToList: document.getElementById('btn-back-to-list'),
|
||||
btnDelete: document.getElementById('btn-delete-recording'),
|
||||
panel: document.getElementById('recordings-panel'),
|
||||
overlay: document.getElementById('overlay'),
|
||||
recordingsList: document.getElementById('recordings-list'),
|
||||
recordingViewer: document.getElementById('recording-viewer'),
|
||||
viewerFilename: document.getElementById('viewer-filename'),
|
||||
viewerContent: document.getElementById('viewer-content'),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
this.elements.btnRecordings.addEventListener('click', () => this.openPanel());
|
||||
this.elements.btnClose.addEventListener('click', () => this.closePanel());
|
||||
this.elements.btnBackToList.addEventListener('click', () => this.showList());
|
||||
this.elements.btnDelete.addEventListener('click', () => this.deleteCurrentRecording());
|
||||
|
||||
// Close on overlay click (but check if it's not settings panel)
|
||||
this.elements.overlay.addEventListener('click', () => {
|
||||
if (!this.elements.panel.classList.contains('hidden')) {
|
||||
this.closePanel();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the recordings panel
|
||||
*/
|
||||
openPanel() {
|
||||
this.elements.panel.classList.remove('hidden');
|
||||
this.elements.overlay.classList.remove('hidden');
|
||||
this.showList();
|
||||
this.loadRecordings();
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the recordings panel
|
||||
*/
|
||||
closePanel() {
|
||||
this.elements.panel.classList.add('hidden');
|
||||
this.elements.overlay.classList.add('hidden');
|
||||
this.currentRecording = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the recordings list view
|
||||
*/
|
||||
showList() {
|
||||
this.elements.recordingsList.classList.remove('hidden');
|
||||
this.elements.recordingViewer.classList.add('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the recording viewer
|
||||
*/
|
||||
showViewer() {
|
||||
this.elements.recordingsList.classList.add('hidden');
|
||||
this.elements.recordingViewer.classList.remove('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load recordings from the API
|
||||
*/
|
||||
async loadRecordings() {
|
||||
this.elements.recordingsList.innerHTML = '<p class="recordings-empty">Loading recordings...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recordings');
|
||||
if (!response.ok) throw new Error('Failed to load recordings');
|
||||
|
||||
this.recordings = await response.json();
|
||||
this.renderRecordingsList();
|
||||
} catch (error) {
|
||||
console.error('Error loading recordings:', error);
|
||||
this.elements.recordingsList.innerHTML =
|
||||
'<p class="recordings-empty">Failed to load recordings</p>';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the recordings list
|
||||
*/
|
||||
renderRecordingsList() {
|
||||
if (this.recordings.length === 0) {
|
||||
this.elements.recordingsList.innerHTML =
|
||||
'<p class="recordings-empty">No recordings yet.<br>Enable auto-save and record some captions!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.recordings.map(recording => `
|
||||
<div class="recording-item" data-filename="${recording.filename}">
|
||||
<div class="recording-info">
|
||||
<span class="recording-date">${recording.date}</span>
|
||||
<span class="recording-meta">${this.formatFileSize(recording.size)}</span>
|
||||
</div>
|
||||
<span class="recording-arrow">›</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
this.elements.recordingsList.innerHTML = html;
|
||||
|
||||
// Bind click events to items
|
||||
this.elements.recordingsList.querySelectorAll('.recording-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const filename = item.dataset.filename;
|
||||
this.viewRecording(filename);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
/**
|
||||
* View a specific recording
|
||||
*/
|
||||
async viewRecording(filename) {
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${encodeURIComponent(filename)}`);
|
||||
if (!response.ok) throw new Error('Failed to load recording');
|
||||
|
||||
const data = await response.json();
|
||||
this.currentRecording = filename;
|
||||
this.elements.viewerFilename.textContent = filename;
|
||||
this.elements.viewerContent.textContent = data.content;
|
||||
this.showViewer();
|
||||
} catch (error) {
|
||||
console.error('Error loading recording:', error);
|
||||
alert('Failed to load recording');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the currently viewed recording
|
||||
*/
|
||||
async deleteCurrentRecording() {
|
||||
if (!this.currentRecording) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this recording?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${encodeURIComponent(this.currentRecording)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete recording');
|
||||
|
||||
// Remove from local list
|
||||
this.recordings = this.recordings.filter(r => r.filename !== this.currentRecording);
|
||||
this.currentRecording = null;
|
||||
|
||||
// Go back to list
|
||||
this.showList();
|
||||
this.renderRecordingsList();
|
||||
} catch (error) {
|
||||
console.error('Error deleting recording:', error);
|
||||
alert('Failed to delete recording');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Recordings.init();
|
||||
});
|
||||
259
static/js/settings.js
Normal file
259
static/js/settings.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Settings Panel Module
|
||||
* Handles user settings UI and persistence
|
||||
*/
|
||||
|
||||
const Settings = {
|
||||
// Current settings state
|
||||
current: {},
|
||||
|
||||
// DOM elements
|
||||
elements: {},
|
||||
|
||||
/**
|
||||
* Initialize the settings module
|
||||
*/
|
||||
init() {
|
||||
this.cacheElements();
|
||||
this.bindEvents();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache DOM element references
|
||||
*/
|
||||
cacheElements() {
|
||||
this.elements = {
|
||||
panel: document.getElementById('settings-panel'),
|
||||
overlay: document.getElementById('overlay'),
|
||||
btnSettings: document.getElementById('btn-settings'),
|
||||
btnClose: document.getElementById('btn-close-settings'),
|
||||
btnSave: document.getElementById('btn-save-settings'),
|
||||
btnReset: document.getElementById('btn-reset-settings'),
|
||||
|
||||
// Text settings
|
||||
fontFamily: document.getElementById('font-family'),
|
||||
fontSize: document.getElementById('font-size'),
|
||||
fontSizeValue: document.getElementById('font-size-value'),
|
||||
fontWeight: document.getElementById('font-weight'),
|
||||
textColor: document.getElementById('text-color'),
|
||||
textAlign: document.getElementById('text-align'),
|
||||
|
||||
// Background settings
|
||||
backgroundColor: document.getElementById('background-color'),
|
||||
backgroundOpacity: document.getElementById('background-opacity'),
|
||||
opacityValue: document.getElementById('opacity-value'),
|
||||
borderRadius: document.getElementById('border-radius'),
|
||||
radiusValue: document.getElementById('radius-value'),
|
||||
padding: document.getElementById('padding'),
|
||||
paddingValue: document.getElementById('padding-value'),
|
||||
|
||||
// Behavior settings
|
||||
maxWords: document.getElementById('max-words'),
|
||||
maxWordsValue: document.getElementById('max-words-value'),
|
||||
|
||||
// Caption display
|
||||
captionContainer: document.getElementById('caption-container'),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
bindEvents() {
|
||||
// Panel open/close
|
||||
this.elements.btnSettings.addEventListener('click', () => this.openPanel());
|
||||
this.elements.btnClose.addEventListener('click', () => this.closePanel());
|
||||
this.elements.overlay.addEventListener('click', () => this.closePanel());
|
||||
|
||||
// Save/Reset
|
||||
this.elements.btnSave.addEventListener('click', () => this.saveSettings());
|
||||
this.elements.btnReset.addEventListener('click', () => this.resetSettings());
|
||||
|
||||
// Live preview on input change
|
||||
const inputs = [
|
||||
'fontFamily', 'fontSize', 'fontWeight', 'textColor', 'textAlign',
|
||||
'backgroundColor', 'backgroundOpacity', 'borderRadius', 'padding',
|
||||
'maxWords'
|
||||
];
|
||||
|
||||
inputs.forEach(name => {
|
||||
const element = this.elements[name];
|
||||
if (element) {
|
||||
element.addEventListener('input', () => this.updatePreview());
|
||||
}
|
||||
});
|
||||
|
||||
// Update value displays for range inputs
|
||||
this.elements.fontSize.addEventListener('input', (e) => {
|
||||
this.elements.fontSizeValue.textContent = e.target.value;
|
||||
});
|
||||
this.elements.backgroundOpacity.addEventListener('input', (e) => {
|
||||
this.elements.opacityValue.textContent = e.target.value;
|
||||
});
|
||||
this.elements.borderRadius.addEventListener('input', (e) => {
|
||||
this.elements.radiusValue.textContent = e.target.value;
|
||||
});
|
||||
this.elements.padding.addEventListener('input', (e) => {
|
||||
this.elements.paddingValue.textContent = e.target.value;
|
||||
});
|
||||
this.elements.maxWords.addEventListener('input', (e) => {
|
||||
this.elements.maxWordsValue.textContent = e.target.value;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Open settings panel
|
||||
*/
|
||||
openPanel() {
|
||||
this.elements.panel.classList.remove('hidden');
|
||||
this.elements.overlay.classList.remove('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Close settings panel
|
||||
*/
|
||||
closePanel() {
|
||||
this.elements.panel.classList.add('hidden');
|
||||
this.elements.overlay.classList.add('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply settings to the UI
|
||||
*/
|
||||
applySettings(settings) {
|
||||
this.current = settings;
|
||||
|
||||
// Update form values
|
||||
this.elements.fontFamily.value = settings.font_family;
|
||||
this.elements.fontSize.value = settings.font_size;
|
||||
this.elements.fontSizeValue.textContent = settings.font_size;
|
||||
this.elements.fontWeight.value = settings.font_weight;
|
||||
this.elements.textColor.value = settings.text_color;
|
||||
this.elements.textAlign.value = settings.text_align;
|
||||
|
||||
this.elements.backgroundColor.value = settings.background_color;
|
||||
this.elements.backgroundOpacity.value = Math.round(settings.background_opacity * 100);
|
||||
this.elements.opacityValue.textContent = Math.round(settings.background_opacity * 100);
|
||||
this.elements.borderRadius.value = settings.border_radius;
|
||||
this.elements.radiusValue.textContent = settings.border_radius;
|
||||
this.elements.padding.value = settings.padding;
|
||||
this.elements.paddingValue.textContent = settings.padding;
|
||||
|
||||
this.elements.maxWords.value = settings.max_words || 30;
|
||||
this.elements.maxWordsValue.textContent = settings.max_words || 30;
|
||||
|
||||
// Apply to caption container
|
||||
this.updatePreview();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update live preview of caption styling
|
||||
*/
|
||||
updatePreview() {
|
||||
const container = this.elements.captionContainer;
|
||||
const opacity = this.elements.backgroundOpacity.value / 100;
|
||||
|
||||
// Parse background color and apply opacity
|
||||
const bgColor = this.elements.backgroundColor.value;
|
||||
const r = parseInt(bgColor.slice(1, 3), 16);
|
||||
const g = parseInt(bgColor.slice(3, 5), 16);
|
||||
const b = parseInt(bgColor.slice(5, 7), 16);
|
||||
|
||||
container.style.fontFamily = this.elements.fontFamily.value;
|
||||
container.style.fontSize = `${this.elements.fontSize.value}px`;
|
||||
container.style.fontWeight = this.elements.fontWeight.value;
|
||||
container.style.color = this.elements.textColor.value;
|
||||
container.style.textAlign = this.elements.textAlign.value;
|
||||
container.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
container.style.borderRadius = `${this.elements.borderRadius.value}px`;
|
||||
container.style.padding = `${this.elements.padding.value}px`;
|
||||
|
||||
// Store max words for caption management
|
||||
this.current.max_words = parseInt(this.elements.maxWords.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current form values as settings object
|
||||
*/
|
||||
getFormValues() {
|
||||
return {
|
||||
font_family: this.elements.fontFamily.value,
|
||||
font_size: parseInt(this.elements.fontSize.value),
|
||||
font_weight: this.elements.fontWeight.value,
|
||||
text_color: this.elements.textColor.value,
|
||||
text_align: this.elements.textAlign.value,
|
||||
background_color: this.elements.backgroundColor.value,
|
||||
background_opacity: this.elements.backgroundOpacity.value / 100,
|
||||
border_radius: parseInt(this.elements.borderRadius.value),
|
||||
padding: parseInt(this.elements.padding.value),
|
||||
max_words: parseInt(this.elements.maxWords.value),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Save settings to server
|
||||
*/
|
||||
async saveSettings() {
|
||||
const settings = this.getFormValues();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.current = await response.json();
|
||||
this.closePanel();
|
||||
console.log('Settings saved');
|
||||
} else {
|
||||
console.error('Failed to save settings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
async resetSettings() {
|
||||
if (!confirm('Reset all settings to defaults?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
this.applySettings(settings);
|
||||
console.log('Settings reset to defaults');
|
||||
} else {
|
||||
console.error('Failed to reset settings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resetting settings:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch settings from server
|
||||
*/
|
||||
async fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
this.applySettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user