Added live captions for a browser source

This commit is contained in:
bunker-admin 2026-01-14 14:41:24 -07:00
parent f97d3a44f9
commit 1cc30b80ad
6 changed files with 780 additions and 93 deletions

32
app.py
View File

@ -161,6 +161,38 @@ def handle_audio_data(data):
emit('error', {'message': 'Failed to process audio'})
@socketio.on('desktop_audio_data')
def handle_desktop_audio_data(data):
"""
Handle incoming desktop audio data from client.
Args:
data: Dictionary containing 'audio' (base64 or bytes) and 'format'
"""
try:
audio_bytes = data.get('audio')
audio_format = data.get('format', 'webm')
if not audio_bytes:
return
# Handle base64 encoded audio
if isinstance(audio_bytes, str):
import base64
audio_bytes = base64.b64decode(audio_bytes)
# Transcribe audio
text = transcriber.transcribe_audio(audio_bytes, format=audio_format)
if text:
logger.info(f"Desktop transcription: {text}")
emit('desktop_transcription', {'text': text})
except Exception as e:
logger.error(f"Error processing desktop audio: {e}")
emit('error', {'message': 'Failed to process desktop audio'})
@socketio.on('save_recording')
def handle_save_recording(data):
"""Handle saving a recording session."""

View File

@ -8,6 +8,7 @@ from datetime import datetime
# Default settings
DEFAULT_SETTINGS = {
# Microphone caption settings
'font_family': 'Arial, sans-serif',
'font_size': 32,
'font_weight': 'normal',
@ -18,6 +19,17 @@ DEFAULT_SETTINGS = {
'text_align': 'center',
'padding': 20,
'border_radius': 10,
# Desktop audio caption settings
'desktop_font_family': 'Arial, sans-serif',
'desktop_font_size': 28,
'desktop_font_weight': 'normal',
'desktop_text_color': '#90EE90',
'desktop_background_color': '#1a2e1a',
'desktop_background_opacity': 0.9,
'desktop_max_words': 30,
'desktop_text_align': 'center',
'desktop_padding': 20,
'desktop_border_radius': 10,
}
@ -57,6 +69,24 @@ def init_db():
cursor.execute('ALTER TABLE user_settings ADD COLUMN max_words INTEGER DEFAULT 30')
conn.commit()
# Add desktop audio columns if they don't exist
if 'desktop_font_family' not in columns:
desktop_columns = [
('desktop_font_family', 'TEXT', "'Arial, sans-serif'"),
('desktop_font_size', 'INTEGER', '28'),
('desktop_font_weight', 'TEXT', "'normal'"),
('desktop_text_color', 'TEXT', "'#90EE90'"),
('desktop_background_color', 'TEXT', "'#1a2e1a'"),
('desktop_background_opacity', 'REAL', '0.9'),
('desktop_max_words', 'INTEGER', '30'),
('desktop_text_align', 'TEXT', "'center'"),
('desktop_padding', 'INTEGER', '20'),
('desktop_border_radius', 'INTEGER', '10'),
]
for col_name, col_type, default in desktop_columns:
cursor.execute(f'ALTER TABLE user_settings ADD COLUMN {col_name} {col_type} DEFAULT {default}')
conn.commit()
# Remove old columns that are no longer needed (fade_delay, max_lines)
# SQLite doesn't support DROP COLUMN easily, so we just ignore old columns
else:
@ -74,6 +104,16 @@ def init_db():
text_align TEXT DEFAULT 'center',
padding INTEGER DEFAULT 20,
border_radius INTEGER DEFAULT 10,
desktop_font_family TEXT DEFAULT 'Arial, sans-serif',
desktop_font_size INTEGER DEFAULT 28,
desktop_font_weight TEXT DEFAULT 'normal',
desktop_text_color TEXT DEFAULT '#90EE90',
desktop_background_color TEXT DEFAULT '#1a2e1a',
desktop_background_opacity REAL DEFAULT 0.9,
desktop_max_words INTEGER DEFAULT 30,
desktop_text_align TEXT DEFAULT 'center',
desktop_padding INTEGER DEFAULT 20,
desktop_border_radius INTEGER DEFAULT 10,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)

View File

@ -39,28 +39,71 @@ html, body {
padding: 20px;
}
/* Caption Container */
#caption-container {
/* Captions Wrapper */
#captions-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
min-height: 0;
}
/* Caption Area (shared by mic and desktop) */
.caption-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
background-color: rgba(26, 26, 46, 0.9);
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
overflow: hidden;
font-size: 32px;
font-family: Arial, sans-serif;
text-align: center;
min-height: 100px;
}
#captions {
.caption-area.hidden {
display: none;
}
/* Caption Label with Status */
.caption-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--bg-tertiary);
flex-shrink: 0;
}
.caption-label .dot {
width: 8px;
height: 8px;
}
/* Caption Text */
#captions,
#desktop-captions {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.4;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Desktop Caption Container Default Styling */
#desktop-caption-container {
background-color: rgba(26, 46, 26, 0.9);
color: #90EE90;
}
/* Controls Bar */
#controls {
display: flex;
@ -131,6 +174,16 @@ html, body {
background-color: #5aff9a;
}
/* Desktop Toggle Button Active State */
#btn-desktop-toggle.active {
background-color: var(--success);
color: #000;
}
#btn-desktop-toggle.active:hover:not(:disabled) {
background-color: #5aff9a;
}
/* Toggle Switch */
.toggle-switch {
display: flex;
@ -296,6 +349,52 @@ html, body {
padding: 20px;
}
/* Settings Sections (Collapsible) */
.settings-section {
margin-bottom: 10px;
}
.section-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 15px;
background-color: var(--bg-tertiary);
border: none;
border-radius: var(--border-radius);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color var(--transition);
}
.section-toggle:hover {
background-color: #323258;
}
.section-toggle.active {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.toggle-icon {
font-size: 10px;
color: var(--accent);
}
.section-content {
padding: 15px;
background-color: var(--bg-tertiary);
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
.section-content.hidden {
display: none;
}
/* Settings Groups */
.setting-group {
margin-bottom: 25px;

View File

@ -7,21 +7,34 @@ const App = {
// WebSocket connection
socket: null,
// Audio recording
// Mic audio recording
mediaRecorder: null,
audioStream: null,
audioChunks: [],
isRecording: false,
recordingInterval: null,
// Continuous caption stream
// Mic caption stream
wordBuffer: [],
pendingWords: [],
wordAnimationTimer: null,
// Browser tab audio recording
desktopMediaRecorder: null,
desktopStream: null,
desktopAudioChunks: [],
isDesktopCapturing: false,
desktopRecordingInterval: null,
// Browser tab caption stream
desktopWordBuffer: [],
desktopPendingWords: [],
desktopWordAnimationTimer: null,
// Auto-save recording state
sessionStartTime: null,
sessionTranscript: [],
desktopSessionTranscript: [],
// DOM elements
elements: {},
@ -43,13 +56,20 @@ const App = {
*/
cacheElements() {
this.elements = {
// Mic controls
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'),
micStatusDot: document.getElementById('mic-status-dot'),
micStatusText: document.getElementById('mic-status-text'),
// Desktop controls
btnDesktopToggle: document.getElementById('btn-desktop-toggle'),
desktopCaptions: document.getElementById('desktop-captions'),
desktopCaptionContainer: document.getElementById('desktop-caption-container'),
desktopStatusDot: document.getElementById('desktop-status-dot'),
desktopStatusText: document.getElementById('desktop-status-text'),
};
},
@ -57,10 +77,14 @@ const App = {
* Bind event listeners
*/
bindEvents() {
// Mic controls
this.elements.btnStart.addEventListener('click', () => this.startRecording());
this.elements.btnStop.addEventListener('click', () => this.stopRecording());
this.elements.btnClear.addEventListener('click', () => this.clearCaptions());
// Desktop toggle
this.elements.btnDesktopToggle.addEventListener('click', () => this.toggleDesktopCapture());
// Load auto-save preference from localStorage
const savedPref = localStorage.getItem('autoSaveEnabled');
if (savedPref === 'true') {
@ -81,18 +105,22 @@ const App = {
this.socket.on('connect', () => {
console.log('Connected to server');
this.setStatus('connected', 'Connected');
this.setMicStatus('connected', 'Ready');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.setStatus('disconnected', 'Disconnected');
this.setMicStatus('disconnected', 'Disconnected');
});
this.socket.on('transcription', (data) => {
this.addWords(data.text);
});
this.socket.on('desktop_transcription', (data) => {
this.addDesktopWords(data.text);
});
this.socket.on('settings_updated', (settings) => {
Settings.applySettings(settings);
});
@ -111,15 +139,23 @@ const App = {
},
/**
* Update status indicator
* Update mic status indicator
*/
setStatus(state, text) {
this.elements.statusDot.className = `dot ${state}`;
this.elements.statusText.textContent = text;
setMicStatus(state, text) {
this.elements.micStatusDot.className = `dot ${state}`;
this.elements.micStatusText.textContent = text;
},
/**
* Start audio recording
* Update desktop status indicator
*/
setDesktopStatus(state, text) {
this.elements.desktopStatusDot.className = `dot ${state}`;
this.elements.desktopStatusText.textContent = text;
},
/**
* Start mic audio recording
*/
async startRecording() {
try {
@ -134,7 +170,7 @@ const App = {
this.isRecording = true;
this.elements.btnStart.disabled = true;
this.elements.btnStop.disabled = false;
this.setStatus('recording', 'Recording...');
this.setMicStatus('recording', 'Recording...');
// Reset session transcript for auto-save
this.sessionStartTime = new Date();
@ -145,7 +181,7 @@ const App = {
} catch (error) {
console.error('Error starting recording:', error);
this.setStatus('error', 'Microphone access denied');
this.setMicStatus('error', 'Microphone access denied');
}
},
@ -197,7 +233,7 @@ const App = {
},
/**
* Stop audio recording
* Stop mic audio recording
*/
stopRecording() {
this.isRecording = false;
@ -221,7 +257,7 @@ const App = {
this.elements.btnStart.disabled = false;
this.elements.btnStop.disabled = true;
this.setStatus('connected', 'Connected');
this.setMicStatus('connected', 'Ready');
// Auto-save if enabled and we have content
if (this.elements.autoSaveToggle.checked && this.sessionTranscript.length > 0) {
@ -314,10 +350,10 @@ const App = {
},
/**
* Clear all captions
* Clear all captions (both mic and desktop)
*/
clearCaptions() {
// Clear animation timer
// Clear mic animation timer
if (this.wordAnimationTimer) {
clearTimeout(this.wordAnimationTimer);
this.wordAnimationTimer = null;
@ -325,6 +361,257 @@ const App = {
this.wordBuffer = [];
this.pendingWords = [];
this.elements.captions.textContent = '';
// Clear browser tab animation timer
if (this.desktopWordAnimationTimer) {
clearTimeout(this.desktopWordAnimationTimer);
this.desktopWordAnimationTimer = null;
}
this.desktopWordBuffer = [];
this.desktopPendingWords = [];
this.elements.desktopCaptions.textContent = '';
},
// =========================================================================
// Browser Tab Audio Capture
// =========================================================================
/**
* Toggle browser tab audio capture on/off
*/
async toggleDesktopCapture() {
if (this.isDesktopCapturing) {
this.stopDesktopCapture();
} else {
await this.startDesktopCapture();
}
},
/**
* Start browser tab audio capture via getDisplayMedia
*/
async startDesktopCapture() {
// Check browser support
if (!navigator.mediaDevices.getDisplayMedia) {
alert('Browser tab audio capture is not supported in this browser.');
return;
}
try {
// Request browser tab capture with audio (preferCurrentTab helps default to tabs)
this.desktopStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser', // Prefer browser tabs
},
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
preferCurrentTab: false, // Don't default to current tab (we want to pick)
selfBrowserSurface: 'exclude', // Exclude this tab from picker
});
// Check if audio track was granted
const audioTracks = this.desktopStream.getAudioTracks();
if (audioTracks.length === 0) {
alert('No audio track available. Please select a browser tab that has audio playing.');
this.desktopStream.getTracks().forEach(track => track.stop());
this.desktopStream = null;
return;
}
// Stop video track immediately (we only need audio)
this.desktopStream.getVideoTracks().forEach(track => track.stop());
// Handle user stopping share via browser UI
audioTracks[0].addEventListener('ended', () => {
this.handleDesktopShareEnded();
});
this.isDesktopCapturing = true;
this.elements.btnDesktopToggle.classList.add('active');
this.elements.btnDesktopToggle.innerHTML = '<span class="icon">&#127911;</span> Stop Tab';
this.setDesktopStatus('recording', 'Capturing...');
// Show desktop caption container
this.elements.desktopCaptionContainer.classList.remove('hidden');
// Reset desktop session transcript
this.desktopSessionTranscript = [];
// Start recording cycle
this.startDesktopRecordingCycle();
} catch (error) {
console.error('Error starting desktop capture:', error);
if (error.name === 'NotAllowedError') {
// User cancelled the picker
this.setDesktopStatus('', 'Cancelled');
} else {
this.setDesktopStatus('error', 'Failed to start');
}
}
},
/**
* Stop desktop audio capture
*/
stopDesktopCapture() {
this.isDesktopCapturing = false;
// Clear recording interval
if (this.desktopRecordingInterval) {
clearTimeout(this.desktopRecordingInterval);
this.desktopRecordingInterval = null;
}
// Stop media recorder
if (this.desktopMediaRecorder && this.desktopMediaRecorder.state === 'recording') {
this.desktopMediaRecorder.stop();
}
// Stop all tracks
if (this.desktopStream) {
this.desktopStream.getTracks().forEach(track => track.stop());
this.desktopStream = null;
}
this.elements.btnDesktopToggle.classList.remove('active');
this.elements.btnDesktopToggle.innerHTML = '<span class="icon">&#127911;</span> Browser Tab';
this.setDesktopStatus('', 'Stopped');
},
/**
* Handle user clicking "Stop sharing" in browser UI
*/
handleDesktopShareEnded() {
console.log('Browser tab share ended by user');
this.stopDesktopCapture();
},
/**
* Start browser tab recording cycle - record for a duration, then send and restart
*/
startDesktopRecordingCycle() {
if (!this.isDesktopCapturing || !this.desktopStream) return;
const audioTracks = this.desktopStream.getAudioTracks();
if (audioTracks.length === 0) {
this.stopDesktopCapture();
return;
}
// Create audio-only stream from the desktop stream's audio track
const audioOnlyStream = new MediaStream([audioTracks[0]]);
let mimeType = 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
mimeType = 'audio/webm;codecs=opus';
}
this.desktopAudioChunks = [];
this.desktopMediaRecorder = new MediaRecorder(audioOnlyStream, { mimeType });
this.desktopMediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.desktopAudioChunks.push(event.data);
}
};
this.desktopMediaRecorder.onstop = () => {
if (this.desktopAudioChunks.length > 0) {
const blob = new Blob(this.desktopAudioChunks, { type: 'audio/webm' });
this.sendDesktopAudioBlob(blob);
}
// Continue cycle if still capturing
if (this.isDesktopCapturing) {
this.startDesktopRecordingCycle();
}
};
this.desktopMediaRecorder.start();
// Same 1.5s chunk duration as mic
const chunkDuration = 1500;
this.desktopRecordingInterval = setTimeout(() => {
if (this.desktopMediaRecorder && this.desktopMediaRecorder.state === 'recording') {
this.desktopMediaRecorder.stop();
}
}, chunkDuration);
},
/**
* Send browser tab audio blob to server
*/
sendDesktopAudioBlob(blob) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result.split(',')[1];
this.socket.emit('desktop_audio_data', {
audio: base64,
format: 'webm'
});
};
reader.readAsDataURL(blob);
},
/**
* Add words to the browser tab caption stream
*/
addDesktopWords(text) {
if (!text.trim()) return;
const newWords = text.trim().split(/\s+/);
this.desktopPendingWords.push(...newWords);
// Track for auto-save if needed
if (this.isDesktopCapturing) {
this.desktopSessionTranscript.push(...newWords);
}
if (!this.desktopWordAnimationTimer) {
this.animateNextDesktopWord();
}
},
/**
* Animate browser tab words appearing one by one
*/
animateNextDesktopWord() {
if (this.desktopPendingWords.length === 0) {
this.desktopWordAnimationTimer = null;
return;
}
const word = this.desktopPendingWords.shift();
this.desktopWordBuffer.push(word);
// Get max words from desktop settings
const maxWords = Settings.current.desktop_max_words || 30;
while (this.desktopWordBuffer.length > maxWords) {
this.desktopWordBuffer.shift();
}
this.updateDesktopCaptionDisplay();
const baseDelay = 80;
const minDelay = 30;
const delay = this.desktopPendingWords.length > 10 ? minDelay : baseDelay;
this.desktopWordAnimationTimer = setTimeout(() => {
this.animateNextDesktopWord();
}, delay);
},
/**
* Update the browser tab caption display
*/
updateDesktopCaptionDisplay() {
const text = this.desktopWordBuffer.join(' ');
this.elements.desktopCaptions.textContent = text;
},
/**

View File

@ -30,7 +30,7 @@ const Settings = {
btnSave: document.getElementById('btn-save-settings'),
btnReset: document.getElementById('btn-reset-settings'),
// Text settings
// Mic text settings
fontFamily: document.getElementById('font-family'),
fontSize: document.getElementById('font-size'),
fontSizeValue: document.getElementById('font-size-value'),
@ -38,7 +38,7 @@ const Settings = {
textColor: document.getElementById('text-color'),
textAlign: document.getElementById('text-align'),
// Background settings
// Mic background settings
backgroundColor: document.getElementById('background-color'),
backgroundOpacity: document.getElementById('background-opacity'),
opacityValue: document.getElementById('opacity-value'),
@ -47,12 +47,37 @@ const Settings = {
padding: document.getElementById('padding'),
paddingValue: document.getElementById('padding-value'),
// Behavior settings
// Mic behavior settings
maxWords: document.getElementById('max-words'),
maxWordsValue: document.getElementById('max-words-value'),
// Caption display
// Desktop text settings
desktopFontFamily: document.getElementById('desktop-font-family'),
desktopFontSize: document.getElementById('desktop-font-size'),
desktopFontSizeValue: document.getElementById('desktop-font-size-value'),
desktopFontWeight: document.getElementById('desktop-font-weight'),
desktopTextColor: document.getElementById('desktop-text-color'),
desktopTextAlign: document.getElementById('desktop-text-align'),
// Desktop background settings
desktopBackgroundColor: document.getElementById('desktop-background-color'),
desktopBackgroundOpacity: document.getElementById('desktop-background-opacity'),
desktopOpacityValue: document.getElementById('desktop-opacity-value'),
desktopBorderRadius: document.getElementById('desktop-border-radius'),
desktopRadiusValue: document.getElementById('desktop-radius-value'),
desktopPadding: document.getElementById('desktop-padding'),
desktopPaddingValue: document.getElementById('desktop-padding-value'),
// Desktop behavior settings
desktopMaxWords: document.getElementById('desktop-max-words'),
desktopMaxWordsValue: document.getElementById('desktop-max-words-value'),
// Caption displays
captionContainer: document.getElementById('caption-container'),
desktopCaptionContainer: document.getElementById('desktop-caption-container'),
// Section toggles
sectionToggles: document.querySelectorAll('.section-toggle'),
};
},
@ -69,21 +94,40 @@ const Settings = {
this.elements.btnSave.addEventListener('click', () => this.saveSettings());
this.elements.btnReset.addEventListener('click', () => this.resetSettings());
// Live preview on input change
const inputs = [
// Section toggle collapse/expand
this.elements.sectionToggles.forEach(toggle => {
toggle.addEventListener('click', () => this.toggleSection(toggle));
});
// Mic live preview on input change
const micInputs = [
'fontFamily', 'fontSize', 'fontWeight', 'textColor', 'textAlign',
'backgroundColor', 'backgroundOpacity', 'borderRadius', 'padding',
'maxWords'
];
inputs.forEach(name => {
micInputs.forEach(name => {
const element = this.elements[name];
if (element) {
element.addEventListener('input', () => this.updatePreview());
}
});
// Update value displays for range inputs
// Desktop live preview on input change
const desktopInputs = [
'desktopFontFamily', 'desktopFontSize', 'desktopFontWeight', 'desktopTextColor', 'desktopTextAlign',
'desktopBackgroundColor', 'desktopBackgroundOpacity', 'desktopBorderRadius', 'desktopPadding',
'desktopMaxWords'
];
desktopInputs.forEach(name => {
const element = this.elements[name];
if (element) {
element.addEventListener('input', () => this.updateDesktopPreview());
}
});
// Update mic value displays for range inputs
this.elements.fontSize.addEventListener('input', (e) => {
this.elements.fontSizeValue.textContent = e.target.value;
});
@ -99,6 +143,36 @@ const Settings = {
this.elements.maxWords.addEventListener('input', (e) => {
this.elements.maxWordsValue.textContent = e.target.value;
});
// Update desktop value displays for range inputs
this.elements.desktopFontSize.addEventListener('input', (e) => {
this.elements.desktopFontSizeValue.textContent = e.target.value;
});
this.elements.desktopBackgroundOpacity.addEventListener('input', (e) => {
this.elements.desktopOpacityValue.textContent = e.target.value;
});
this.elements.desktopBorderRadius.addEventListener('input', (e) => {
this.elements.desktopRadiusValue.textContent = e.target.value;
});
this.elements.desktopPadding.addEventListener('input', (e) => {
this.elements.desktopPaddingValue.textContent = e.target.value;
});
this.elements.desktopMaxWords.addEventListener('input', (e) => {
this.elements.desktopMaxWordsValue.textContent = e.target.value;
});
},
/**
* Toggle settings section collapse/expand
*/
toggleSection(toggle) {
const sectionId = toggle.dataset.section;
const section = document.getElementById(sectionId);
const icon = toggle.querySelector('.toggle-icon');
section.classList.toggle('hidden');
toggle.classList.toggle('active');
icon.textContent = section.classList.contains('hidden') ? '\u25B6' : '\u25BC';
},
/**
@ -123,7 +197,7 @@ const Settings = {
applySettings(settings) {
this.current = settings;
// Update form values
// Update mic form values
this.elements.fontFamily.value = settings.font_family;
this.elements.fontSize.value = settings.font_size;
this.elements.fontSizeValue.textContent = settings.font_size;
@ -142,12 +216,47 @@ const Settings = {
this.elements.maxWords.value = settings.max_words || 30;
this.elements.maxWordsValue.textContent = settings.max_words || 30;
// Apply to caption container
// Apply to mic caption container
this.updatePreview();
// Apply desktop settings if present
if (settings.desktop_font_family !== undefined) {
this.applyDesktopSettings(settings);
}
},
/**
* Update live preview of caption styling
* Apply desktop settings to the UI
*/
applyDesktopSettings(settings) {
// Update desktop form values
this.elements.desktopFontFamily.value = settings.desktop_font_family;
this.elements.desktopFontSize.value = settings.desktop_font_size;
this.elements.desktopFontSizeValue.textContent = settings.desktop_font_size;
this.elements.desktopFontWeight.value = settings.desktop_font_weight;
this.elements.desktopTextColor.value = settings.desktop_text_color;
this.elements.desktopTextAlign.value = settings.desktop_text_align;
this.elements.desktopBackgroundColor.value = settings.desktop_background_color;
this.elements.desktopBackgroundOpacity.value = Math.round(settings.desktop_background_opacity * 100);
this.elements.desktopOpacityValue.textContent = Math.round(settings.desktop_background_opacity * 100);
this.elements.desktopBorderRadius.value = settings.desktop_border_radius;
this.elements.desktopRadiusValue.textContent = settings.desktop_border_radius;
this.elements.desktopPadding.value = settings.desktop_padding;
this.elements.desktopPaddingValue.textContent = settings.desktop_padding;
this.elements.desktopMaxWords.value = settings.desktop_max_words || 30;
this.elements.desktopMaxWordsValue.textContent = settings.desktop_max_words || 30;
// Store desktop max words
this.current.desktop_max_words = settings.desktop_max_words || 30;
// Apply to desktop caption container
this.updateDesktopPreview();
},
/**
* Update live preview of mic caption styling
*/
updatePreview() {
const container = this.elements.captionContainer;
@ -172,11 +281,40 @@ const Settings = {
this.current.max_words = parseInt(this.elements.maxWords.value);
},
/**
* Update live preview of desktop caption styling
*/
updateDesktopPreview() {
const container = this.elements.desktopCaptionContainer;
if (!container) return;
const opacity = this.elements.desktopBackgroundOpacity.value / 100;
// Parse background color and apply opacity
const bgColor = this.elements.desktopBackgroundColor.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.desktopFontFamily.value;
container.style.fontSize = `${this.elements.desktopFontSize.value}px`;
container.style.fontWeight = this.elements.desktopFontWeight.value;
container.style.color = this.elements.desktopTextColor.value;
container.style.textAlign = this.elements.desktopTextAlign.value;
container.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${opacity})`;
container.style.borderRadius = `${this.elements.desktopBorderRadius.value}px`;
container.style.padding = `${this.elements.desktopPadding.value}px`;
// Store desktop max words for caption management
this.current.desktop_max_words = parseInt(this.elements.desktopMaxWords.value);
},
/**
* Get current form values as settings object
*/
getFormValues() {
return {
// Mic settings
font_family: this.elements.fontFamily.value,
font_size: parseInt(this.elements.fontSize.value),
font_weight: this.elements.fontWeight.value,
@ -187,6 +325,17 @@ const Settings = {
border_radius: parseInt(this.elements.borderRadius.value),
padding: parseInt(this.elements.padding.value),
max_words: parseInt(this.elements.maxWords.value),
// Desktop settings
desktop_font_family: this.elements.desktopFontFamily.value,
desktop_font_size: parseInt(this.elements.desktopFontSize.value),
desktop_font_weight: this.elements.desktopFontWeight.value,
desktop_text_color: this.elements.desktopTextColor.value,
desktop_text_align: this.elements.desktopTextAlign.value,
desktop_background_color: this.elements.desktopBackgroundColor.value,
desktop_background_opacity: this.elements.desktopBackgroundOpacity.value / 100,
desktop_border_radius: parseInt(this.elements.desktopBorderRadius.value),
desktop_padding: parseInt(this.elements.desktopPadding.value),
desktop_max_words: parseInt(this.elements.desktopMaxWords.value),
};
},

View File

@ -8,18 +8,37 @@
</head>
<body>
<div id="app">
<!-- Caption Display Area -->
<div id="caption-container">
<div id="captions"></div>
<!-- Caption Display Areas -->
<div id="captions-wrapper">
<!-- Microphone Caption Display -->
<div id="caption-container" class="caption-area">
<div class="caption-label">
<span id="mic-status-dot" class="dot"></span>
<span id="mic-status-text">Microphone</span>
</div>
<div id="captions"></div>
</div>
<!-- Browser Tab Caption Display (hidden by default) -->
<div id="desktop-caption-container" class="caption-area hidden">
<div class="caption-label">
<span id="desktop-status-dot" class="dot"></span>
<span id="desktop-status-text">Browser Tab</span>
</div>
<div id="desktop-captions"></div>
</div>
</div>
<!-- Controls Bar -->
<div id="controls">
<button id="btn-start" class="btn btn-primary">
<span class="icon">&#9658;</span> Start
<span class="icon">&#9658;</span> Start Mic
</button>
<button id="btn-stop" class="btn btn-danger" disabled>
<span class="icon">&#9632;</span> Stop
<span class="icon">&#9632;</span> Stop Mic
</button>
<button id="btn-desktop-toggle" class="btn btn-secondary" title="Capture audio from a browser tab">
<span class="icon">&#127911;</span> Browser Tab
</button>
<button id="btn-clear" class="btn btn-secondary">
Clear
@ -37,12 +56,6 @@
</button>
</div>
<!-- Status Indicator -->
<div id="status">
<span id="status-dot" class="dot"></span>
<span id="status-text">Ready</span>
</div>
<!-- Settings Panel -->
<div id="settings-panel" class="panel hidden">
<div class="panel-header">
@ -50,72 +63,139 @@
<button id="btn-close-settings" class="btn-close">&times;</button>
</div>
<div class="panel-content">
<!-- Font Settings -->
<div class="setting-group">
<h3>Text</h3>
<!-- Microphone Settings Section -->
<div class="settings-section">
<button class="section-toggle active" data-section="mic-settings">
<span class="toggle-icon">&#9660;</span> Microphone Captions
</button>
<div id="mic-settings" class="section-content">
<div class="setting-group">
<h3>Text</h3>
<label for="font-family">Font Family</label>
<select id="font-family">
<option value="Arial, sans-serif">Arial</option>
<option value="'Helvetica Neue', Helvetica, sans-serif">Helvetica</option>
<option value="'Segoe UI', sans-serif">Segoe UI</option>
<option value="'Roboto', sans-serif">Roboto</option>
<option value="'Open Sans', sans-serif">Open Sans</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="monospace">Monospace</option>
</select>
<label for="font-family">Font Family</label>
<select id="font-family">
<option value="Arial, sans-serif">Arial</option>
<option value="'Helvetica Neue', Helvetica, sans-serif">Helvetica</option>
<option value="'Segoe UI', sans-serif">Segoe UI</option>
<option value="'Roboto', sans-serif">Roboto</option>
<option value="'Open Sans', sans-serif">Open Sans</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="monospace">Monospace</option>
</select>
<label for="font-size">Font Size: <span id="font-size-value">32</span>px</label>
<input type="range" id="font-size" min="16" max="72" value="32">
<label for="font-size">Font Size: <span id="font-size-value">32</span>px</label>
<input type="range" id="font-size" min="16" max="72" value="32">
<label for="font-weight">Font Weight</label>
<select id="font-weight">
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="lighter">Light</option>
</select>
<label for="font-weight">Font Weight</label>
<select id="font-weight">
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="lighter">Light</option>
</select>
<label for="text-color">Text Color</label>
<input type="color" id="text-color" value="#ffffff">
<label for="text-color">Text Color</label>
<input type="color" id="text-color" value="#ffffff">
<label for="text-align">Text Alignment</label>
<select id="text-align">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<label for="text-align">Text Alignment</label>
<select id="text-align">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
<div class="setting-group">
<h3>Background</h3>
<label for="background-color">Background Color</label>
<input type="color" id="background-color" value="#1a1a2e">
<label for="background-opacity">Opacity: <span id="opacity-value">90</span>%</label>
<input type="range" id="background-opacity" min="0" max="100" value="90">
<label for="border-radius">Corner Radius: <span id="radius-value">10</span>px</label>
<input type="range" id="border-radius" min="0" max="30" value="10">
<label for="padding">Padding: <span id="padding-value">20</span>px</label>
<input type="range" id="padding" min="5" max="50" value="20">
</div>
<div class="setting-group">
<h3>Behavior</h3>
<label for="max-words">Max Words: <span id="max-words-value">30</span></label>
<input type="range" id="max-words" min="1" max="100" value="30">
</div>
</div>
</div>
<!-- Background Settings -->
<div class="setting-group">
<h3>Background</h3>
<!-- Browser Tab Settings Section -->
<div class="settings-section">
<button class="section-toggle" data-section="desktop-settings">
<span class="toggle-icon">&#9654;</span> Browser Tab Captions
</button>
<div id="desktop-settings" class="section-content hidden">
<div class="setting-group">
<h3>Text</h3>
<label for="desktop-font-family">Font Family</label>
<select id="desktop-font-family">
<option value="Arial, sans-serif">Arial</option>
<option value="'Helvetica Neue', Helvetica, sans-serif">Helvetica</option>
<option value="'Segoe UI', sans-serif">Segoe UI</option>
<option value="'Roboto', sans-serif">Roboto</option>
<option value="'Open Sans', sans-serif">Open Sans</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="monospace">Monospace</option>
</select>
<label for="background-color">Background Color</label>
<input type="color" id="background-color" value="#1a1a2e">
<label for="desktop-font-size">Font Size: <span id="desktop-font-size-value">28</span>px</label>
<input type="range" id="desktop-font-size" min="16" max="72" value="28">
<label for="background-opacity">Opacity: <span id="opacity-value">90</span>%</label>
<input type="range" id="background-opacity" min="0" max="100" value="90">
<label for="desktop-font-weight">Font Weight</label>
<select id="desktop-font-weight">
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="lighter">Light</option>
</select>
<label for="border-radius">Corner Radius: <span id="radius-value">10</span>px</label>
<input type="range" id="border-radius" min="0" max="30" value="10">
<label for="desktop-text-color">Text Color</label>
<input type="color" id="desktop-text-color" value="#90EE90">
<label for="padding">Padding: <span id="padding-value">20</span>px</label>
<input type="range" id="padding" min="5" max="50" value="20">
</div>
<label for="desktop-text-align">Text Alignment</label>
<select id="desktop-text-align">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<!-- Caption Behavior -->
<div class="setting-group">
<h3>Behavior</h3>
<div class="setting-group">
<h3>Background</h3>
<label for="desktop-background-color">Background Color</label>
<input type="color" id="desktop-background-color" value="#1a2e1a">
<label for="max-words">Max Words: <span id="max-words-value">30</span></label>
<input type="range" id="max-words" min="1" max="100" value="30">
<label for="desktop-background-opacity">Opacity: <span id="desktop-opacity-value">90</span>%</label>
<input type="range" id="desktop-background-opacity" min="0" max="100" value="90">
<label for="desktop-border-radius">Corner Radius: <span id="desktop-radius-value">10</span>px</label>
<input type="range" id="desktop-border-radius" min="0" max="30" value="10">
<label for="desktop-padding">Padding: <span id="desktop-padding-value">20</span>px</label>
<input type="range" id="desktop-padding" min="5" max="50" value="20">
</div>
<div class="setting-group">
<h3>Behavior</h3>
<label for="desktop-max-words">Max Words: <span id="desktop-max-words-value">30</span></label>
<input type="range" id="desktop-max-words" min="1" max="100" value="30">
</div>
</div>
</div>
<!-- Actions -->
<div class="setting-actions">
<button id="btn-save-settings" class="btn btn-primary">Save Settings</button>
<button id="btn-save-settings" class="btn btn-primary">Save All Settings</button>
<button id="btn-reset-settings" class="btn btn-secondary">Reset to Defaults</button>
</div>
</div>