File: /home/dfwparty/santadallas.com/wp-content/plugins/ws-form-pro/public/js/external/media-capture.js
/**
* MediaCapture
* A library for injecting video/photo capture functionality into a DOM element
*/
(function(window) {
'use strict';
/**
* Get complete default configuration object
* @returns {Object} Default configuration with all settings, labels, icons, and callbacks
* @private
*/
function get_default_config() {
return Object.assign({},
get_default_capture_settings(),
get_default_icons(),
get_default_labels(),
get_default_callbacks()
);
}
/**
* Get default capture settings
* @returns {Object} Default capture settings
* @private
*/
function get_default_capture_settings() {
return {
mode: 'video',
facing_mode: 'user',
max_duration: 30,
video_format: 'mp4',
resolution: '',
audio_format: 'webm',
photo_format: 'jpeg',
photo_quality: 0.9,
photo_sound_enabled: false,
photo_sound_url: '',
countdown_enabled: false,
countdown_duration: 3,
countdown_sound_enabled: false,
countdown_sound_url: '',
auto_start: false,
controls_auto_hide: false,
controls_auto_hide_duration: 3000,
max_width: '',
settings_download_enabled: false,
log: false,
log_to_console: true,
qr_scan_enabled: false,
qr_scan_interval: 200,
qr_scan_pause_on_detect: true,
qr_scan_beep: false,
qr_scan_auto: false,
qr_scan_overlay_enabled: true
};
}
/**
* Get default label strings
* @returns {Object} Default label strings
* @private
*/
function get_default_labels() {
return {
label_cancel_retake: 'Cancel',
label_capture_photo: 'Take Photo',
label_close_settings: 'Close Settings',
label_error_camera: 'Unable to access camera. Please check permissions.',
label_error_camera_microphone: 'Unable to access camera and microphone. Please check permissions.',
label_error_microphone: 'Unable to access microphone. Please check permissions.',
label_flip_camera: 'Flip Camera',
label_mic_active: 'Microphone Active',
label_mic_muted: 'Microphone Muted',
label_pause: 'Pause',
label_play: 'Play',
label_progress: 'Progress',
label_resume: 'Resume',
label_retake: 'Retake Photo',
label_settings: 'Settings',
label_settings_audio_input: 'Audio Input',
label_settings_back: 'Back',
label_settings_download: 'Download',
label_settings_video_input: 'Video Input',
label_start_camera: 'Click to Start Camera',
label_start_microphone: 'Click to Start Microphone',
label_start_recording: 'Start Recording',
label_stop: 'Stop',
label_volume: 'Volume',
label_volume_muted: 'Volume Muted',
label_qr_scanning: 'Scanning for QR code'
};
}
/**
* Get default SVG icons
* @returns {Object} Default SVG icon strings
* @private
*/
function get_default_icons() {
return {
icon_camera: '<svg viewBox="0 0 20 20"><path d="M17,4.3h-2.3l-1.8-2.2c-.2-.3-.5-.4-.8-.4h-4.3c-.3,0-.6.2-.8.4l-1.7,2.2h-2.3c-1.7,0-3,1.3-3,3v8c0,1.7,1.3,3,3,3h14c1.7,0,3-1.3,3-3V7.3c0-1.7-1.3-3-3-3ZM10,14.8c-2.8,0-5-2.2-5-5s2.2-5,5-5,5,2.2,5,5-2.2,5-5,5ZM10,6.8c-1.7,0-3,1.3-3,3s1.3,3,3,3,3-1.3,3-3-1.3-3-3-3Z"/></svg>',
icon_checkmark: '<svg viewBox="0 0 20 20"><path d="M7.3,14.2l-4.8-4.8c-0.4-0.4-1-0.4-1.4,0s-0.4,1,0,1.4l5.5,5.5c0.4,0.4,1,0.4,1.4,0l11-11c0.4-0.4,0.4-1,0-1.4s-1-0.4-1.4,0 L7.3,14.2z"/></svg>',
icon_chevron_right: '<svg viewBox="0 0 20 20"><path d="M7.5,2.5c-0.4-0.4-1-0.4-1.4,0s-0.4,1,0,1.4L13.4,10l-7.3,6.1c-0.4,0.4-0.4,1,0,1.4c0.2,0.2,0.5,0.3,0.7,0.3s0.5-0.1,0.7-0.3 l8-7c0.4-0.4,0.4-1,0-1.4L7.5,2.5z"/></svg>',
icon_close: '<svg viewBox="0 0 20 20"><path d="M20,2l-2-2-8,8L2,0,0,2l8,8L0,18l2,2,8-8,8,8,2-2-8-8,8-8Z"/></svg>',
icon_download: '<svg viewBox="0 0 20 20"><path d="M11.4 0c.2 0 .5.3.5.5v8.3h2.6s.2 0 .3.1c.3.2.3.6.1.9-1.5 1.6-2.8 3.4-4.3 4.9s-.7.5-1.2 0c-1.5-1.5-2.8-3.4-4.3-4.9-.2-.3-.1-.6.1-.9s.2-.1.3-.1h2.6V.5c0-.2.3-.4.5-.5h2.8Z"/><path d="M.6 13.8h2.5v3.8h13.8v-3.8h2.5V19c-.1.5-.5.9-1 1H1.6c-.5-.1-.9-.5-1-1v-5.2Z"/></svg>',
icon_error: '<svg viewBox="0 0 20 20"><path d="M10,0C4.5,0,0,4.5,0,10s4.5,10,10,10,10-4.5,10-10S15.5,0,10,0ZM11,15H9v-2h2v2ZM11,11H9V5h2v6Z"/></svg>',
icon_flip_camera: '<svg viewBox="0 0 20 20"><path d="M14.1 6.6h-1.4l-1.1-1.3c-.1-.2-.3-.2-.5-.2H8.6c-.2 0-.4.1-.5.2l-1 1.3H5.7c-1 0-1.8.8-1.8 1.8v4.7c0 1 .8 1.8 1.8 1.8H14c1 0 1.8-.8 1.8-1.8V8.4c0-1-.8-1.8-1.8-1.8ZM10 12.8c-1.7 0-2.9-1.3-2.9-2.9S8.4 7 10 7s2.9 1.3 2.9 2.9-1.3 2.9-2.9 2.9Zm0-4.7c-1 0-1.8.8-1.8 1.8s.8 1.8 1.8 1.8 1.8-.8 1.8-1.8S11 8.1 10 8.1Z"/><path d="M7.5.5c-.4.4-1.3 1.2-1.6 1.7s-.2.4.1.6c.7.2 1.4.5 2.1.8s.7.3.8.2c.5 0 0-1.2 0-1.5 3-.6 5.9.9 7.6 3.3s2 6 .4 8.5-.9 1-.3 1.4 1-.7 1.2-1c3.3-6-1.2-13.4-8-13.2s-.8 0-1.2.1c-.1 0-.3-1.2-.5-1.3s-.5.3-.6.5ZM4.5 15.9C1.9 13.5 1.1 9.3 3 6.2s.9-.9.5-1.4-1 .3-1.2.6c-3.2 5.4 0 12.3 6 13.4s2.1.2 3.1 0c.1 0 .2 1.1.5 1.2s.6-.4.7-.5c.5-.5.9-1.1 1.5-1.6.1-.2.2-.4 0-.5s-1.4-.6-1.8-.7-1-.4-1.2-.4-.2 0-.2.1c-.2.3.2 1.1.2 1.4-.7.2-1.6.2-2.4 0-1.6-.2-3.1-1-4.3-2.1Z"/></svg>',
icon_mic: '<svg viewBox="0 0 14.7 20"><path d="M7.4,12.6c1.7,0,3.2-1.4,3.2-3.2V3.2c0-1.7-1.4-3.2-3.2-3.2s-3.2,1.4-3.2,3.2v6.3c0,1.7,1.4,3.2,3.2,3.2ZM12.6,9.5c0,2.9-2.4,5.3-5.3,5.3s-5.3-2.4-5.3-5.3H0c0,3.7,2.7,6.8,6.3,7.3v3.2h2.1v-3.2c3.6-.5,6.3-3.6,6.3-7.3h-2.1Z"/></svg>',
icon_mic_muted: '<svg viewBox="0 0 14.7 20"><path d="M14.7,9.5h-1.8c0,.8-.2,1.5-.5,2.2l1.3,1.3c.6-1,.9-2.2,.9-3.5ZM10.5,9.7c0,0,0-.1,0-.2V3.2C10.5,1.4,9.1,0,7.4,0s-3.2,1.4-3.2,3.2v.2l6.3,6.3ZM-.8,1.1l-1.3,1.3,6.3,6.3v.8c0,1.7,1.4,3.2,3.1,3.2s.5,0,.7,0l1.7,1.7c-.7.3-1.6.5-2.4.5-2.9,0-5.6-2.2-5.6-5.4H0C0,13.1,2.9,16,6.3,16.5v3.5h2.1v-3.5c1-.1,1.9-.5,2.7-.9l4.4,4.4,1.3-1.3L-.8,1.1Z"/></svg>',
icon_pause: '<svg viewBox="0 0 20 20"><path d="M2.5,0h5v20H2.5V0ZM12.5,0h5v20h-5V0Z"/></svg>',
icon_play: '<svg viewBox="0 0 20 20"><path d="M4.1,0v20l15.7-10L2.1,0Z"/></svg>',
icon_record: '<svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="10"/></svg>',
icon_retake: '<svg viewBox="0 0 20 20"><path d="M20,10.4h-2.1c-.2,6.3-7.9,9.9-12.8,5.9l1.7-2.2h.1c3.2,2.5,7.9.3,8.2-3.7h-2.1l3.5-4.4,3.5,4.4Z"/><path d="M4.9,9.6h2.1l-3.5,4.5L0,9.6h2.1c0-2.7,1.7-5.3,4.1-6.6s5.5-1.3,8,.2.6.3.7.5,0,0,0,0l-1.8,2.2s-.6-.3-.7-.4c-3.3-1.8-7.2.4-7.6,4.1Z"/></svg>',
icon_source_audio: '<svg viewBox="0 0 20 20"><path d="M10,12.6c1.7,0,3.2-1.4,3.2-3.2V3.2c0-1.7-1.4-3.2-3.2-3.2s-3.2,1.4-3.2,3.2v6.3c0,1.7,1.4,3.2,3.2,3.2ZM16.2,9.5c-.5,0-.9.4-1,.9-.4,2.5-2.6,4.4-5.2,4.4s-4.8-1.9-5.2-4.4c0-.5-.5-.9-1-.9s-1.1.6-1.1,1.2c.5,3.2,3,5.6,6.2,6.1v2.2c0,.6.5,1.1,1.1,1.1s1.1-.5,1.1-1.1v-2.2c3.2-.5,5.7-2.9,6.2-6.1.1-.6-.4-1.2-1.1-1.2Z"/></svg>',
icon_source_video: '<svg viewBox="0 0 20 20"><path d="M15.6,8.3v-3.9c0-.6-.5-1.1-1.1-1.1H1.1c-.6,0-1.1.5-1.1,1.1v11.1c0,.6.5,1.1,1.1,1.1h13.3c.6,0,1.1-.5,1.1-1.1v-3.9l4.4,4.4V3.9l-4.4,4.4Z"/></svg>',
icon_settings: '<svg viewBox="0 0 20 20"><path d="M17.43,10.98c0.04-0.32,0.07-0.64,0.07-0.98s-0.03-0.66-0.07-0.98l2.11-1.65c0.19-0.15,0.24-0.42,0.12-0.64l-2-3.46 c-0.12-0.22-0.39-0.3-0.61-0.22l-2.49,1c-0.52-0.4-1.08-0.73-1.69-0.98l-0.38-2.65C12.46,0.18,12.25,0,12,0h-4 c-0.25,0-0.46,0.18-0.49,0.42L7.13,3.07c-0.61,0.25-1.17,0.59-1.69,0.98l-2.49-1c-0.23-0.09-0.49,0-0.61,0.22l-2,3.46 c-0.13,0.22-0.07,0.49,0.12,0.64l2.11,1.65c-0.04,0.32-0.07,0.65-0.07,0.98s0.03,0.66,0.07,0.98l-2.11,1.65 c-0.19,0.15-0.24,0.42-0.12,0.64l2,3.46c0.12,0.22,0.39,0.3,0.61,0.22l2.49-1c0.52,0.4,1.08,0.73,1.69,0.98l0.38,2.65 C7.54,19.82,7.75,20,8,20h4c0.25,0,0.46-0.18,0.49-0.42l0.38-2.65c0.61-0.25,1.17-0.59,1.69-0.98l2.49,1 c0.23,0.09,0.49,0,0.61-0.22l2-3.46c0.12-0.22,0.07-0.49-0.12-0.64L17.43,10.98z M10,13c-1.65,0-3-1.35-3-3s1.35-3,3-3s3,1.35,3,3 S11.65,13,10,13z"/></svg>',
icon_sound: '<svg viewBox="0 0 20 20"><rect x="3.0" y="1.0" width="2.5" height="4" /><rect x="7.5" y="1.0" width="2.5" height="11" /><rect x="12.0" y="1.0" width="2.5" height="10" /></svg>',
icon_stop: '<svg viewBox="0 0 20 20"><rect x="2" y="2" width="16" height="16"/></svg>',
icon_volume: '<svg viewBox="0 0 20 20"><path d="M0,6.7v6.6h4.4l5.5,5.5V1.1l-5.5,5.5H0ZM14.9,10c0-2-1.1-3.6-2.8-4.5v8.9c1.6-.8,2.8-2.5,2.8-4.4ZM12.2.3v2.3c3.2,1,5.5,3.9,5.5,7.4s-2.3,6.5-5.5,7.4v2.3c4.4-1,7.7-5,7.7-9.7S16.6,1.3,12.2.3h0Z"/></svg>',
icon_volume_low: '<svg viewBox="0 0 20 20"><path d="M0,6.7v6.7h4.4l5.6,5.6V1.1l-5.6,5.6H0ZM15,10c0-2-1.1-3.7-2.8-4.5v8.9c1.6-.8,2.8-2.5,2.8-4.5Z"/></svg>',
icon_volume_muted: '<svg viewBox="0 0 20 20"><path d="M15,10c0-2-1.1-3.7-2.8-4.5v2.5l2.7,2.7c0-.2,0-.5,0-.7ZM17.8,10c0,1-.2,2-.6,2.9l1.7,1.7c.7-1.4,1.1-2.9,1.1-4.6,0-4.8-3.3-8.7-7.8-9.7v2.3c3.2,1,5.6,3.9,5.6,7.5ZM1.4,0L0,1.4l5.3,5.3H0v6.7h4.4l5.6,5.6v-7.5l4.7,4.7c-.7.6-1.6,1-2.5,1.3v2.3c1.5-.3,2.9-1.1,4.1-2l2.3,2.3,1.4-1.4-10-10L1.4,0ZM10,1.1l-2.3,2.3,2.3,2.3V1.1Z"/></svg>',
};
}
/**
* Get default callback functions (all null)
* @returns {Object} Default callback configuration
* @private
*/
function get_default_callbacks() {
return {
on_photo_captured: null,
on_photo_retake: null,
on_photo_cancel: null,
on_video_captured: null,
on_video_started: null,
on_video_stopped: null,
on_video_paused: null,
on_video_resumed: null,
on_video_retake: null,
on_video_cancel: null,
on_audio_captured: null,
on_audio_started: null,
on_audio_stopped: null,
on_audio_paused: null,
on_audio_resumed: null,
on_audio_retake: null,
on_audio_cancel: null,
on_error: null,
on_log: null,
on_stream_started: null,
on_stream_stopped: null,
on_stream_flipped: null,
on_settings_opened: null,
on_settings_closed: null,
on_result_show: null,
on_result_hide: null,
on_cameras_detected: null,
on_ui_flip_on: null,
on_ui_flip_off: null,
on_ui_capture_on: null,
on_ui_capture_off: null,
on_ui_retake_on: null,
on_ui_retake_off: null,
on_ui_cancel_on: null,
on_ui_cancel_off: null,
on_ui_settings_on: null,
on_ui_settings_off: null,
on_ui_download_on: null,
on_ui_download_off: null,
on_ui_playback_play_on: null,
on_ui_playback_play_off: null,
on_ui_playback_pause_on: null,
on_ui_playback_pause_off: null,
on_ui_playback_toggle_on: null,
on_ui_playback_toggle_off: null,
on_ui_playback_stop_on: null,
on_ui_playback_stop_off: null,
on_qr_detected: null,
on_qr_scan_start: null,
on_qr_scan_stop: null
};
}
/**
* MediaCapture Constructor
* @param {HTMLElement} element - The DOM element to inject the capture interface into
* @param {Object} options - Configuration options
*/
function MediaCapture(element, options) {
// Validate element
if (!element || typeof element.appendChild !== 'function') {
throw new Error('MediaCapture requires a valid DOM element');
}
// Store references
this.element = element;
// Default configuration
this.config = get_default_config();
// Merge user options with defaults
this.config = Object.assign({}, this.config, options || {});
// Sanitize and validate all config values - revert invalid values to defaults
this.sanitize_config();
// Internal state
this.stream = null;
this.video_element = null;
this.audio_element = null;
this.canvas_element = null;
this.media_recorder = null;
this.recorded_chunks = [];
this.timer_interval = null;
this.is_recording = false;
this.is_initialized = false;
this.blob = null;
this.filename = null;
this.selected_video_device = null;
this.selected_audio_device = null;
this.is_mobile = false;
this.has_multiple_cameras = false;
this.has_checked_cameras = false;
this.has_multiple_microphones = false;
this.has_checked_microphones = false;
this.video_width = null; // parsed from resolution
this.video_height = null; // parsed from resolution
this.qr_scan_interval_id = null;
this.qr_scan_active = false;
this.qr_barcode_detector = null;
// Bind methods to instance
this.init = this.init.bind(this);
this.destroy = this.destroy.bind(this);
this.stream_start = this.stream_start.bind(this);
this.stream_stop = this.stream_stop.bind(this);
this.stream_flip = this.stream_flip.bind(this);
this.photo_capture = this.photo_capture.bind(this);
this.photo_retake = this.photo_retake.bind(this);
this.photo_cancel = this.photo_cancel.bind(this);
this.video_start = this.video_start.bind(this);
this.video_stop = this.video_stop.bind(this);
this.video_pause = this.video_pause.bind(this);
this.video_resume = this.video_resume.bind(this);
this.video_retake = this.video_retake.bind(this);
this.video_cancel = this.video_cancel.bind(this);
this.audio_start = this.audio_start.bind(this);
this.audio_stop = this.audio_stop.bind(this);
this.audio_pause = this.audio_pause.bind(this);
this.audio_resume = this.audio_resume.bind(this);
this.audio_retake = this.audio_retake.bind(this);
this.audio_cancel = this.audio_cancel.bind(this);
this.settings_open = this.settings_open.bind(this);
this.settings_close = this.settings_close.bind(this);
this.show_controls = this.show_controls.bind(this);
this.hide_controls = this.hide_controls.bind(this);
this.get_stream = this.get_stream.bind(this);
this.is_stream_active = this.is_stream_active.bind(this);
this.set_config = this.set_config.bind(this);
this.get_config = this.get_config.bind(this);
// Auto-initialize
this.init();
}
/**
* Initialize the MediaCapture component
* Creates and injects the UI into the target element
* Note: This is called automatically in the constructor.
* You only need to call this manually if you need to reinitialize after destroy().
*/
MediaCapture.prototype.init = function() {
if (this.is_initialized) {
this.log('Init already called - skipping re-initialization');
return;
}
this.log('Initializing MediaCapture with mode: ' + this.config.mode);
// Parse resolution if provided
if (this.config.resolution) {
var resolution_parts = this.config.resolution.split(',');
this.video_width = parseInt(resolution_parts[0], 10) || null;
this.video_height = parseInt(resolution_parts[1], 10) || null;
this.log('Resolution parsed: ' + this.video_width + 'x' + this.video_height);
}
// Build and inject the interface
this.log('Building interface');
this.build_interface();
// Store references to key elements
this.video_element = this.element.querySelector('.mc-video');
this.audio_element = this.element.querySelector('.mc-audio');
this.canvas_element = this.element.querySelector('.mc-canvas');
// Mark as initialized BEFORE init_functionality (which may call stream_start via auto_start)
this.is_initialized = true;
// Initialize functionality and event handlers
this.log('Initializing functionality and event handlers');
this.init_functionality();
// Note: Auto-start is handled in init_functionality
this.log('MediaCapture initialization complete');
};
/**
* Centralized logging method
* @param {string} message - The message to log
* @param {*} args - Additional arguments to log
* @private
*/
MediaCapture.prototype.log = function(message) {
if (!this.config.log) {
return;
}
var level = 'log';
var args = Array.prototype.slice.call(arguments, 1);
// Check if last argument is a log level
if (args.length > 0) {
var last_arg = args[args.length - 1];
if (last_arg === 'warn' || last_arg === 'error' || last_arg === 'log') {
level = last_arg;
args = args.slice(0, -1);
}
}
// Call on_log callback if provided
if (typeof this.config.on_log === 'function') {
this.config.on_log(message, args, level);
}
// Log to console if enabled
if (this.config.log_to_console && console && console[level]) {
var log_args = ['[MediaCapture]', message].concat(args);
console[level].apply(console, log_args);
}
};
/**
* Sanitize and validate config values
* Reverts any invalid, null, undefined, or empty string values back to defaults
* @private
*/
MediaCapture.prototype.sanitize_config = function() {
// Get default config values from helper function
var defaults = get_default_config();
// Helper function to check if value is invalid
var is_invalid = function(value) {
return value === null ||
value === undefined ||
(typeof value === 'string' && value.trim() === '');
};
// Sanitize all config values
for (var key in defaults) {
if (defaults.hasOwnProperty(key)) {
var default_value = defaults[key];
var current_value = this.config[key];
// Skip function types (like log_handler) - they're handled separately
if (typeof default_value === 'function' || typeof default_value === 'object' && default_value === null) {
continue;
}
// For strings, check if empty/null/undefined
if (typeof default_value === 'string') {
if (is_invalid(current_value)) {
this.config[key] = default_value;
}
}
// For numbers, check if invalid or not a number
else if (typeof default_value === 'number') {
if (is_invalid(current_value) || isNaN(current_value)) {
this.config[key] = default_value;
}
}
// For booleans, ensure it's actually a boolean
else if (typeof default_value === 'boolean') {
if (typeof current_value !== 'boolean') {
this.config[key] = default_value;
}
}
}
}
// Validate specific enum values
var valid_modes = ['photo', 'video', 'audio'];
if (valid_modes.indexOf(this.config.mode) === -1) {
this.config.mode = defaults.mode;
}
var valid_facing_modes = ['user', 'environment'];
if (valid_facing_modes.indexOf(this.config.facing_mode) === -1) {
this.config.facing_mode = defaults.facing_mode;
}
var valid_photo_formats = ['jpeg', 'png', 'webp'];
if (valid_photo_formats.indexOf(this.config.photo_format) === -1) {
this.config.photo_format = defaults.photo_format;
}
var valid_video_formats = ['webm', 'mp4'];
if (valid_video_formats.indexOf(this.config.video_format) === -1) {
this.config.video_format = defaults.video_format;
}
var valid_audio_formats = ['webm', 'mp4', 'ogg', 'wav'];
if (valid_audio_formats.indexOf(this.config.audio_format) === -1) {
this.config.audio_format = defaults.audio_format;
}
// Validate numeric ranges
this.config.photo_quality = Math.max(0.1, Math.min(1.0, this.config.photo_quality));
this.config.countdown_duration = Math.max(1, Math.min(10, this.config.countdown_duration));
this.config.max_duration = Math.max(1, this.config.max_duration);
this.config.controls_auto_hide_duration = Math.max(0, this.config.controls_auto_hide_duration);
};
/**
* Build the media capture interface HTML
* @private
*/
MediaCapture.prototype.build_interface = function() {
var html = '';
var mode = this.config.mode;
var auto_start = this.config.auto_start;
var countdown_enabled = this.config.countdown_enabled;
var controls_auto_hide = this.config.controls_auto_hide;
var controls_auto_hide_duration = this.config.controls_auto_hide_duration;
// Wrapper div to contain all library HTML
html += '<div class="mc-wrapper mc-mode-' + mode + '" role="region" aria-label="Media capture interface">';
// Add aria-live region for screen reader announcements
html += '<div class="mc-sr-announcer" role="status" aria-live="polite" aria-atomic="true" style="position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;"></div>';
// Initial start screen (shown when auto_start is false)
if (!auto_start) {
var start_screen_class = 'mc-start';
html += '<div class="' + start_screen_class + '">';
html += '<div class="mc-start-inner">';
html += '<div class="mc-start-icon">';
if (mode === 'audio') {
html += this.config.icon_mic;
} else {
html += this.config.icon_camera;
}
html += '</div>';
if (mode === 'audio') {
html += '<p class="mc-start-text">' + this.esc_html(this.config.label_start_microphone) + '</p>';
} else {
html += '<p class="mc-start-text">' + this.esc_html(this.config.label_start_camera) + '</p>';
}
html += '</div>';
html += '</div>';
}
// Error screen (hidden by default)
var error_screen_class = 'mc-error';
html += '<div class="' + error_screen_class + '" role="alert" aria-live="assertive" style="display: none;">';
html += '<div class="mc-error-inner">';
html += '<div class="mc-error-icon">';
html += this.config.icon_error;
html += '</div>';
if (mode === 'video') {
html += '<p class="mc-error-text">' + this.esc_html(this.config.label_error_camera_microphone) + '</p>';
} else if (mode === 'audio') {
html += '<p class="mc-error-text">' + this.esc_html(this.config.label_error_microphone) + '</p>';
} else {
html += '<p class="mc-error-text">' + this.esc_html(this.config.label_error_camera) + '</p>';
}
html += '</div>';
html += '</div>';
// Preview container with overlay controls
var preview_class = 'mc-capture';
html += '<div class="' + preview_class + '"' + (auto_start ? '' : ' style="display: none;"') + '>';
// Video element for photo/video modes, audio element for audio mode
if (mode === 'audio') {
html += '<audio class="mc-audio" style="display: none;" aria-label="Audio recording"></audio>';
} else {
// Single video element that serves both live preview and playback
html += '<video class="mc-video" autoplay playsinline muted aria-label="Camera preview"></video>';
}
// Canvas for photo capture (photo mode only)
if (mode === 'photo') {
html += '<canvas class="mc-canvas"></canvas>';
}
// QR scan overlay (photo mode only, shown when qr_scan_enabled and qr_scan_overlay_enabled)
if (mode === 'photo' && this.config.qr_scan_enabled && this.config.qr_scan_overlay_enabled) {
html += '<div class="mc-qr-overlay" aria-hidden="true">';
html += '<div class="mc-qr-finder">';
html += '<span class="mc-qr-corner mc-qr-corner-tl"></span>';
html += '<span class="mc-qr-corner mc-qr-corner-tr"></span>';
html += '<span class="mc-qr-corner mc-qr-corner-bl"></span>';
html += '<span class="mc-qr-corner mc-qr-corner-br"></span>';
html += '</div>';
html += '</div>';
}
if (mode === 'photo' && this.config.qr_scan_enabled) {
html += '<div class="mc-qr-sr" role="status" aria-live="polite" aria-atomic="true" style="position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;"></div>';
}
// Countdown overlay
if (countdown_enabled) {
html += '<div class="mc-countdown" style="display: none;">';
html += '<span class="mc-countdown-number"></span>';
html += '<span class="mc-countdown-sr" role="status" aria-live="assertive" aria-atomic="true"></span>';
html += '</div>';
}
// Overlay controls bar at bottom
html += '<div class="mc-capture-controls">';
// Left column - Record button, Mic level, Timer
html += '<div class="mc-capture-controls-left">';
if (mode === 'photo') {
html += '<button type="button" class="mc-btn mc-capture-btn" aria-label="' + this.esc_attr(this.config.label_capture_photo) + '" title="' + this.esc_attr(this.config.label_capture_photo) + '">';
html += this.config.icon_camera;
html += '</button>';
} else if (mode === 'video') {
html += '<button type="button" class="mc-btn mc-record-btn" aria-label="' + this.esc_attr(this.config.label_start_recording) + '" title="' + this.esc_attr(this.config.label_start_recording) + '">';
html += this.config.icon_record;
html += '</button>';
// Pause button (video mode only, hidden until recording starts)
html += '<button type="button" class="mc-btn mc-pause-btn" style="display: none;" aria-label="' + this.esc_attr(this.config.label_pause) + '" title="' + this.esc_attr(this.config.label_pause) + '">';
html += this.config.icon_pause;
html += '</button>';
// Microphone level indicator (video mode only)
html += '<div class="mc-mic-indicator" tabindex="0" role="button" aria-label="' + this.esc_attr(this.config.label_mic_active) + '" title="' + this.esc_attr(this.config.label_mic_active) + '">';
html += '<div class="mc-mic-icon">';
html += this.config.icon_mic;
html += '</div>';
html += '<div class="mc-mic-bar">';
html += '<div class="mc-mic-bar-track">';
html += '<div class="mc-mic-bar-fill"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
// Recording timer
var max_duration = this.config.max_duration;
var has_max_duration = max_duration && max_duration < Infinity;
html += '<div class="mc-timer" role="timer" aria-live="off" style="display: none;">';
html += '<span class="mc-timer-elapsed">00:00</span>';
if (has_max_duration) {
// Calculate initial remaining time display
var initial_mins = Math.floor(max_duration / 60);
var initial_secs = max_duration % 60;
var initial_remaining = this.pad_zero(initial_mins) + ':' + this.pad_zero(initial_secs);
html += '<span class="mc-timer-separator"> / </span>';
html += '<span class="mc-timer-remaining">' + initial_remaining + '</span>';
}
html += '</div>';
} else if (mode === 'audio') {
html += '<button type="button" class="mc-btn mc-record-btn" aria-label="' + this.esc_attr(this.config.label_start_recording) + '" title="' + this.esc_attr(this.config.label_start_recording) + '">';
html += this.config.icon_record;
html += '</button>';
// Pause button (audio mode, hidden until recording starts)
html += '<button type="button" class="mc-btn mc-pause-btn" style="display: none;" aria-label="' + this.esc_attr(this.config.label_pause) + '" title="' + this.esc_attr(this.config.label_pause) + '">';
html += this.config.icon_pause;
html += '</button>';
// Microphone level indicator (audio mode)
html += '<div class="mc-mic-indicator" tabindex="0" role="button" aria-label="' + this.esc_attr(this.config.label_mic_active) + '" title="' + this.esc_attr(this.config.label_mic_active) + '">';
html += '<div class="mc-mic-icon">';
html += this.config.icon_mic;
html += '</div>';
html += '<div class="mc-mic-bar">';
html += '<div class="mc-mic-bar-track">';
html += '<div class="mc-mic-bar-fill"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
// Recording timer
var max_duration = this.config.max_duration;
var has_max_duration = max_duration && max_duration < Infinity;
html += '<div class="mc-timer" role="timer" aria-live="off" style="display: none;">';
html += '<span class="mc-timer-elapsed">00:00</span>';
if (has_max_duration) {
// Calculate initial remaining time display
var audio_initial_mins = Math.floor(max_duration / 60);
var audio_initial_secs = max_duration % 60;
var audio_initial_remaining = this.pad_zero(audio_initial_mins) + ':' + this.pad_zero(audio_initial_secs);
html += '<span class="mc-timer-separator"> / </span>';
html += '<span class="mc-timer-remaining">' + audio_initial_remaining + '</span>';
}
html += '</div>';
}
html += '</div>';
// Right column - Settings
html += '<div class="mc-capture-controls-right">';
// Camera flip button (hidden by default, shown on mobile with multiple cameras, not shown for audio mode)
if (mode !== 'audio') {
html += '<button type="button" class="mc-btn mc-flip-btn" style="display: none;" aria-label="' + this.esc_attr(this.config.label_flip_camera) + '" title="' + this.esc_attr(this.config.label_flip_camera) + '">';
html += this.config.icon_flip_camera;
html += '</button>';
}
// Cancel retake button (hidden until retake is clicked)
html += '<button type="button" class="mc-btn mc-cancel-btn" style="display: none;" aria-label="' + this.esc_attr(this.config.label_cancel_retake) + '" title="' + this.esc_attr(this.config.label_cancel_retake) + '">';
html += this.config.icon_close;
html += '</button>';
html += '<button type="button" class="mc-btn mc-settings-btn" aria-label="' + this.esc_attr(this.config.label_settings) + '" title="' + this.esc_attr(this.config.label_settings) + '" aria-expanded="false">';
html += this.config.icon_settings;
html += '</button>';
html += '</div>';
html += '</div>'; // End overlay controls
// Playback controls (hidden by default, shown when playing back recorded video/audio)
if (mode === 'video' || mode === 'audio') {
html += '<div class="mc-result-controls" style="display: none;">';
// Left - Play button, Volume controls, Time display
html += '<div class="mc-result-controls-left">';
html += '<button type="button" class="mc-btn mc-play-btn" aria-label="' + this.esc_attr(this.config.label_play) + '" title="' + this.esc_attr(this.config.label_play) + '">';
html += this.config.icon_play;
html += '</button>';
// Volume level indicator
html += '<div class="mc-volume-indicator" tabindex="0" role="slider" aria-label="' + this.esc_attr(this.config.label_volume) + '" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" title="' + this.esc_attr(this.config.label_volume) + '">';
html += '<div class="mc-volume-icon">';
html += this.config.icon_volume;
html += '</div>';
html += '<div class="mc-volume-bar">';
html += '<div class="mc-volume-fill"></div>';
html += '<input type="range" class="mc-volume-slider" min="0" max="100" value="100" tabindex="-1">';
html += '</div>';
html += '</div>';
html += '<div class="mc-time" role="status" aria-live="polite"><span class="mc-time-current">0:00</span> / <span class="mc-time-duration">0:00</span></div>';
html += '</div>';
// Center - Progress bar
html += '<div class="mc-result-controls-center">';
var progress_label = this.config.label_progress;
html += '<div class="mc-progress" tabindex="0" role="slider" aria-label="' + this.esc_attr(progress_label) + '" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" title="' + this.esc_attr(progress_label) + '"><div class="mc-progress-track"><div class="mc-progress-fill"></div></div></div>';
html += '</div>';
// Right - Retake and Settings
html += '<div class="mc-result-controls-right">';
var retake_label = this.config.label_retake;
html += '<button type="button" class="mc-btn mc-retake-btn" aria-label="' + this.esc_attr(retake_label) + '" title="' + this.esc_attr(retake_label) + '">';
html += this.config.icon_retake;
html += '</button>';
html += '<button type="button" class="mc-btn mc-settings-btn" aria-label="' + this.esc_attr(this.config.label_settings) + '" title="' + this.esc_attr(this.config.label_settings) + '" aria-expanded="false">';
html += this.config.icon_settings;
html += '</button>';
html += '</div>';
html += '</div>'; // End playback controls
}
// Result display container (hidden by default, shown when media is captured)
if (mode === 'photo') {
// For photos, show the captured image
html += '<div class="mc-result" style="display: none;">';
html += '<img class="mc-photo" src="" alt="Captured Photo">';
// Flash overlay for photo capture
html += '<div class="mc-flash"></div>';
// Photo controls bar
html += '<div class="mc-result-controls">';
// Left column - Retake button (matches position of capture button)
html += '<div class="mc-result-controls-left">';
html += '<button type="button" class="mc-btn mc-retake-btn" aria-label="' + this.esc_attr(this.config.label_retake) + '" title="' + this.esc_attr(this.config.label_retake) + '">';
html += this.config.icon_retake;
html += '</button>';
html += '</div>';
// Center column - Empty
html += '<div class="mc-result-controls-center">';
html += '</div>';
// Right column - Settings only
html += '<div class="mc-result-controls-right">';
html += '<button type="button" class="mc-btn mc-settings-btn" aria-label="' + this.esc_attr(this.config.label_settings) + '" title="' + this.esc_attr(this.config.label_settings) + '" aria-expanded="false">';
html += this.config.icon_settings;
html += '</button>';
html += '</div>';
html += '</div>'; // End photo controls bar
html += '</div>'; // End photo player
}
html += '</div>'; // End preview container
// Settings panel (at wrapper level so it can overlay both preview and result)
var unique_id = 'mc-' + Date.now();
html += '<div class="mc-settings" role="menu" style="display: none;">';
// Main menu
html += '<div class="mc-settings-menu mc-settings-menu-main" data-menu="main">';
// Video source menu item (only for photo and video modes)
if (mode === 'photo' || mode === 'video') {
html += '<button type="button" class="mc-settings-menu-item" role="menuitem" data-action="video-input">';
html += '<span class="mc-settings-menu-icon">' + this.config.icon_source_video + '</span>';
html += '<span class="mc-settings-menu-label">' + this.esc_html(this.config.label_settings_video_input) + '</span>';
html += '<span class="mc-settings-menu-arrow">' + this.config.icon_chevron_right + '</span>';
html += '</button>';
}
// Audio source menu item (for video and audio modes)
if (mode === 'video' || mode === 'audio') {
html += '<button type="button" class="mc-settings-menu-item" role="menuitem" data-action="audio-input">';
html += '<span class="mc-settings-menu-icon">' + this.config.icon_source_audio + '</span>';
html += '<span class="mc-settings-menu-label">' + this.esc_html(this.config.label_settings_audio_input) + '</span>';
html += '<span class="mc-settings-menu-arrow">' + this.config.icon_chevron_right + '</span>';
html += '</button>';
}
// Download menu item (if enabled, disabled until media is captured)
if (this.config.settings_download_enabled) {
html += '<button type="button" class="mc-settings-menu-item mc-download-item" role="menuitem" data-action="download" disabled>';
html += '<span class="mc-settings-menu-icon">' + this.config.icon_download + '</span>';
html += '<span class="mc-settings-menu-label">' + this.esc_html(this.config.label_settings_download) + '</span>';
html += '</button>';
}
html += '</div>'; // End main menu
// Video input submenu (only for photo and video modes)
if (mode === 'photo' || mode === 'video') {
html += '<div class="mc-settings-menu mc-settings-menu-sub" data-menu="video-input" style="display: none;">';
html += '<button type="button" class="mc-settings-menu-item mc-settings-back-btn" role="menuitem" data-action="back">';
html += '<span class="mc-settings-menu-label">' + this.esc_html(this.config.label_settings_back) + '</span>';
html += '</button>';
html += '<div class="mc-settings-menu-devices mc-video-devices">';
html += '<!-- Video devices will be populated dynamically -->';
html += '</div>';
html += '</div>'; // End video input submenu
}
// Audio input submenu (for video and audio modes)
if (mode === 'video' || mode === 'audio') {
html += '<div class="mc-settings-menu mc-settings-menu-sub" data-menu="audio-input" style="display: none;">';
html += '<button type="button" class="mc-settings-menu-item mc-settings-back-btn" role="menuitem" data-action="back">';
html += '<span class="mc-settings-menu-label">' + this.esc_html(this.config.label_settings_back) + '</span>';
html += '</button>';
html += '<div class="mc-settings-menu-devices mc-audio-devices">';
html += '<!-- Audio devices will be populated dynamically -->';
html += '</div>';
html += '</div>'; // End audio input submenu
}
html += '</div>'; // End settings panel
html += '</div>'; // End mc-wrapper
// Inject HTML into the element
this.element.innerHTML = html;
};
/**
* Helper: Escape HTML entities
* @private
*/
MediaCapture.prototype.esc_html = function(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
/**
* Helper: Escape HTML attributes
* @private
*/
MediaCapture.prototype.esc_attr = function(text) {
return text.replace(/"/g, '"').replace(/'/g, ''');
};
/**
* Helper: Pad numbers with zero
* @private
*/
MediaCapture.prototype.pad_zero = function(num) {
return num < 10 ? '0' + num : num;
};
/**
* Helper: Format time in M:SS or MM:SS format
* @private
*/
MediaCapture.prototype.format_time = function(seconds) {
if (isNaN(seconds)) {
return '0:00';
}
var mins = Math.floor(seconds / 60);
var secs = Math.floor(seconds % 60);
return mins + ':' + this.pad_zero(secs);
};
/**
* Helper: Announce message to screen readers
* @param {string} message - Message to announce
* @private
*/
MediaCapture.prototype.announce_to_screen_reader = function(message) {
var announcer = this.element.querySelector('.mc-sr-announcer');
if (announcer) {
announcer.textContent = message;
}
};
/**
* Get supported audio MIME type based on format preference
* @param {string} format - Preferred format ('webm', 'mp4', 'ogg', 'wav')
* @returns {string} Supported MIME type
* @private
*/
MediaCapture.prototype.get_supported_audio_mime_type = function(format) {
// Define format options with their MIME types and codecs
var format_options = {
'webm': [
'audio/webm;codecs=opus',
'audio/webm'
],
'mp4': [
'audio/mp4',
'audio/mp4;codecs=mp4a.40.2'
],
'ogg': [
'audio/ogg;codecs=opus',
'audio/ogg'
],
'wav': [
'audio/wav'
]
};
// Try preferred format first
if (format && format_options[format]) {
for (var i = 0; i < format_options[format].length; i++) {
if (MediaRecorder.isTypeSupported(format_options[format][i])) {
return format_options[format][i];
}
}
}
// Fallback order: webm -> mp4 -> ogg -> wav
var fallback_order = ['webm', 'mp4', 'ogg', 'wav'];
for (var j = 0; j < fallback_order.length; j++) {
var fallback_format = fallback_order[j];
if (fallback_format !== format && format_options[fallback_format]) {
for (var k = 0; k < format_options[fallback_format].length; k++) {
if (MediaRecorder.isTypeSupported(format_options[fallback_format][k])) {
return format_options[fallback_format][k];
}
}
}
}
// Final fallback - try basic audio/webm
return 'audio/webm';
};
/**
* Get supported video MIME type based on format preference
* @param {string} format - Preferred format ('webm', 'mp4')
* @returns {string} Supported MIME type
* @private
*/
MediaCapture.prototype.get_supported_video_mime_type = function(format) {
// Define format options with their MIME types and codecs
var format_options = {
'webm': [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm'
],
'mp4': [
'video/mp4;codecs=h264,aac',
'video/mp4'
]
};
// Try preferred format first
if (format && format_options[format]) {
for (var i = 0; i < format_options[format].length; i++) {
if (MediaRecorder.isTypeSupported(format_options[format][i])) {
return format_options[format][i];
}
}
}
// Fallback order: webm -> mp4
var fallback_order = ['webm', 'mp4'];
for (var j = 0; j < fallback_order.length; j++) {
var fallback_format = fallback_order[j];
if (fallback_format !== format && format_options[fallback_format]) {
for (var k = 0; k < format_options[fallback_format].length; k++) {
if (MediaRecorder.isTypeSupported(format_options[fallback_format][k])) {
return format_options[fallback_format][k];
}
}
}
}
// Final fallback
return 'video/webm;codecs=vp8,opus';
};
/**
* Get supported photo MIME type based on format preference
* @param {string} format - Preferred format ('jpeg', 'png', 'webp')
* @returns {string} Supported MIME type
* @private
*/
MediaCapture.prototype.get_supported_photo_mime_type = function(format) {
// Valid photo formats
var valid_formats = ['jpeg', 'png', 'webp'];
// Check if format is valid
if (format && valid_formats.indexOf(format) !== -1) {
return 'image/' + format;
}
// Fallback to jpeg
return 'image/jpeg';
};
/**
* Initialize media capture functionality
* Sets up event handlers and starts camera if auto_start is enabled
* @private
*/
MediaCapture.prototype.init_functionality = function() {
var self = this;
self.log('[init_functionality] Starting...');
var container = this.element;
var mode = this.config.mode;
var auto_start = this.config.auto_start;
self.log('[init_functionality] mode:', mode);
self.log('[init_functionality] auto_start:', auto_start);
var max_duration = this.config.max_duration;
var countdown_enabled = this.config.countdown_enabled;
var countdown_duration = this.config.countdown_duration;
var countdown_sound_enabled = this.config.countdown_sound_enabled;
var facing_mode = this.config.facing_mode;
var video_width = this.video_width;
var video_height = this.video_height;
var photo_quality = this.config.photo_quality;
var photo_format = this.config.photo_format;
var controls_auto_hide = this.config.controls_auto_hide;
var controls_auto_hide_duration = this.config.controls_auto_hide_duration;
// Get the wrapper element for setting CSS variables
var wrapper = container.querySelector('.mc-wrapper');
// Set up ResizeObserver to dynamically update --mc-wrapper-width for responsive control sizing
var update_wrapper_width = function() {
if (wrapper) {
var wrapper_width = wrapper.offsetWidth;
wrapper.style.setProperty('--mc-wrapper-width', wrapper_width + 'px');
}
};
// Initial width setting
update_wrapper_width();
// Observe size changes on the wrapper itself
if (typeof ResizeObserver !== 'undefined' && wrapper) {
var resize_observer = new ResizeObserver(function() {
update_wrapper_width();
});
resize_observer.observe(wrapper);
// Store observer for cleanup
self.resize_observer = resize_observer;
}
// Constants
var MILLISECONDS_PER_SECOND = 1000;
var TIMER_UPDATE_INTERVAL = 1000;
var DURATION_UPDATE_INTERVAL = 100;
var PERCENTAGE_MAX = 100;
var VOLUME_LOW_THRESHOLD = 50;
var ANALYSER_SMOOTHING = 0.3;
var ANALYSER_FFT_SIZE = 256;
var DOUBLE_CLICK_PREVENTION_MS = 300; // Prevent accidental double-clicks
// Selector constants
var SELECTORS = {
VIDEO: '.mc-video',
CANVAS: '.mc-canvas',
RESULT_PHOTO: '.mc-photo',
COUNTDOWN: '.mc-countdown',
START_SCREEN: '.mc-start',
PREVIEW_CONTAINER: '.mc-capture',
ERROR_SCREEN: '.mc-error',
SETTINGS_PANEL: '.mc-settings',
SETTINGS_BTN: '.mc-settings-btn',
OVERLAY_CONTROLS: '.mc-capture-controls',
PLAYBACK_CONTROLS: '.mc-result-controls',
CONTROLS: '.mc-result-controls',
PROGRESS: '.mc-progress',
TIMER: '.mc-timer',
TIME_CURRENT: '.mc-time-current',
TIME_DURATION: '.mc-time-duration',
RECORD_BTN: '.mc-record-btn',
PAUSE_BTN: '.mc-pause-btn',
PLAY_BTN: '.mc-play-btn',
RETAKE_BTN: '.mc-retake-btn',
CANCEL_BTN: '.mc-cancel-btn',
FLIP_BTN: '.mc-flip-btn',
MIC_INDICATOR: '.mc-mic-indicator',
MIC_FILL: '.mc-mic-bar-fill',
VIDEO_SELECT: '.mc-video-select',
AUDIO_SELECT: '.mc-audio-select',
VOLUME_INDICATOR: '.mc-volume-indicator',
VOLUME_SLIDER: '.mc-volume-slider',
VOLUME_FILL: '.mc-volume-fill',
VOLUME_ICON: '.mc-volume-icon'
};
// Note: Using self.stream instead of local media_stream variable
// This ensures consistency between internal UI and external API calls
var media_recorder = null;
var recorded_chunks = [];
var is_recording = false;
var audio_context = null;
var analyser = null;
var mic_animation_frame = null;
// Timestamps for double-click prevention
var last_record_action_time = 0;
var last_play_pause_action_time = 0;
var last_pause_recording_action_time = 0;
var last_photo_action_time = 0; // Shared between capture and retake for photos
var last_settings_action_time = 0; // For settings button double-click prevention
// Cached DOM elements
var video_element = container.querySelector(SELECTORS.VIDEO);
var canvas_element = container.querySelector(SELECTORS.CANVAS);
var result_photo = container.querySelector(SELECTORS.RESULT_PHOTO);
var countdown_overlay = container.querySelector(SELECTORS.COUNTDOWN);
var start_screen = container.querySelector(SELECTORS.START_SCREEN);
var preview_container = container.querySelector(SELECTORS.PREVIEW_CONTAINER);
var error_screen = container.querySelector(SELECTORS.ERROR_SCREEN);
var settings_panel = container.querySelector(SELECTORS.SETTINGS_PANEL);
var settings_btn = container.querySelector(SELECTORS.SETTINGS_BTN);
var overlay_controls = container.querySelector(SELECTORS.OVERLAY_CONTROLS);
var playback_controls = container.querySelector(SELECTORS.PLAYBACK_CONTROLS);
var result_controls = container.querySelector(SELECTORS.CONTROLS);
// Initialize audio context for countdown beeps if enabled
var beep_audio_context = null;
if (countdown_sound_enabled) {
try {
beep_audio_context = new (window.AudioContext || window.webkitAudioContext)();
} catch(e) {
// Audio API not supported - fail silently
}
}
// Detect if mobile device
this.is_mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Check if browser supports programmatic volume control
// Most mobile browsers don't allow setting volume programmatically
var test_video = document.createElement('video');
var supports_volume_control = true;
try {
test_video.volume = 0.5;
// If volume is still 1.0, the browser ignored our attempt to change it
if (test_video.volume === 1.0) {
supports_volume_control = false;
}
} catch(e) {
supports_volume_control = false;
}
// Hide volume control if browser doesn't support it
if (!supports_volume_control) {
var volume_indicator = container.querySelector('.mc-volume-indicator');
if (volume_indicator) {
volume_indicator.style.display = 'none';
}
}
// Enumerate devices and populate selectors
this.enumerate_devices(container, mode);
// Function to start camera
var start_camera_fn = function() {
// Resume AudioContext if needed for countdown beeps
if (beep_audio_context && beep_audio_context.state === 'suspended') {
beep_audio_context.resume();
}
// Preload photo sound on user interaction to avoid browser blocking
if (self.config.photo_sound_enabled && self.config.photo_sound_url && !self.photo_sound_preloaded) {
try {
var preload_audio = new Audio(self.config.photo_sound_url);
preload_audio.volume = 0.01; // Very quiet
preload_audio.play().then(function() {
preload_audio.pause();
self.photo_sound_preloaded = true;
}).catch(function() {
// Couldn't preload - sound may not work
});
} catch(e) {
// Preload failed
}
}
// Preload countdown sound on user interaction to avoid browser blocking
if (self.config.countdown_sound_enabled && self.config.countdown_sound_url && !self.countdown_sound_preloaded) {
try {
var preload_countdown = new Audio(self.config.countdown_sound_url);
preload_countdown.volume = 0.01; // Very quiet
preload_countdown.play().then(function() {
preload_countdown.pause();
self.countdown_sound_preloaded = true;
}).catch(function() {
// Couldn't preload - sound may not work
});
} catch(e) {
// Preload failed
}
}
// Use the public start_stream method
self.stream_start()
.then(function(stream) {
// self.stream is already set by stream_start()
// No need to set media_stream
// Start mic level monitoring for video and audio modes and store references locally
if (mode === 'video' || mode === 'audio') {
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
audio_context = context;
analyser = analyser_node;
});
}
// Start auto-hide timer when camera starts (if auto-hide is enabled and not audio mode)
if (controls_auto_hide && mode !== 'audio' && typeof start_autohide_timer === 'function') {
start_autohide_timer();
}
})
.catch(function(error) {
// Error handled by start_stream method
});
};
// Start camera screen click handler - clicking anywhere starts the camera
if (start_screen) {
start_screen.addEventListener('click', function() {
start_camera_fn();
});
// Also allow keyboard activation
start_screen.addEventListener('keydown', function(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
start_camera_fn();
}
});
// Make start screen focusable and add role for accessibility
start_screen.setAttribute('tabindex', '0');
start_screen.setAttribute('role', 'button');
var start_label = mode === 'audio' ? self.config.label_start_microphone : self.config.label_start_camera;
start_screen.setAttribute('aria-label', start_label);
}
// Auto-start camera if enabled
if (auto_start) {
start_camera_fn();
}
// Event Handlers
// Settings button - toggle settings panel
container.addEventListener('click', function(e) {
var settings_btn_clicked = e.target.closest('.mc-settings-btn');
if (settings_btn_clicked) {
self.log('[settings click] Button clicked:', settings_btn_clicked);
self.log('[settings click] Button disabled:', settings_btn_clicked.disabled);
self.log('[settings click] is_recording:', self.is_recording);
e.preventDefault();
// Prevent opening settings during recording
if (self.is_recording) {
self.log('[settings click] Blocked: is_recording = true');
return;
}
// Prevent accidental double-clicks
var now = Date.now();
var time_since_last = now - last_settings_action_time;
if (time_since_last < DOUBLE_CLICK_PREVENTION_MS && last_settings_action_time > 0) {
self.log('[settings click] Blocked: double-click prevention');
return; // Too soon after last settings action, ignore
}
last_settings_action_time = now;
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
self.log('[settings click] Settings panel is_visible:', is_visible);
if (is_visible) {
self.log('[settings click] Closing settings');
self.settings_close_internal(container, settings_panel, settings_btn_clicked);
} else {
self.log('[settings click] Opening settings');
self.settings_open_internal(container, settings_panel, settings_btn_clicked);
}
}
});
// Keyboard handler for settings button
container.addEventListener('keydown', function(e) {
var settings_btn_key = e.target.closest('.mc-settings-btn');
if (settings_btn_key && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
settings_btn_key.click();
}
});
// Escape key closes settings panel or goes back in submenu
container.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (is_visible) {
// Check if we're in a submenu
var active_submenu = settings_panel.querySelector('.mc-settings-menu-sub[style*="display: block"]');
if (active_submenu) {
// Go back to main menu
self.hide_settings_submenu(settings_panel);
} else {
// Close settings panel
self.settings_close_internal(container, settings_panel, settings_btn);
}
}
}
});
// Click outside settings panel to close it
var click_outside_handler = function(e) {
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (is_visible) {
// Check if click is outside the settings panel and settings button
if (!e.target.closest('.mc-settings') && !e.target.closest('.mc-settings-btn')) {
self.close_settings(container);
}
}
};
document.addEventListener('click', click_outside_handler);
// Reposition settings panel on scroll and resize (like native audio controls)
var reposition_handler = function() {
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (is_visible) {
self.position_settings_panel(settings_panel, settings_btn);
}
};
window.addEventListener('scroll', reposition_handler, true); // Use capture phase to catch all scroll events
window.addEventListener('resize', reposition_handler);
// Settings menu navigation
settings_panel.addEventListener('click', function(e) {
var menu_item = e.target.closest('.mc-settings-menu-item');
if (!menu_item) return;
e.preventDefault();
e.stopPropagation();
var action = menu_item.getAttribute('data-action');
var device_id = menu_item.getAttribute('data-device-id');
var device_type = menu_item.getAttribute('data-device-type');
// Handle back button
if (action === 'back') {
self.hide_settings_submenu(settings_panel);
// Focus first menu item in main menu
var first_item = settings_panel.querySelector('[data-menu="main"] .mc-settings-menu-item');
if (first_item) first_item.focus();
}
// Handle submenu navigation
else if (action === 'video-input' || action === 'audio-input') {
self.show_settings_submenu(settings_panel, action);
// Focus first menu item in submenu (back button)
var submenu_first_item = settings_panel.querySelector('[data-menu="' + action + '"] .mc-settings-menu-item');
if (submenu_first_item) submenu_first_item.focus();
}
// Handle download action
else if (action === 'download') {
self.download();
self.settings_close_internal(container, settings_panel, settings_btn);
}
// Handle device selection
else if (device_id && device_type) {
self.select_device(container, video_element, facing_mode, video_width, video_height,
mode, device_type, device_id, audio_context, analyser);
self.settings_close_internal(container, settings_panel, settings_btn);
}
});
// Keyboard navigation for settings menu
settings_panel.addEventListener('keydown', function(e) {
var current_item = e.target.closest('.mc-settings-menu-item');
if (!current_item) return;
var visible_menu = settings_panel.querySelector('.mc-settings-menu[style*="display: block"], .mc-settings-menu:not([style*="display: none"])');
if (!visible_menu) return;
var all_items = Array.from(visible_menu.querySelectorAll('.mc-settings-menu-item:not([disabled])'));
var current_index = all_items.indexOf(current_item);
if (e.key === 'ArrowDown') {
e.preventDefault();
var next_index = (current_index + 1) % all_items.length;
all_items[next_index].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
var prev_index = (current_index - 1 + all_items.length) % all_items.length;
all_items[prev_index].focus();
} else if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
current_item.click();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
// Check if we're in a submenu
var active_submenu = settings_panel.querySelector('.mc-settings-menu-sub[style*="display: block"]');
if (active_submenu) {
// Go back to main menu
self.hide_settings_submenu(settings_panel);
}
}
});
// Capture photo button (overlay)
container.addEventListener('click', function(e) {
var capture_btn = e.target.closest('.mc-capture-btn');
if (capture_btn) {
// Prevent accidental double-clicks (shared with retake button)
var now = Date.now();
var time_since_last = now - last_photo_action_time;
if (time_since_last < DOUBLE_CLICK_PREVENTION_MS && last_photo_action_time > 0) {
return; // Too soon after last photo action, ignore
}
last_photo_action_time = now;
if (countdown_enabled) {
self.countdown(
countdown_overlay,
countdown_duration,
countdown_sound_enabled,
800,
beep_audio_context,
self.config.countdown_sound_url,
function() {
// Use the public capture_photo method
self.photo_capture().catch(function(error) {
// Error handled by capture_photo method
});
}
);
} else {
// Use the public capture_photo method
self.photo_capture().catch(function(error) {
// Error handled by capture_photo method
});
}
}
});
// Record button (overlay)
// Track event listener additions
if (!window._mc_record_listener_count) {
window._mc_record_listener_count = 0;
}
window._mc_record_listener_count++;
self.log('[INIT] Adding record button listener #' + window._mc_record_listener_count);
container.addEventListener('click', function(e) {
var record_btn = e.target.closest('.mc-record-btn');
if (record_btn) {
self.log('[record_btn click] Button clicked (listener #' + window._mc_record_listener_count + ')');
self.log('[record_btn click] self.stream:', self.stream);
self.log('[record_btn click] self.is_recording:', self.is_recording);
if (!self.stream) {
self.log('[record_btn click] No stream available, returning');
return;
}
if (!self.is_recording) {
self.log('[record_btn click] Starting recording');
// Start recording
last_record_action_time = Date.now(); // Set timestamp when starting
// Determine which mode we're in
if (mode === 'audio') {
// Audio mode recording
if (countdown_enabled) {
self.countdown(
countdown_overlay,
countdown_duration,
countdown_sound_enabled,
800,
beep_audio_context,
self.config.countdown_sound_url,
function() {
self.audio_start_internal(container, self.stream, max_duration);
}
);
} else {
self.audio_start_internal(container, self.stream, max_duration);
}
} else if (mode === 'video') {
// Video mode recording
if (countdown_enabled) {
self.countdown(
countdown_overlay,
countdown_duration,
countdown_sound_enabled,
800,
beep_audio_context,
self.config.countdown_sound_url,
function() {
recorded_chunks = [];
is_recording = true;
self.recorded_chunks = recorded_chunks; // Sync instance property
self.is_recording = is_recording; // Sync instance property
self.start_recording(
self.stream,
container,
max_duration,
function(recorder, interval) {
media_recorder = recorder;
self.media_recorder = recorder; // Sync instance property
self.timer_interval = interval;
},
function(chunk) {
recorded_chunks.push(chunk);
// No need to push to self.recorded_chunks - they point to the same array
},
'video',
self.config.video_format
);
// Update button to stop state
record_btn.innerHTML = self.config.icon_stop;
record_btn.setAttribute('aria-label', self.config.label_stop);
record_btn.setAttribute('title', self.config.label_stop);
record_btn.classList.add('mc-recording');
// Show pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) pause_btn.style.display = '';
// Hide flip button during recording
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Disable all settings buttons during recording
var settings_btns = container.querySelectorAll('.mc-settings-btn');
settings_btns.forEach(function(btn) {
btn.disabled = true;
});
// Dispatch UI state events for external buttons
self.dispatch_ui_state('flip', 'off');
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'on');
self.dispatch_ui_state('pause', 'on');
self.dispatch_ui_state('settings-open', 'off');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('video-start');
// Trigger callback if provided
if (self.config.on_video_started) {
self.config.on_video_started();
}
}
);
} else {
recorded_chunks = [];
is_recording = true;
self.recorded_chunks = recorded_chunks; // Sync instance property
self.is_recording = is_recording; // Sync instance property
self.start_recording(
self.stream,
container,
max_duration,
function(recorder, interval) {
media_recorder = recorder;
self.media_recorder = recorder; // Sync instance property
self.timer_interval = interval;
},
function(chunk) {
recorded_chunks.push(chunk);
// No need to push to self.recorded_chunks - they point to the same array
},
'video',
self.config.video_format
);
// Update button to stop state
record_btn.innerHTML = self.config.icon_stop;
record_btn.setAttribute('aria-label', self.config.label_stop);
record_btn.setAttribute('title', self.config.label_stop);
record_btn.classList.add('mc-recording');
// Show pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) pause_btn.style.display = '';
// Hide flip button during recording
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Disable all settings buttons during recording
var settings_btns = container.querySelectorAll('.mc-settings-btn');
settings_btns.forEach(function(btn) {
btn.disabled = true;
});
// Dispatch UI state events for external buttons
self.dispatch_ui_state('flip', 'off');
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'on');
self.dispatch_ui_state('pause', 'on');
self.dispatch_ui_state('settings-open', 'off');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('video-start');
// Trigger callback if provided
if (self.config.on_video_started) {
self.config.on_video_started();
}
}
}
} else {
// Stop recording - prevent accidental double-click
var time_since_start = Date.now() - last_record_action_time;
if (time_since_start < DOUBLE_CLICK_PREVENTION_MS) {
return; // Too soon after starting, ignore this click
}
if (!self.media_recorder || self.media_recorder.state === 'inactive') { return; }
// Stop based on mode
if (mode === 'audio') {
// Use audio_stop_internal to ensure UI events are dispatched
var audio_element = container.querySelector('.mc-audio');
self.audio_stop_internal(container, audio_element, function() {
self.timer_interval = null;
// Hide and reset pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) {
pause_btn.style.display = 'none';
pause_btn.classList.remove('mc-paused');
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
}
// Remove paused class from timer
var timer = container.querySelector('.mc-timer');
if (timer) timer.classList.remove('mc-paused');
// Hide cancel button and clear backup data
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = 'none';
self.blob_backup = null;
self.filename_backup = null;
});
} else if (mode === 'video') {
// Use video_stop_internal to ensure UI events are dispatched
self.video_stop_internal(container, video_element, function() {
self.timer_interval = null;
// Note: self.stream is managed by stream lifecycle methods
// Hide and reset pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) {
pause_btn.style.display = 'none';
pause_btn.classList.remove('mc-paused');
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
}
// Remove paused class from timer
var timer = container.querySelector('.mc-timer');
if (timer) timer.classList.remove('mc-paused');
// Hide cancel button and clear backup data (new recording replaces old)
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = 'none';
self.blob_backup = null;
self.filename_backup = null;
self.video_url_backup = null;
// Stop mic monitoring
if (mic_animation_frame) {
cancelAnimationFrame(mic_animation_frame);
mic_animation_frame = null;
}
if (audio_context && audio_context.state !== 'closed') {
audio_context.close();
}
audio_context = null;
analyser = null;
});
}
}
}
});
// Pause/Resume button (overlay)
container.addEventListener('click', function(e) {
var pause_btn = e.target.closest('.mc-pause-btn');
if (pause_btn) {
if (!self.media_recorder) { return; }
// Prevent accidental double-clicks
var now = Date.now();
if (now - last_pause_recording_action_time < DOUBLE_CLICK_PREVENTION_MS) {
return; // Too soon after last action, ignore
}
last_pause_recording_action_time = now;
if (self.media_recorder.state === 'recording') {
// Pause based on mode
if (mode === 'audio') {
self.audio_pause_internal(container);
} else if (mode === 'video') {
self.video_pause_internal(container);
}
} else if (self.media_recorder.state === 'paused') {
// Resume based on mode
if (mode === 'audio') {
self.audio_resume_internal(container);
} else if (mode === 'video') {
self.video_resume_internal(container);
}
}
}
});
// Mic mute toggle (click)
container.addEventListener('click', function(e) {
var mic_indicator = e.target.closest('.mc-mic-indicator');
if (mic_indicator) {
if (!self.stream) { return; }
var mic_icon = mic_indicator.querySelector('.mc-mic-icon');
var audio_tracks = self.stream.getAudioTracks();
if (audio_tracks.length > 0) {
var is_muted = !audio_tracks[0].enabled;
// Toggle mute
audio_tracks.forEach(function(track) {
track.enabled = is_muted;
});
// Update icon and aria-label
if (is_muted) {
// Unmuted
mic_icon.innerHTML = self.config.icon_mic;
mic_indicator.setAttribute('aria-label', self.config.label_mic_active);
mic_indicator.setAttribute('title', self.config.label_mic_active);
mic_indicator.classList.remove('mc-mic-muted');
} else {
// Muted
mic_icon.innerHTML = self.config.icon_mic_muted;
mic_indicator.setAttribute('aria-label', self.config.label_mic_muted);
mic_indicator.setAttribute('title', self.config.label_mic_muted);
mic_indicator.classList.add('mc-mic-muted');
}
}
}
});
// Mic mute toggle (keyboard M key)
container.addEventListener('keydown', function(e) {
var mic_indicator_key = e.target.closest('.mc-mic-indicator');
if (mic_indicator_key && (e.key === 'M' || e.key === 'm' || e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
mic_indicator_key.click();
}
});
// Keyboard handlers for buttons (Space/Enter)
container.addEventListener('keydown', function(e) {
var button = e.target.closest('.mc-capture-btn, .mc-record-btn, .mc-pause-btn, .mc-play-btn, .mc-retake-btn, .mc-cancel-btn, .mc-flip-btn');
if (button && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
button.click();
}
});
// Custom video player controls - play/pause button
container.addEventListener('click', function(e) {
var play_btn = e.target.closest('.mc-play-btn');
if (play_btn) {
// Prevent accidental double-clicks
var now = Date.now();
if (now - last_play_pause_action_time < DOUBLE_CLICK_PREVENTION_MS) {
return; // Too soon after last action, ignore
}
last_play_pause_action_time = now;
// Get the appropriate media element (video or audio)
var media_element = video_element || self.audio_element;
if (media_element && media_element.paused) {
media_element.play();
play_btn.innerHTML = self.config.icon_pause;
play_btn.setAttribute('aria-label', self.config.label_pause);
} else if (media_element) {
media_element.pause();
play_btn.innerHTML = self.config.icon_play;
play_btn.setAttribute('aria-label', self.config.label_play);
}
}
});
// Video element event listeners (only for photo/video modes)
if (video_element) {
// Video time update
video_element.addEventListener('timeupdate', function() {
// Get effective duration (prioritize data-actual-duration from recording)
var actual_duration = parseFloat(video_element.getAttribute('data-actual-duration'));
var effective_duration = 0;
if (actual_duration && actual_duration > 0) {
effective_duration = actual_duration;
} else if (!isNaN(video_element.duration) && isFinite(video_element.duration)) {
effective_duration = video_element.duration;
} else if (video_element.buffered.length > 0) {
effective_duration = video_element.buffered.end(video_element.buffered.length - 1);
}
if (effective_duration > 0) {
var current = self.format_time(video_element.currentTime);
var duration = self.format_time(effective_duration);
var time_current = container.querySelector(SELECTORS.TIME_CURRENT);
var time_duration = container.querySelector(SELECTORS.TIME_DURATION);
if (time_current) time_current.textContent = current;
if (time_duration) time_duration.textContent = duration;
// Update progress bar - just set the percentage!
var percent = Math.min(PERCENTAGE_MAX, (video_element.currentTime / effective_duration) * PERCENTAGE_MAX);
var progress_bar = container.querySelector(SELECTORS.PROGRESS);
if (progress_bar) {
progress_bar.style.setProperty('--mc-progress-percent', percent + '%');
progress_bar.setAttribute('aria-valuenow', Math.round(percent).toString());
}
}
});
// Listen for duration change (for blob videos that start with Infinity)
video_element.addEventListener('durationchange', function() {
if (!isNaN(video_element.duration) && isFinite(video_element.duration)) {
var duration = self.format_time(video_element.duration);
var time_duration = container.querySelector('.mc-time-duration');
if (time_duration) time_duration.textContent = duration;
}
});
// Video loaded metadata
video_element.addEventListener('loadedmetadata', function() {
if (!isNaN(video_element.duration) && isFinite(video_element.duration)) {
var duration = self.format_time(video_element.duration);
var time_duration = container.querySelector('.mc-time-duration');
if (time_duration) time_duration.textContent = duration;
}
});
// Video ended
video_element.addEventListener('ended', function() {
var play_btn = container.querySelector('.mc-play-btn');
if (play_btn) {
play_btn.innerHTML = self.config.icon_play;
play_btn.setAttribute('aria-label', self.config.label_play);
}
// Dispatch UI state events when video ends (same as pause)
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-stop', 'off');
});
// Video play event - dispatch UI state when video starts playing
video_element.addEventListener('play', function() {
// Only dispatch if in video mode, not recording, and has recorded content
// (i.e., not the live preview stream)
if (self.config.mode === 'video' && !self.is_recording && self.blob) {
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'on');
}
});
// Video pause event - dispatch UI state when video is paused
video_element.addEventListener('pause', function() {
// Only dispatch if in video mode and not currently recording
if (self.config.mode === 'video' && !self.is_recording) {
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
}
});
}
// Audio element event listeners (only for audio mode)
if (self.audio_element) {
// Audio time update
self.audio_element.addEventListener('timeupdate', function() {
// Get effective duration (prioritize data-actual-duration from recording)
var actual_duration = parseFloat(self.audio_element.getAttribute('data-actual-duration'));
var effective_duration = 0;
if (actual_duration && actual_duration > 0) {
effective_duration = actual_duration;
} else if (!isNaN(self.audio_element.duration) && isFinite(self.audio_element.duration)) {
effective_duration = self.audio_element.duration;
} else if (self.audio_element.buffered.length > 0) {
effective_duration = self.audio_element.buffered.end(self.audio_element.buffered.length - 1);
}
if (effective_duration > 0) {
var current = self.format_time(self.audio_element.currentTime);
var duration = self.format_time(effective_duration);
var time_current = container.querySelector(SELECTORS.TIME_CURRENT);
var time_duration = container.querySelector(SELECTORS.TIME_DURATION);
if (time_current) time_current.textContent = current;
if (time_duration) time_duration.textContent = duration;
// Update progress bar - just set the percentage!
var percent = Math.min(PERCENTAGE_MAX, (self.audio_element.currentTime / effective_duration) * PERCENTAGE_MAX);
var progress_bar = container.querySelector(SELECTORS.PROGRESS);
if (progress_bar) {
progress_bar.style.setProperty('--mc-progress-percent', percent + '%');
progress_bar.setAttribute('aria-valuenow', Math.round(percent).toString());
}
}
});
// Listen for duration change (for blob audio that start with Infinity)
self.audio_element.addEventListener('durationchange', function() {
if (!isNaN(self.audio_element.duration) && isFinite(self.audio_element.duration)) {
var duration = self.format_time(self.audio_element.duration);
var time_duration = container.querySelector('.mc-time-duration');
if (time_duration) time_duration.textContent = duration;
}
});
// Audio loaded metadata
self.audio_element.addEventListener('loadedmetadata', function() {
if (!isNaN(self.audio_element.duration) && isFinite(self.audio_element.duration)) {
var duration = self.format_time(self.audio_element.duration);
var time_duration = container.querySelector('.mc-time-duration');
if (time_duration) time_duration.textContent = duration;
}
});
// Audio ended
self.audio_element.addEventListener('ended', function() {
var play_btn = container.querySelector('.mc-play-btn');
if (play_btn) {
play_btn.innerHTML = self.config.icon_play;
play_btn.setAttribute('aria-label', self.config.label_play);
}
// Dispatch UI state events when audio ends (same as pause)
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-stop', 'off');
});
// Audio play event - dispatch UI state when audio starts playing
self.audio_element.addEventListener('play', function() {
// Only dispatch if in audio mode, not recording, and has recorded content
// (i.e., not the live preview stream)
if (self.config.mode === 'audio' && !self.is_recording && self.blob) {
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'on');
}
});
// Audio pause event - dispatch UI state when audio is paused
self.audio_element.addEventListener('pause', function() {
// Only dispatch if in audio mode and not currently recording
if (self.config.mode === 'audio' && !self.is_recording) {
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
}
});
}
// Progress bar scrubbing (mouse and touch support)
container.addEventListener('mousedown', function(e) {
var progress_bar = e.target.closest('.mc-progress');
if (progress_bar) {
handle_scrub_start(e, progress_bar);
}
});
container.addEventListener('touchstart', function(e) {
var progress_bar = e.target.closest('.mc-progress');
if (progress_bar) {
handle_scrub_start(e, progress_bar);
}
});
function handle_scrub_start(e, progress_bar) {
// Get the appropriate media element (video or audio)
var media_element = video_element || self.audio_element;
if (!media_element) return;
var was_playing = !media_element.paused;
// Focus the scrubber when clicked
progress_bar.focus();
// Pause video while scrubbing for smoother seeking
if (was_playing) {
media_element.pause();
}
// Get effective duration (prioritize data-actual-duration from recording)
function get_effective_duration() {
// First try the data attribute (most accurate for recorded videos)
var actual_duration = parseFloat(media_element.getAttribute('data-actual-duration'));
if (actual_duration && actual_duration > 0) {
return actual_duration;
}
// Then try video.duration
if (isFinite(media_element.duration) && media_element.duration > 0) {
return media_element.duration;
}
// Finally fall back to buffered
if (media_element.buffered.length > 0) {
return media_element.buffered.end(media_element.buffered.length - 1);
}
return 0;
}
function seek(event) {
var rect = progress_bar.getBoundingClientRect();
// Get padding from computed style
var computedStyle = window.getComputedStyle(progress_bar);
var padding = parseFloat(computedStyle.paddingLeft);
// Get X position from mouse or touch event
var clientX;
if (event.type.indexOf('touch') === 0) {
// Touch event
var touch = event.touches[0] || event.changedTouches[0];
clientX = touch.clientX;
} else {
// Mouse event
clientX = event.clientX;
}
// Calculate position - clientX is relative to viewport, rect.left is the element's left edge
var clickX = clientX - rect.left;
var totalWidth = rect.width;
var usableWidth = totalWidth - (padding * 2);
// Adjust for the padding offset - the bar starts at 'padding' pixels from the left
var relativeX = clickX - padding;
var percent = Math.max(0, Math.min(1, relativeX / usableWidth));
var effective_duration = get_effective_duration();
if (effective_duration > 0) {
media_element.currentTime = percent * effective_duration;
}
// Immediate visual update of progress bar - just set the percentage!
progress_bar.style.setProperty('--mc-progress-percent', (percent * 100) + '%');
// Update time display immediately
var current = self.format_time(media_element.currentTime);
var time_current = container.querySelector('.mc-time-current');
if (time_current) time_current.textContent = current;
}
// Seek on initial touch/click
seek(e);
// Continue seeking on drag
function handle_move(e) {
seek(e);
}
document.addEventListener('mousemove', handle_move);
document.addEventListener('touchmove', handle_move);
// Clean up on release
function handle_end() {
document.removeEventListener('mousemove', handle_move);
document.removeEventListener('touchmove', handle_move);
document.removeEventListener('mouseup', handle_end);
document.removeEventListener('touchend', handle_end);
// Resume playing if video was playing before scrubbing
if (was_playing) {
media_element.play();
}
}
document.addEventListener('mouseup', handle_end, { once: true });
document.addEventListener('touchend', handle_end, { once: true });
e.preventDefault();
}
// Keyboard navigation for progress bar
container.addEventListener('keydown', function(e) {
var progress_bar = e.target.closest('.mc-progress');
if (progress_bar) {
// Get the appropriate media element (video or audio)
var media_element = video_element || self.audio_element;
if (!media_element) return;
// Get effective duration
var actual_duration = parseFloat(media_element.getAttribute('data-actual-duration'));
var duration = 0;
if (actual_duration && actual_duration > 0) {
duration = actual_duration;
} else if (isFinite(media_element.duration) && media_element.duration > 0) {
duration = media_element.duration;
} else if (media_element.buffered.length > 0) {
duration = media_element.buffered.end(media_element.buffered.length - 1);
}
if (duration === 0) { return; }
var handled = false;
if (e.key === ' ' || e.key === 'Enter') {
// Space/Enter toggles play/pause
e.preventDefault();
var play_btn = container.querySelector('.mc-play-btn');
if (play_btn) play_btn.click();
return;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
// Seek backward 5% of duration
var seek_amount = duration * 0.05;
media_element.currentTime = Math.max(0, media_element.currentTime - seek_amount);
handled = true;
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
// Seek forward 5% of duration
var seek_amount = duration * 0.05;
media_element.currentTime = Math.min(duration, media_element.currentTime + seek_amount);
handled = true;
} else if (e.key === 'Home') {
// Go to start
media_element.currentTime = 0;
handled = true;
} else if (e.key === 'End') {
// Go to end
media_element.currentTime = duration;
handled = true;
}
if (handled) {
e.preventDefault();
// Update UI immediately - just set the percentage!
var percent = Math.min(100, (media_element.currentTime / duration) * 100);
if (progress_bar) {
progress_bar.style.setProperty('--mc-progress-percent', percent + '%');
progress_bar.setAttribute('aria-valuenow', Math.round(percent).toString());
}
var current = self.format_time(media_element.currentTime);
var time_current = container.querySelector('.mc-time-current');
if (time_current) time_current.textContent = current;
}
}
});
// Volume icon button for muting
container.addEventListener('click', function(e) {
var volume_icon = e.target.closest('.mc-volume-icon');
if (volume_icon) {
// Get the appropriate media element (video or audio)
var media_element = video_element || self.audio_element;
if (!media_element) return;
var volume_control = volume_icon.closest('.mc-volume-indicator');
var slider = container.querySelector('.mc-volume-slider');
var bar = container.querySelector('.mc-volume-fill');
// Focus the volume control wrapper when icon is clicked
if (volume_control) volume_control.focus();
if (media_element.muted || media_element.volume === 0) {
// Unmute and restore volume
media_element.muted = false;
var restore_volume = slider.getAttribute('data-last-volume') || PERCENTAGE_MAX;
restore_volume = parseInt(restore_volume, 10);
media_element.volume = restore_volume / PERCENTAGE_MAX;
slider.value = restore_volume;
if (bar) bar.style.width = restore_volume + '%';
if (volume_control) volume_control.setAttribute('aria-valuenow', restore_volume.toString());
// Update icon
if (restore_volume < VOLUME_LOW_THRESHOLD) {
volume_icon.innerHTML = self.config.icon_volume_low;
} else {
volume_icon.innerHTML = self.config.icon_volume;
}
// Update aria-label to Volume
if (volume_control) {
volume_control.setAttribute('aria-label', self.config.label_volume);
volume_control.setAttribute('title', self.config.label_volume);
}
} else {
// Mute and save current volume
slider.setAttribute('data-last-volume', slider.value);
media_element.muted = true;
media_element.volume = 0;
slider.value = 0;
if (bar) bar.style.width = '0%';
if (volume_control) volume_control.setAttribute('aria-valuenow', '0');
// Update icon to muted
volume_icon.innerHTML = self.config.icon_volume_muted;
// Update aria-label and title to Volume Muted
if (volume_control) {
volume_control.setAttribute('aria-label', self.config.label_volume_muted);
volume_control.setAttribute('title', self.config.label_volume_muted);
}
}
if (volume_control) {
if (media_element.muted) {
volume_control.classList.add('mc-volume-muted');
} else {
volume_control.classList.remove('mc-volume-muted');
}
}
e.stopPropagation();
}
});
// Volume bar mousedown - focus wrapper and prevent slider from getting focus
container.addEventListener('mousedown', function(e) {
var volume_bar = e.target.closest('.mc-volume-bar');
if (volume_bar) {
var volume_control = volume_bar.closest('.mc-volume-indicator');
if (volume_control) {
// Focus the wrapper immediately on mousedown
volume_control.focus();
// Don't stop propagation - let the slider still work
}
}
});
// Prevent slider from taking focus - immediately transfer to parent
container.addEventListener('focus', function(e) {
var slider = e.target.closest('.mc-volume-slider');
if (slider) {
var volume_control = slider.closest('.mc-volume-indicator');
if (volume_control) {
volume_control.focus();
}
}
}, true); // Use capture phase
// Volume bar click - ensure focus on click (for cases where mousedown didn't fire)
container.addEventListener('click', function(e) {
var volume_bar = e.target.closest('.mc-volume-bar');
if (volume_bar) {
var volume_control = volume_bar.closest('.mc-volume-indicator');
if (volume_control) {
// Focus the wrapper
volume_control.focus();
e.stopPropagation();
}
}
});
// Volume slider input
container.addEventListener('input', function(e) {
var slider = e.target.closest('.mc-volume-slider');
if (slider) {
// Get the appropriate media element (video or audio)
var media_element = video_element || self.audio_element;
if (!media_element) return;
var volume = parseInt(slider.value, 10);
var volume_control = slider.closest('.mc-volume-indicator');
var bar = container.querySelector('.mc-volume-fill');
var icon = container.querySelector('.mc-volume-icon');
// Focus the volume control wrapper when slider is used
if (volume_control) volume_control.focus();
// Update visual bar
if (bar) bar.style.width = volume + '%';
// Update audio volume
media_element.volume = volume / PERCENTAGE_MAX;
media_element.muted = (volume === 0);
// Update wrapper aria-valuenow
if (volume_control) volume_control.setAttribute('aria-valuenow', volume.toString());
// Update icon based on volume
if (icon) {
if (volume === 0) {
// Muted/zero
icon.innerHTML = self.config.icon_volume_muted;
if (volume_control) {
volume_control.setAttribute('aria-label', self.config.label_volume_muted);
volume_control.setAttribute('title', self.config.label_volume_muted);
}
} else if (volume < VOLUME_LOW_THRESHOLD) {
// Low volume
icon.innerHTML = self.config.icon_volume_low;
if (volume_control) {
volume_control.setAttribute('aria-label', self.config.label_volume);
volume_control.setAttribute('title', self.config.label_volume);
}
} else {
// Full volume
icon.innerHTML = self.config.icon_volume;
if (volume_control) {
volume_control.setAttribute('aria-label', self.config.label_volume);
volume_control.setAttribute('title', self.config.label_volume);
}
}
}
if (volume_control) {
if (media_element.muted) {
volume_control.classList.add('mc-volume-muted');
} else {
volume_control.classList.remove('mc-volume-muted');
}
}
}
});
// Keyboard handler for volume control wrapper
container.addEventListener('keydown', function(e) {
var volume_control = e.target.closest('.mc-volume-indicator');
if (volume_control) {
var slider = volume_control.querySelector('.mc-volume-slider');
var icon = volume_control.querySelector('.mc-volume-icon');
if (!slider) return;
var current_volume = parseInt(slider.value, 10);
var handled = false;
if (e.key === 'M' || e.key === 'm') {
// M key toggles mute
if (icon) icon.click();
handled = true;
} else if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
// Increase volume by 5%
var new_volume = Math.min(100, current_volume + 5);
slider.value = new_volume;
slider.dispatchEvent(new Event('input', { bubbles: true }));
handled = true;
} else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
// Decrease volume by 5%
var new_volume = Math.max(0, current_volume - 5);
slider.value = new_volume;
slider.dispatchEvent(new Event('input', { bubbles: true }));
handled = true;
}
if (handled) {
e.preventDefault();
}
}
});
// Retake button
container.addEventListener('click', function(e) {
var retake_btn = e.target.closest('.mc-retake-btn');
if (retake_btn) {
// Prevent accidental double-clicks (shared with capture button)
var now = Date.now();
var time_since_last = now - last_photo_action_time;
if (time_since_last < DOUBLE_CLICK_PREVENTION_MS && last_photo_action_time > 0) {
return; // Too soon after last photo action, ignore
}
last_photo_action_time = now;
// For photo mode, use the photo_retake_internal method
if (mode === 'photo') {
self.photo_retake_internal(container);
} else if (mode === 'audio') {
// For audio mode, use the audio_retake_internal method
self.audio_retake_internal(container);
} else {
// For video mode, handle video retake
// Backup the current blob and filename before clearing (for cancel functionality)
self.blob_backup = self.blob;
self.filename_backup = self.filename;
self.blob = null;
self.filename = null;
// Hide download option
self.hide_download_option(container);
// Store video URL for cancel functionality
self.video_url_backup = video_element.src;
// Reset recording state variables
is_recording = false;
media_recorder = null;
recorded_chunks = [];
if (self.timer_interval) {
clearInterval(self.timer_interval);
self.timer_interval = null;
}
// Clear the recorded video source
video_element.pause();
video_element.currentTime = 0;
video_element.src = '';
video_element.removeAttribute('data-actual-duration');
// Reset to muted autoplay for camera preview
video_element.muted = true;
video_element.autoplay = true;
// Reset progress bar using CSS variable
var progress_bar = container.querySelector(SELECTORS.PROGRESS);
if (progress_bar) {
progress_bar.style.setProperty('--mc-progress-percent', '0%');
}
var time_current = container.querySelector(SELECTORS.TIME_CURRENT);
var time_duration = container.querySelector(SELECTORS.TIME_DURATION);
if (time_current) time_current.textContent = '0:00';
if (time_duration) time_duration.textContent = '0:00';
// Reset play button
var play_btn = container.querySelector('.mc-play-btn');
if (play_btn) {
play_btn.innerHTML = self.config.icon_play;
play_btn.setAttribute('aria-label', self.config.label_play);
}
// Hide playback controls, show overlay controls
if (playback_controls) playback_controls.style.display = 'none';
if (overlay_controls) overlay_controls.style.display = '';
// Show cancel button and hide pause button in overlay controls
var cancel_btn = container.querySelector(SELECTORS.CANCEL_BTN);
var pause_btn = container.querySelector(SELECTORS.PAUSE_BTN);
if (cancel_btn) cancel_btn.style.display = '';
if (pause_btn) pause_btn.style.display = 'none';
// Reset timer display
var timer = container.querySelector(SELECTORS.TIMER);
if (timer) timer.classList.remove('mc-paused');
var timer_elapsed = container.querySelector('.mc-timer-elapsed');
if (timer_elapsed) timer_elapsed.textContent = '00:00';
// Reset remaining time if it exists
var timer_remaining = container.querySelector('.mc-timer-remaining');
if (timer_remaining) {
var initial_mins = Math.floor(max_duration / 60);
var initial_secs = max_duration % 60;
timer_remaining.textContent = self.pad_zero(initial_mins) + ':' + self.pad_zero(initial_secs);
}
if (settings_panel) settings_panel.style.display = 'none';
// Restart camera
self.stream_start_internal(
container,
video_element,
facing_mode,
video_width,
video_height,
mode,
self.selected_video_device,
self.selected_audio_device,
function(stream) {
self.stream = stream;
if (mode === 'video') {
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
audio_context = context;
analyser = analyser_node;
});
}
}
);
// Reset record button if video mode
var record_btn = container.querySelector(SELECTORS.RECORD_BTN);
if (record_btn) {
record_btn.innerHTML = self.config.icon_record;
record_btn.setAttribute('aria-label', self.config.label_start_recording);
record_btn.setAttribute('title', self.config.label_start_recording);
record_btn.classList.remove('mc-recording');
record_btn.classList.remove('mc-paused');
}
// Show flip button again if multiple cameras available
if (self.has_multiple_cameras) {
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = '';
}
// Move focus to record button for keyboard users
if (record_btn) record_btn.focus();
}
// Move focus to capture button for keyboard users (photo mode)
if (mode === 'photo') {
var capture_btn = container.querySelector('.mc-capture-btn');
if (capture_btn) capture_btn.focus();
}
}
});
// Cancel retake button (video/audio/photo modes)
container.addEventListener('click', function(e) {
var cancel_btn = e.target.closest('.mc-cancel-btn');
if (cancel_btn) {
if (mode === 'photo') {
// Photo mode: restore the backed up photo
self.photo_cancel_internal(container);
} else if (mode === 'audio') {
// Audio mode: restore the backed up audio
self.audio_cancel_internal(container);
} else {
// Video mode: restore the backed up video
self.video_cancel_internal(container);
}
}
});
// Camera flip button (mobile only)
container.addEventListener('click', function(e) {
var flip_btn = e.target.closest('.mc-flip-btn');
if (flip_btn) {
// Don't allow flipping if no stream or if no multiple cameras
if (!self.stream || !self.has_multiple_cameras) {
return;
}
// Don't allow flipping during recording - it causes playback issues
if (mode === 'video' && is_recording && media_recorder && media_recorder.state === 'recording') {
return;
}
self.stream_flip_internal(container, video_element, mode, function(new_stream) {
self.stream = new_stream;
if (mode === 'video') {
self.start_mic_monitoring(container, new_stream, function(context, analyser_node) {
audio_context = context;
analyser = analyser_node;
});
}
});
}
});
// Auto-hide controls functionality (not for audio mode)
if (controls_auto_hide && mode !== 'audio') {
var hide_timeout;
var controls_ready = false; // Flag to prevent hiding during initial 3 seconds
// Add auto-hide class to both preview and result containers
if (preview_container) preview_container.classList.add('mc-controls-autohide');
if (preview_container) preview_container.classList.add('mc-controls-autohide');
// Function to show controls
function show_controls() {
self.show_controls();
clearTimeout(hide_timeout);
}
// Function to hide controls immediately (only if ready)
function hide_controls() {
if (controls_ready) {
self.hide_controls();
clearTimeout(hide_timeout);
}
}
// Function to start the auto-hide timer (called after camera starts)
var start_autohide_timer = function() {
// Show controls initially and keep visible for controls_auto_hide_duration milliseconds
show_controls();
setTimeout(function() {
controls_ready = true;
// Hide if mouse is not over the container after controls_auto_hide_duration milliseconds
if (!preview_container.matches(':hover')) {
hide_controls();
}
}, controls_auto_hide_duration);
};
// Helper function to check if element is hovered
function is_hovered(element) {
if (!element) return false;
return element.matches(':hover');
}
// Show controls on mouse enter (both containers)
if (preview_container) {
preview_container.addEventListener('mouseenter', function() {
show_controls();
});
}
if (preview_container) {
preview_container.addEventListener('mouseenter', function() {
show_controls();
});
}
// Hide controls immediately on mouse leave (only after initial 3 seconds)
if (preview_container) {
preview_container.addEventListener('mouseleave', function() {
hide_controls();
});
}
// Show controls when any control gets focus (keep visible while focused)
if (overlay_controls) {
overlay_controls.addEventListener('focusin', function() {
show_controls();
});
}
if (playback_controls) {
playback_controls.addEventListener('focusin', function() {
show_controls();
});
}
if (result_controls) {
result_controls.addEventListener('focusin', function() {
show_controls();
});
}
// Hide controls when focus leaves (unless hovering)
if (overlay_controls) {
overlay_controls.addEventListener('focusout', function() {
if (!is_hovered(preview_container)) {
hide_controls();
}
});
}
if (playback_controls) {
playback_controls.addEventListener('focusout', function() {
if (!is_hovered(preview_container)) {
hide_controls();
}
});
}
if (result_controls) {
result_controls.addEventListener('focusout', function() {
if (!is_hovered(result_container)) {
hide_controls();
}
});
}
// If auto-start is enabled, start the timer immediately (camera already started)
if (auto_start) {
start_autohide_timer();
}
}
};
/**
* Destroy the MediaCapture instance
* Cleans up streams, elements, and event listeners
*/
MediaCapture.prototype.destroy = function() {
// Disconnect ResizeObserver
if (this.resize_observer) {
this.resize_observer.disconnect();
this.resize_observer = null;
}
// TODO: Implement additional cleanup logic
};
/**
* Dispatch a custom event from the MediaCapture element
* @param {string} event_name - Name of the event (without 'mc-' prefix)
* @param {Object} detail - Event detail data
* @private
*/
MediaCapture.prototype.dispatch_event = function(event_name, detail) {
// Merge the provided detail with the instance reference
var event_detail = Object.assign({}, detail || {}, {
instance: this
});
this.log('Dispatching event: mc-' + event_name, (detail ? detail : ''));
var event = new CustomEvent('mc-' + event_name, {
detail: event_detail,
bubbles: true,
cancelable: true
});
this.element.dispatchEvent(event);
};
/**
* Dispatch UI state change event and trigger callback
* @param {string} element_name - Name of UI element (flip, capture, retake, cancel, settings)
* @param {string} state - State 'on' or 'off'
* @private
*/
MediaCapture.prototype.dispatch_ui_state = function(element_name, state) {
var event_name = 'ui-' + element_name + '-' + state;
var callback_name = 'on_' + event_name.replace(/-/g, '_');
this.log('Dispatching UI state: ' + event_name);
// Dispatch event
this.dispatch_event(event_name);
// Trigger callback if provided
if (this.config[callback_name]) {
this.log('Executing callback: ' + callback_name);
this.config[callback_name]();
}
};
/**
* Enumerate media devices and populate menu items
* @private
*/
MediaCapture.prototype.enumerate_devices = function(container, mode) {
var self = this;
self.log('Enumerating media devices for mode: ' + mode);
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
self.log('enumerateDevices not available', 'warn');
return;
}
navigator.mediaDevices.enumerateDevices()
.then(function(devices) {
self.log('Found ' + devices.length + ' total media devices');
var video_devices_container = container.querySelector('.mc-video-devices');
var audio_devices_container = container.querySelector('.mc-audio-devices');
if (video_devices_container) {
// Clear existing items
video_devices_container.innerHTML = '';
var video_count = 1;
var video_devices = devices.filter(function(device) {
return device.kind === 'videoinput';
});
self.log('Found ' + video_devices.length + ' video input devices');
video_devices.forEach(function(device) {
var label = device.label || 'Camera ' + video_count++;
var is_selected = self.selected_video_device === device.deviceId ||
(!self.selected_video_device && video_count === 2);
var item_html = '<button type="button" class="mc-settings-menu-item' +
(is_selected ? ' mc-active' : '') +
'" role="menuitemradio" aria-checked="' + (is_selected ? 'true' : 'false') +
'" data-device-id="' + self.esc_attr(device.deviceId) +
'" data-device-type="video">';
item_html += '<span class="mc-settings-menu-label">' + self.esc_html(label) + '</span>';
item_html += '<span class="mc-settings-menu-check">' + self.config.icon_checkmark + '</span>';
item_html += '</button>';
video_devices_container.insertAdjacentHTML('beforeend', item_html);
});
}
if (audio_devices_container && (mode === 'video' || mode === 'audio')) {
// Clear existing items
audio_devices_container.innerHTML = '';
var audio_count = 1;
var audio_devices = devices.filter(function(device) {
return device.kind === 'audioinput';
});
self.log('Found ' + audio_devices.length + ' audio input devices');
audio_devices.forEach(function(device) {
var label = device.label || 'Microphone ' + audio_count++;
var is_selected = self.selected_audio_device === device.deviceId ||
(!self.selected_audio_device && audio_count === 2);
var item_html = '<button type="button" class="mc-settings-menu-item' +
(is_selected ? ' mc-active' : '') +
'" role="menuitemradio" aria-checked="' + (is_selected ? 'true' : 'false') +
'" data-device-id="' + self.esc_attr(device.deviceId) +
'" data-device-type="audio">';
item_html += '<span class="mc-settings-menu-label">' + self.esc_html(label) + '</span>';
item_html += '<span class="mc-settings-menu-check">' + self.config.icon_checkmark + '</span>';
item_html += '</button>';
audio_devices_container.insertAdjacentHTML('beforeend', item_html);
});
}
})
.catch(function(error) {
self.log('Error enumerating devices: ' + error.message, 'error');
});
};
/**
* Check if camera flip is available (multiple cameras on mobile)
* @private
*/
MediaCapture.prototype.check_camera_flip_availability = function(container) {
var self = this;
self.log('Checking camera flip availability');
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
self.log('enumerateDevices not available for camera flip check', 'warn');
return;
}
navigator.mediaDevices.enumerateDevices()
.then(function(devices) {
var video_devices = devices.filter(function(device) {
return device.kind === 'videoinput';
});
self.log('Camera flip check found ' + video_devices.length + ' video devices');
// If there are 2 or more video devices, show the flip button
if (video_devices.length >= 2) {
self.has_multiple_cameras = true;
self.log('Multiple cameras detected, showing flip button');
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) {
flip_btn.style.display = '';
}
// Dispatch UI state event
self.dispatch_ui_state('flip', 'on');
// Dispatch event when multiple cameras are detected
self.log('Firing cameras-detected event with count: ' + video_devices.length);
self.dispatch_event('cameras-detected', { count: video_devices.length });
// Trigger callback if provided
if (self.config.on_cameras_detected) {
self.config.on_cameras_detected(video_devices.length);
}
}
})
.catch(function(error) {
// Failed to enumerate devices - fail silently
});
};
/**
* Start microphone level monitoring
* @private
*/
MediaCapture.prototype.start_mic_monitoring = function(container, stream, callback) {
// Constants
var ANALYSER_SMOOTHING = 0.3;
var ANALYSER_FFT_SIZE = 256;
var PERCENTAGE_MAX = 100;
try {
var audio_context = new (window.AudioContext || window.webkitAudioContext)();
var analyser = audio_context.createAnalyser();
var microphone = audio_context.createMediaStreamSource(stream);
// More responsive settings
analyser.smoothingTimeConstant = ANALYSER_SMOOTHING; // Less smoothing for more responsiveness
analyser.fftSize = ANALYSER_FFT_SIZE; // Smaller size for faster response
microphone.connect(analyser);
var data_array = new Uint8Array(analyser.frequencyBinCount);
var level_bar = container.querySelector('.mc-mic-bar-fill');
function update_level() {
analyser.getByteTimeDomainData(data_array); // Use time domain for better voice detection
// Calculate RMS (Root Mean Square) for better level detection
var sum = 0;
for (var i = 0; i < data_array.length; i++) {
var normalized = (data_array[i] - 128) / 128; // Normalize to -1 to 1
sum += normalized * normalized;
}
var rms = Math.sqrt(sum / data_array.length);
var percent = Math.min(rms * 300, PERCENTAGE_MAX); // Amplify and cap at 100%
if (level_bar) {
level_bar.style.width = percent + '%';
}
requestAnimationFrame(update_level);
}
update_level();
if (callback) {
callback(audio_context, analyser);
}
} catch(error) {
// Failed to start mic monitoring - fail silently
}
};
/**
* Slide element up (jQuery-like animation)
* @private
*/
MediaCapture.prototype.slide_up = function(element, duration) {
if (!element) return;
// Check if element has position classes (popup style)
var is_popup = element.classList.contains('mc-position-above') || element.classList.contains('mc-position-below');
if (is_popup) {
// Use fade and scale for popup
element.style.transition = 'opacity ' + duration + 'ms ease, transform ' + duration + 'ms ease';
element.style.opacity = '0';
element.style.transform = 'scale(0.95)';
setTimeout(function() {
element.style.display = 'none';
element.style.opacity = '';
element.style.transform = '';
element.style.transition = '';
}, duration);
} else {
// Use original height animation
element.style.overflow = 'hidden';
element.style.transition = 'height ' + duration + 'ms ease';
element.style.height = element.scrollHeight + 'px';
// Force reflow
element.offsetHeight;
element.style.height = '0';
setTimeout(function() {
element.style.display = 'none';
element.style.height = '';
element.style.overflow = '';
element.style.transition = '';
}, duration);
}
};
/**
* Slide element down (jQuery-like animation)
* @private
*/
MediaCapture.prototype.slide_down = function(element, duration, callback) {
if (!element) return;
// Check if element has position classes (popup style)
var is_popup = element.classList.contains('mc-position-above') || element.classList.contains('mc-position-below');
if (is_popup) {
// Use fade and scale for popup
element.style.display = 'block';
element.style.opacity = '0';
element.style.transform = 'scale(0.95)';
// Force reflow
element.offsetHeight;
element.style.transition = 'opacity ' + duration + 'ms ease, transform ' + duration + 'ms ease';
element.style.opacity = '1';
element.style.transform = 'scale(1)';
setTimeout(function() {
element.style.opacity = '';
element.style.transform = '';
element.style.transition = '';
if (callback) callback();
}, duration);
} else {
// Use original height animation
element.style.display = 'block';
element.style.overflow = 'hidden';
element.style.height = '0';
var target_height = element.scrollHeight;
element.style.transition = 'height ' + duration + 'ms ease';
// Force reflow
element.offsetHeight;
element.style.height = target_height + 'px';
setTimeout(function() {
element.style.height = '';
element.style.overflow = '';
element.style.transition = '';
if (callback) callback();
}, duration);
}
};
/**
* Close settings panel
* @private
*/
MediaCapture.prototype.close_settings = function(container) {
var settings_panel = container.querySelector('.mc-settings');
var settings_btn = container.querySelector('.mc-settings-btn');
if (settings_panel) {
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (is_visible) {
this.settings_close_internal(container, settings_panel, settings_btn);
}
}
};
/**
* Start recording video from the media stream
* @param {MediaStream} media_stream - The media stream to record
* @param {HTMLElement} container - The container element
* @param {number} max_duration - Maximum recording duration in seconds
* @param {Function} recorder_callback - Callback function (recorder, timer_interval)
* @param {Function} data_callback - Callback function for data chunks
* @private
*/
MediaCapture.prototype.start_recording = function(media_stream, container, max_duration, recorder_callback, data_callback, mode, format) {
var self = this;
// Constants
var TIMER_UPDATE_INTERVAL = 1000;
var DURATION_UPDATE_INTERVAL = 100;
var MILLISECONDS_PER_SECOND = 1000;
// Determine MIME type based on capture mode and format
var mime_type;
if (mode === 'audio') {
mime_type = this.get_supported_audio_mime_type(format);
} else {
// Video mode
mime_type = this.get_supported_video_mime_type(format);
}
try {
var media_recorder = new MediaRecorder(media_stream, {
mimeType: mime_type
});
media_recorder.ondataavailable = function(event) {
if (event.data && event.data.size > 0) {
data_callback(event.data);
}
};
// Clear any existing error handler
media_recorder.onerror = null;
// Clear any existing stop handler
media_recorder.onstop = null;
media_recorder.start(DURATION_UPDATE_INTERVAL);
// Show recording timer
var timer = container.querySelector('.mc-timer');
if (timer) timer.style.display = '';
// Start timer and track actual recording duration
var seconds = 0;
var start_time = Date.now();
var timer_remaining_element = container.querySelector('.mc-timer-remaining');
var has_remaining_display = timer_remaining_element !== null;
var timer_interval = setInterval(function() {
seconds++;
var mins = Math.floor(seconds / 60);
var secs = seconds % 60;
// Update elapsed time
var timer_elapsed = container.querySelector('.mc-timer-elapsed');
if (timer_elapsed) {
timer_elapsed.textContent = self.pad_zero(mins) + ':' + self.pad_zero(secs);
}
// Calculate and update remaining time (only if element exists)
if (has_remaining_display) {
var remaining_seconds = max_duration - seconds;
if (remaining_seconds > 0) {
var remaining_mins = Math.floor(remaining_seconds / 60);
var remaining_secs = remaining_seconds % 60;
timer_remaining_element.textContent = self.pad_zero(remaining_mins) + ':' + self.pad_zero(remaining_secs);
} else {
timer_remaining_element.textContent = '00:00';
}
}
// Auto-stop at max duration
if (seconds >= max_duration) {
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) record_btn.click();
}
}, TIMER_UPDATE_INTERVAL);
// Store the recorded duration on the media_recorder object so we can access it later
// Use milliseconds for granular precision, convert to seconds with decimals
media_recorder.recorded_duration = 0;
media_recorder.start_time = start_time;
media_recorder.duration_interval = setInterval(function() {
var elapsed_ms = Date.now() - start_time;
media_recorder.recorded_duration = elapsed_ms / MILLISECONDS_PER_SECOND;
}, DURATION_UPDATE_INTERVAL);
if (recorder_callback) {
recorder_callback(media_recorder, timer_interval);
}
} catch (error) {
// Trigger error callback if provided
if (this.config.on_error) {
this.config.on_error(error);
}
}
};
/**
* Stop recording video and process the recorded data
* @param {MediaRecorder} media_recorder - The media recorder instance
* @param {number} timer_interval - The timer interval ID
* @param {Array} recorded_chunks - Array of recorded data chunks
* @param {HTMLElement} container - The container element
* @param {HTMLVideoElement} video_element - The video element
* @param {Function} callback - Callback function to execute after stopping
* @private
*/
MediaCapture.prototype.stop_recording = function(media_recorder, timer_interval, recorded_chunks, container, video_element, callback) {
var self = this;
if (media_recorder && media_recorder.state !== 'inactive') {
// Set up the onstop handler BEFORE calling stop()
media_recorder.onstop = function() {
// Use the MIME type from the MediaRecorder
var mime_type = media_recorder.mimeType || 'video/webm';
var blob = new Blob(recorded_chunks, { type: mime_type });
// Get the actual recorded duration from the media_recorder
var actual_duration = media_recorder.recorded_duration || 0;
// Clean up duration tracking interval
if (media_recorder.duration_interval) {
clearInterval(media_recorder.duration_interval);
media_recorder.duration_interval = null;
}
// Only handle video element if it exists (not in audio mode)
if (video_element) {
// Revoke old blob URL if it exists to free memory
if (video_element.src && video_element.src.startsWith('blob:')) {
URL.revokeObjectURL(video_element.src);
}
var video_url = URL.createObjectURL(blob);
// Switch video from live preview to recorded playback
video_element.srcObject = null; // Stop using camera stream
video_element.src = video_url;
video_element.muted = false; // Unmute for playback
video_element.autoplay = false; // Don't autoplay recorded video
video_element.controls = false; // Use custom controls
}
// Handle audio element if it exists (audio mode)
if (!video_element && self.audio_element) {
// Revoke old blob URL if it exists to free memory
if (self.audio_element.src && self.audio_element.src.startsWith('blob:')) {
URL.revokeObjectURL(self.audio_element.src);
}
var audio_url = URL.createObjectURL(blob);
// Switch audio from live recording to recorded playback
self.audio_element.srcObject = null; // Stop using microphone stream
self.audio_element.src = audio_url;
self.audio_element.autoplay = false; // Don't autoplay recorded audio
self.audio_element.controls = false; // Use custom controls
}
// Set the duration display immediately using the actual recorded duration
if (actual_duration > 0) {
var duration_text = self.format_time(actual_duration);
var time_duration = container.querySelector('.mc-time-duration');
if (time_duration) time_duration.textContent = duration_text;
// Store the actual duration on the media element for scrubbing (only if media element exists)
if (video_element) {
video_element.setAttribute('data-actual-duration', actual_duration.toString());
} else if (self.audio_element) {
self.audio_element.setAttribute('data-actual-duration', actual_duration.toString());
}
}
// Force media to load metadata (only if media element exists)
if (video_element) {
video_element.load();
} else if (self.audio_element) {
self.audio_element.load();
}
// Store blob and filename
var filename = 'video-' + Date.now() + '.webm';
self.blob = blob;
self.filename = filename;
// Wait for video to load metadata before getting dimensions
var set_metadata = function() {
// Get actual video dimensions (only if video element exists)
var video_width = null;
var video_height = null;
if (video_element) {
video_width = video_element.videoWidth || null;
video_height = video_element.videoHeight || null;
}
// If video element doesn't have dimensions, try to get from configured settings
if (!video_width && self.video_width) {
video_width = self.video_width;
}
if (!video_height && self.video_height) {
video_height = self.video_height;
}
// Create metadata JSON
var metadata = {
type: 'video',
width: video_width,
height: video_height,
duration: actual_duration,
size: blob.size,
format: 'webm',
timestamp: Date.now()
};
// Trigger callback with blob and filename
if (self.config.on_video_captured) {
self.config.on_video_captured(blob, filename, metadata);
}
// Show download option in settings if enabled
self.show_download_option(container);
};
// Try to get dimensions immediately (might work if already loaded)
if (video_element && video_element.videoWidth && video_element.videoHeight) {
set_metadata();
} else if (video_element) {
// Otherwise wait for loadedmetadata event
video_element.addEventListener('loadedmetadata', set_metadata, { once: true });
} else {
// No video element, call set_metadata directly (audio mode)
set_metadata();
}
// Switch from recording controls to playback controls
var overlay_controls = container.querySelector('.mc-capture-controls');
var playback_controls = container.querySelector('.mc-result-controls');
var settings_panel = container.querySelector('.mc-settings');
if (overlay_controls) overlay_controls.style.display = 'none';
if (playback_controls) playback_controls.style.display = '';
if (settings_panel) settings_panel.style.display = 'none';
// Move focus to play button for keyboard users
var play_btn = container.querySelector('.mc-play-btn');
if (play_btn) play_btn.focus();
// Clear the event handlers to prevent memory leaks
media_recorder.ondataavailable = null;
media_recorder.onstop = null;
media_recorder.onerror = null;
if (callback) {
callback();
}
};
media_recorder.stop();
// Stop timer and hide it
clearInterval(timer_interval);
var timer = container.querySelector('.mc-timer');
if (timer) timer.style.display = 'none';
var timer_elapsed = container.querySelector('.mc-timer-elapsed');
if (timer_elapsed) timer_elapsed.textContent = '00:00';
// Reset remaining time if it exists
var timer_remaining = container.querySelector('.mc-timer-remaining');
if (timer_remaining) {
var initial_mins = Math.floor(self.config.max_duration / 60);
var initial_secs = self.config.max_duration % 60;
timer_remaining.textContent = self.pad_zero(initial_mins) + ':' + self.pad_zero(initial_secs);
}
// Stop camera stream
self.stream_stop_internal(video_element);
}
};
/**
* Start the camera stream
* @param {HTMLElement} container - The container element
* @param {HTMLVideoElement} video_element - The video element
* @param {string} facing_mode - Camera facing mode ('user' or 'environment')
* @param {number} video_width - Desired video width (optional)
* @param {number} video_height - Desired video height (optional)
* @param {string} mode - Capture mode ('photo' or 'video')
* @param {string} video_device_id - Specific video device ID (optional)
* @param {string} audio_device_id - Specific audio device ID (optional)
* @param {Function} callback - Success callback function
* @param {Function} error_callback - Error callback function
* @private
*/
MediaCapture.prototype.stream_start_internal = function(container, video_element, facing_mode, video_width, video_height, mode, video_device_id, audio_device_id, callback, error_callback) {
var self = this;
// Track getUserMedia calls
if (!window._mc_get_user_media_count) {
window._mc_get_user_media_count = 0;
}
window._mc_get_user_media_count++;
self.log('[stream_start_internal] getUserMedia call #' + window._mc_get_user_media_count);
self.log('[stream_start_internal] Called with mode:', mode);
var video_constraints = {};
// Only set width/height if they are provided (not empty/blank)
if (video_width && video_height) {
video_constraints.width = { ideal: parseInt(video_width, 10) };
video_constraints.height = { ideal: parseInt(video_height, 10) };
}
// Use specific device if selected, otherwise use facing mode
if (video_device_id) {
video_constraints.deviceId = { exact: video_device_id };
} else if (facing_mode) {
video_constraints.facingMode = facing_mode;
}
var constraints = {
video: mode === 'audio' ? false : video_constraints,
audio: mode === 'video' || mode === 'audio'
};
// Use specific audio device if selected (for video or audio mode)
if ((mode === 'video' || mode === 'audio') && audio_device_id) {
constraints.audio = { deviceId: { exact: audio_device_id } };
}
self.log('[stream_start_internal] About to call getUserMedia with constraints:', JSON.stringify(constraints));
navigator.mediaDevices.getUserMedia(constraints)
.then(function(stream) {
self.log('[stream_start_internal] getUserMedia SUCCESS, stream:', stream);
self.log('[stream_start_internal] Stream tracks:', stream.getTracks().length);
// Reset recording control UI states immediately after stream starts
// This ensures pause/resume buttons are properly disabled
if (mode === 'video' || mode === 'audio') {
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
}
// Only set srcObject for video element if it exists (not in audio mode)
if (video_element) {
video_element.srcObject = stream;
// CRITICAL: Ensure video preview is muted to prevent audio feedback
video_element.muted = true;
}
var preview_container = container.querySelector('.mc-capture');
if (preview_container) preview_container.style.display = '';
// Trigger callback if provided
if (self.config.on_stream_started) {
self.config.on_stream_started(stream);
}
// Auto-start QR scanning in photo mode if enabled
if (mode === 'photo' && self.config.qr_scan_enabled) {
self.qr_scan_start();
}
if (callback) {
callback(stream);
}
})
.catch(function(error) {
self.log('[stream_start_internal] getUserMedia FAILED:', error, 'error');
self.log('[stream_start_internal] Error name:', error.name, 'error');
self.log('[stream_start_internal] Error message:', error.message, 'error');
// Trigger error callback if provided
if (self.config.on_error) {
self.config.on_error(error);
}
if (error_callback) {
error_callback(error);
}
});
};
/**
* Stop the camera stream
* @param {HTMLVideoElement} video_element - The video element (can be null for audio mode)
* @private
*/
MediaCapture.prototype.stream_stop_internal = function(video_element) {
var self = this;
self.log('[stream_stop_internal] Called');
self.log('[stream_stop_internal] video_element:', video_element);
self.log('[stream_stop_internal] video_element.srcObject:', video_element ? video_element.srcObject : 'N/A');
if (video_element && video_element.srcObject) {
var tracks = video_element.srcObject.getTracks();
self.log('[stream_stop_internal] Stopping ' + tracks.length + ' tracks');
tracks.forEach(function(track) {
self.log('[stream_stop_internal] Stopping track:', track.kind, track.label, 'state:', track.readyState);
track.stop();
});
video_element.srcObject = null;
self.log('[stream_stop_internal] Tracks stopped and srcObject cleared');
// Stop QR scanning if active
if (self.qr_scan_active) {
self.qr_scan_stop();
}
// Trigger callback if provided
if (self.config.on_stream_stopped) {
self.config.on_stream_stopped();
}
} else {
self.log('[stream_stop_internal] No video element or srcObject to stop');
}
};
/**
* Countdown timer with visual and audio feedback
* @param {HTMLElement} countdown_overlay - The countdown overlay element
* @param {number} countdown_duration - Duration in seconds
* @param {boolean} sound_enabled - Whether to play beep sounds
* @param {number} beep_frequency - Frequency of beep sound in Hz
* @param {AudioContext} beep_audio_context - Audio context for beeps
* @param {Function} callback - Callback when countdown completes
* @private
*/
MediaCapture.prototype.countdown = function(countdown_overlay, countdown_duration, sound_enabled, beep_frequency, beep_audio_context, countdown_sound_url, callback) {
var count = countdown_duration;
var countdown_number = countdown_overlay.querySelector('.mc-countdown-number');
var countdown_sr = countdown_overlay.querySelector('.mc-countdown-sr');
// Number to word mapping for screen readers
var number_words = {
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six',
7: 'seven',
8: 'eight',
9: 'nine',
10: 'ten'
};
// Play beep function
var play_beep = function() {
if (!sound_enabled) { return; }
// If custom countdown sound URL is provided, use it
if (countdown_sound_url) {
try {
var audio = new Audio(countdown_sound_url);
audio.volume = 0.5; // 50% volume
audio.play().catch(function(error) {
// Error playing sound - fail silently
});
} catch(e) {
// Error creating audio - fail silently
}
return;
}
// Otherwise use synthetic beep
if (!beep_audio_context) { return; }
try {
var oscillator = beep_audio_context.createOscillator();
var gain_node = beep_audio_context.createGain();
oscillator.connect(gain_node);
gain_node.connect(beep_audio_context.destination);
oscillator.frequency.value = beep_frequency;
oscillator.type = 'sine';
gain_node.gain.setValueAtTime(0.3, beep_audio_context.currentTime);
gain_node.gain.exponentialRampToValueAtTime(0.01, beep_audio_context.currentTime + 0.2);
oscillator.start(beep_audio_context.currentTime);
oscillator.stop(beep_audio_context.currentTime + 0.2);
} catch(e) {
// Error playing beep - fail silently
}
};
// Initial announcement and beep (but not on the last second)
if (countdown_number) countdown_number.textContent = count;
if (countdown_sr) countdown_sr.textContent = number_words[count] || count;
if (countdown_overlay) countdown_overlay.style.display = '';
// Play beep for initial count if it's not 1
if (count > 0) {
play_beep();
}
var countdown_interval = setInterval(function() {
count--;
if (count > 0) {
if (countdown_number) countdown_number.textContent = count;
if (countdown_sr) countdown_sr.textContent = number_words[count] || count;
// Play beep only if count is greater than 1 (skip the last second)
if (count > 0) {
play_beep();
}
} else {
// Clear visual and announce completion
if (countdown_number) countdown_number.textContent = '';
if (countdown_sr) countdown_sr.textContent = 'Capturing now';
// Clean up after a brief delay to ensure announcement
setTimeout(function() {
if (countdown_sr) countdown_sr.textContent = '';
clearInterval(countdown_interval);
if (countdown_overlay) countdown_overlay.style.display = 'none';
if (callback) {
callback();
}
}, 100);
}
}, 1000);
};
/**
* Play camera shutter sound effect
* @param {boolean} photo_sound_enabled - Whether sound is enabled
* @private
*/
MediaCapture.prototype.play_photo_sound = function(photo_sound_enabled) {
// Only play if enabled
if (!photo_sound_enabled) { return; }
try {
// Check if photo_sound_url is configured
var photo_sound_url = this.config.photo_sound_url || '';
if (photo_sound_url) {
// Use external MP3 file
var audio = new Audio(photo_sound_url);
audio.volume = 0.5; // 50% volume
audio.play().catch(function(error) {
// Error playing sound - fail silently
});
} else {
// Fallback: generate synthetic shutter sound using Web Audio API
var audio_context = new (window.AudioContext || window.webkitAudioContext)();
// Create a short "click" sound (simulates mechanical shutter)
var oscillator = audio_context.createOscillator();
var gain_node = audio_context.createGain();
oscillator.connect(gain_node);
gain_node.connect(audio_context.destination);
// High frequency click
oscillator.frequency.value = 1200;
oscillator.type = 'sine';
// Quick envelope for sharp click
var now = audio_context.currentTime;
gain_node.gain.setValueAtTime(0.3, now);
gain_node.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
oscillator.start(now);
oscillator.stop(now + 0.05);
}
} catch(e) {
// Error playing photo sound - fail silently
}
};
/**
* Capture a photo from the video stream
* @param {HTMLVideoElement} video_element - The video element
* @param {HTMLCanvasElement} canvas_element - The canvas element
* @param {HTMLImageElement} result_photo - The result image element
* @param {HTMLElement} container - The container element
* @param {HTMLElement} result_container - The result container element
* @param {number} photo_quality - Image quality (0.1 to 1.0)
* @param {string} photo_format - Image format ('jpeg', 'png', or 'webp')
* @private
*/
MediaCapture.prototype.photo_capture_internal = function(video_element, canvas_element, result_photo, container, result_container, photo_quality, photo_format) {
var self = this;
// Play photo sound if enabled
this.play_photo_sound(this.config.photo_sound_enabled);
// Stop QR scanning if active (video is about to be hidden)
if (this.qr_scan_active) {
this.qr_scan_stop();
}
canvas_element.width = video_element.videoWidth;
canvas_element.height = video_element.videoHeight;
var context = canvas_element.getContext('2d');
context.drawImage(video_element, 0, 0);
var mime_type = this.get_supported_photo_mime_type(photo_format);
// Convert canvas to blob with specified mime type and quality
canvas_element.toBlob(function(blob) {
// Create object URL for preview
var object_url = URL.createObjectURL(blob);
result_photo.src = object_url;
// Store blob and filename
var filename = 'photo-' + Date.now() + '.' + photo_format;
self.blob = blob;
self.filename = filename;
// Create metadata JSON
var metadata = {
type: 'photo',
width: canvas_element.width,
height: canvas_element.height,
size: blob.size,
format: photo_format,
quality: photo_quality,
timestamp: Date.now()
};
// Trigger callback with blob and filename
if (self.config.on_photo_captured) {
self.config.on_photo_captured(blob, filename, metadata);
}
// Show download option in settings if enabled
self.show_download_option(container);
// Show result container and hide video/controls, show player/result controls
var preview_container = container.querySelector('.mc-capture');
var settings_panel = container.querySelector('.mc-settings');
var video_element_dom = container.querySelector('.mc-video');
var player_element = container.querySelector('.mc-result');
var overlay_controls = container.querySelector('.mc-capture-controls');
if (preview_container) preview_container.style.display = '';
if (settings_panel) settings_panel.style.display = 'none';
if (video_element_dom) video_element_dom.style.display = 'none';
if (player_element) player_element.style.display = '';
if (overlay_controls) overlay_controls.style.display = 'none';
// Hide flip button when viewing result
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Dispatch UI state event
self.dispatch_ui_state('flip', 'off');
// Dispatch UI state events for photo buttons
self.dispatch_ui_state('capture', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
// Dispatch result-show event
self.dispatch_event('result-show');
// Trigger callback if provided
if (self.config.on_result_show) {
self.config.on_result_show();
}
// Trigger flash effect in result container - start at full white
var flash_overlay = preview_container.querySelector('.mc-flash');
if (flash_overlay) {
// Make sure it starts at full opacity immediately
flash_overlay.classList.add('mc-flash-active');
// Use a tiny delay to ensure the DOM is updated before starting fade
setTimeout(function() {
flash_overlay.classList.remove('mc-flash-active');
}, 50);
}
// Move focus to retake button for keyboard users
var retake_btn = container.querySelector('.mc-retake-btn');
if (retake_btn) retake_btn.focus();
// Announce to screen readers
self.announce_to_screen_reader('Photo captured');
}, mime_type, photo_quality);
};
/**
* Retake a photo (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} result_container - The result container element
* @param {HTMLElement} preview_container - The preview container element
* @private
*/
MediaCapture.prototype.photo_retake_internal = function(container) {
var self = this;
// Backup the current blob and filename before clearing (for cancel functionality)
self.blob_backup = self.blob;
self.filename_backup = self.filename;
self.blob = null;
self.filename = null;
// Hide download option
self.hide_download_option(container);
// Hide result and show preview container
var video_element_dom = container.querySelector('.mc-video');
var player_element = container.querySelector('.mc-result');
var overlay_controls = container.querySelector('.mc-capture-controls');
if (video_element_dom) video_element_dom.style.display = '';
if (player_element) player_element.style.display = 'none';
if (overlay_controls) overlay_controls.style.display = '';
// Dispatch result-hide event
self.dispatch_event('result-hide');
// Trigger callback if provided
if (self.config.on_result_hide) {
self.config.on_result_hide();
}
// Show cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = '';
// Show flip button again if multiple cameras available
if (self.has_multiple_cameras) {
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = '';
// Dispatch UI state event
self.dispatch_ui_state('flip', 'on');
}
// Dispatch UI state events for photo buttons
self.dispatch_ui_state('capture', 'on');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'on');
// Hide settings panel
var settings_panel = container.querySelector('.mc-settings');
if (settings_panel) settings_panel.style.display = 'none';
// Dispatch custom event
self.dispatch_event('photo-retake');
// Trigger callback if provided
if (self.config.on_photo_retake) {
self.config.on_photo_retake();
}
// Resume QR scanning if enabled (stream is still active after retake)
if (self.config.qr_scan_enabled && !self.qr_scan_active && self.stream && self.stream.active) {
self.qr_scan_start();
}
};
/**
* Cancel photo retake (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.photo_cancel_internal = function(container) {
var self = this;
// Restore the backed up blob and filename
self.blob = self.blob_backup;
self.filename = self.filename_backup;
// Restore the photo in the result image
var result_photo = container.querySelector('.mc-photo');
if (result_photo && self.blob) {
var object_url = URL.createObjectURL(self.blob);
result_photo.src = object_url;
}
// Show result container, hide preview container
var preview_container = container.querySelector('.mc-preview');
if (preview_container) preview_container.style.display = 'none';
// Dispatch result-show event
self.dispatch_event('result-show');
// Trigger callback if provided
if (self.config.on_result_show) {
self.config.on_result_show();
}
// Hide cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = 'none';
// Hide flip button when viewing result
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Dispatch UI state event
self.dispatch_ui_state('flip', 'off');
// Dispatch UI state events for photo buttons
self.dispatch_ui_state('capture', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
// Clear backup data
self.blob_backup = null;
self.filename_backup = null;
// Dispatch custom event
self.dispatch_event('photo-cancel');
// Trigger callback if provided
if (self.config.on_photo_cancel) {
self.config.on_photo_cancel();
}
// Move focus to retake button for keyboard users
var retake_btn = container.querySelector('.mc-retake-btn');
if (retake_btn) retake_btn.focus();
};
/**
* Start video recording (internal method)
* @param {HTMLElement} container - The container element
* @param {MediaStream} stream - The media stream
* @param {number} max_duration - Maximum video duration in seconds
* @private
*/
MediaCapture.prototype.video_start_internal = function(container, stream, max_duration) {
var self = this;
// Mark as recording
self.is_recording = true;
self.recorded_chunks = [];
// Start the recording
self.start_recording(
stream,
container,
max_duration,
function(recorder, interval) {
self.media_recorder = recorder;
self.timer_interval = interval;
},
function(chunk) {
self.recorded_chunks.push(chunk);
},
'video',
self.config.video_format
);
// Update record button to stop state
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) {
record_btn.innerHTML = self.config.icon_stop;
record_btn.setAttribute('aria-label', self.config.label_stop);
record_btn.setAttribute('title', self.config.label_stop);
record_btn.classList.add('mc-recording');
}
// Show pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) pause_btn.style.display = '';
// Hide flip button during recording
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Dispatch UI state events
self.dispatch_ui_state('flip', 'off');
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'on');
self.dispatch_ui_state('pause', 'on');
// Dispatch custom event
self.dispatch_event('video-start');
// Trigger callback if provided
if (self.config.on_video_started) {
self.config.on_video_started();
}
// Announce to screen readers
self.announce_to_screen_reader('Recording started');
};
/**
* Stop video recording (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLVideoElement} video_element - The video element
* @param {Function} callback - Callback function when stop completes
* @private
*/
MediaCapture.prototype.video_stop_internal = function(container, video_element, callback) {
var self = this;
self.stop_recording(
self.media_recorder,
self.timer_interval,
self.recorded_chunks,
container,
video_element,
function() {
self.media_recorder = null;
self.timer_interval = null;
self.is_recording = false;
self.stream = null;
// Hide and reset pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) {
pause_btn.style.display = 'none';
pause_btn.classList.remove('mc-paused');
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
}
// Remove paused class from timer
var timer = container.querySelector('.mc-timer');
if (timer) timer.classList.remove('mc-paused');
// Re-enable all settings buttons after recording stops
var settings_btns = container.querySelectorAll('.mc-settings-btn');
settings_btns.forEach(function(btn) {
btn.disabled = false;
});
// Dispatch UI state events
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'on');
self.dispatch_ui_state('playback-stop', 'off');
self.dispatch_ui_state('settings-open', 'on');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('video-stop', {
blob: self.blob,
filename: self.filename,
duration: self.media_recorder ? self.media_recorder.recorded_duration : 0
});
// Trigger callback if provided
if (self.config.on_video_stopped) {
self.config.on_video_stopped(self.blob, self.filename);
}
// Announce to screen readers
self.announce_to_screen_reader('Recording stopped');
if (callback) {
callback();
}
}
);
};
/**
* Pause video recording (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.video_pause_internal = function(container) {
var self = this;
var pause_btn = container.querySelector('.mc-pause-btn');
var timer = container.querySelector('.mc-timer');
// Pause recording
self.media_recorder.pause();
// Pause the timer intervals
if (self.timer_interval) {
clearInterval(self.timer_interval);
self.timer_interval = null;
}
if (self.media_recorder.duration_interval) {
clearInterval(self.media_recorder.duration_interval);
self.media_recorder.duration_interval = null;
}
// Store the paused time for resume
self.media_recorder.paused_time = Date.now();
// Update button to resume/play state
if (pause_btn) {
pause_btn.innerHTML = self.config.icon_play;
pause_btn.setAttribute('aria-label', self.config.label_resume);
pause_btn.setAttribute('title', self.config.label_resume);
pause_btn.classList.add('mc-paused');
}
// Add paused class to timer for visual feedback
if (timer) timer.classList.add('mc-paused');
// Dispatch UI state events
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'on');
// Dispatch custom event
self.dispatch_event('video-pause');
// Trigger callback if provided
if (self.config.on_video_paused) {
self.config.on_video_paused();
}
// Announce to screen readers
self.announce_to_screen_reader('Recording paused');
};
/**
* Resume video recording (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.video_resume_internal = function(container) {
var self = this;
var pause_btn = container.querySelector('.mc-pause-btn');
var timer = container.querySelector('.mc-timer');
var TIMER_UPDATE_INTERVAL = 1000;
var DURATION_UPDATE_INTERVAL = 100;
var MILLISECONDS_PER_SECOND = 1000;
var SELECTORS = { RECORD_BTN: '.mc-record-btn' };
// Resume recording
self.media_recorder.resume();
// Calculate time elapsed during pause and adjust start_time
var pause_duration = Date.now() - self.media_recorder.paused_time;
self.media_recorder.start_time += pause_duration;
// Resume the timer display
var current_seconds = Math.floor((Date.now() - self.media_recorder.start_time) / 1000);
var timer_remaining_element = container.querySelector('.mc-timer-remaining');
var has_remaining_display = timer_remaining_element !== null;
var max_duration = self.config.max_duration;
self.timer_interval = setInterval(function() {
// Safety check: if media_recorder is null, clear interval and stop
if (!self.media_recorder) {
clearInterval(self.timer_interval);
return;
}
current_seconds++;
var mins = Math.floor(current_seconds / 60);
var secs = current_seconds % 60;
// Update elapsed time only
var timer_elapsed = container.querySelector('.mc-timer-elapsed');
if (timer_elapsed) {
timer_elapsed.textContent = self.pad_zero(mins) + ':' + self.pad_zero(secs);
}
// Update remaining time if element exists
if (has_remaining_display) {
var remaining_seconds = max_duration - current_seconds;
if (remaining_seconds > 0) {
var remaining_mins = Math.floor(remaining_seconds / 60);
var remaining_secs = remaining_seconds % 60;
timer_remaining_element.textContent = self.pad_zero(remaining_mins) + ':' + self.pad_zero(remaining_secs);
} else {
timer_remaining_element.textContent = '00:00';
}
}
// Auto-stop at max duration
if (current_seconds >= max_duration) {
var record_btn = container.querySelector(SELECTORS.RECORD_BTN);
if (record_btn) record_btn.click();
}
}, TIMER_UPDATE_INTERVAL);
// Resume duration tracking
var duration_interval = setInterval(function() {
// Safety check: if media_recorder is null, clear interval and stop
if (!self.media_recorder) {
clearInterval(duration_interval);
return;
}
var elapsed_ms = Date.now() - self.media_recorder.start_time;
self.media_recorder.recorded_duration = elapsed_ms / MILLISECONDS_PER_SECOND;
}, DURATION_UPDATE_INTERVAL);
self.media_recorder.duration_interval = duration_interval;
// Update button back to pause state
if (pause_btn) {
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
pause_btn.classList.remove('mc-paused');
}
// Remove paused class from timer
if (timer) timer.classList.remove('mc-paused');
// Dispatch UI state events
self.dispatch_ui_state('pause', 'on');
self.dispatch_ui_state('resume', 'off');
// Dispatch custom event
self.dispatch_event('video-resume');
// Trigger callback if provided
if (self.config.on_video_resumed) {
self.config.on_video_resumed();
}
// Announce to screen readers
self.announce_to_screen_reader('Recording resumed');
};
/**
* Retake video (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} result_container - The result container element
* @param {HTMLElement} preview_container - The preview container element
* @private
*/
MediaCapture.prototype.video_retake_internal = function(container, result_container, preview_container) {
var self = this;
self.log('[video_retake_internal] Starting retake');
self.log('[video_retake_internal] Current stream:', self.stream);
self.log('[video_retake_internal] Current media_recorder:', self.media_recorder);
self.log('[video_retake_internal] is_recording:', self.is_recording);
// Backup the current blob, filename, and video URL before clearing (for cancel functionality)
self.blob_backup = self.blob;
self.filename_backup = self.filename;
// Backup the current video URL
var video_element = self.video_element;
self.video_url_backup = video_element.src;
self.blob = null;
self.filename = null;
// Hide result and show preview container
if (preview_container) preview_container.style.display = 'none';
if (preview_container) preview_container.style.display = '';
// Dispatch result-hide event
self.dispatch_event('result-hide');
// Trigger callback if provided
if (self.config.on_result_hide) {
self.config.on_result_hide();
}
// Show cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = '';
// Reset video element back to live preview mode
video_element.src = '';
video_element.srcObject = null;
video_element.muted = true;
video_element.autoplay = true;
video_element.controls = false;
// Hide playback controls (result controls)
var playback_controls = container.querySelector('.mc-result-controls');
if (playback_controls) {
playback_controls.style.display = 'none';
playback_controls.classList.remove('mc-controls-visible');
}
// Show overlay controls
var overlay_controls = container.querySelector('.mc-capture-controls');
if (overlay_controls) {
overlay_controls.style.display = '';
}
// Reset and show record button
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) {
record_btn.innerHTML = self.config.icon_record;
record_btn.setAttribute('aria-label', self.config.label_start_recording);
record_btn.setAttribute('title', self.config.label_start_recording);
record_btn.classList.remove('mc-recording');
record_btn.style.display = '';
}
self.log('[video_retake_internal] Calling stream_start_internal');
// Restart the camera stream for re-recording
self.stream_start_internal(
container,
video_element,
self.config.facing_mode,
self.video_width,
self.video_height,
self.config.mode,
self.selected_video_device,
self.selected_audio_device,
function(stream) {
self.log('[video_retake_internal] stream_start_internal SUCCESS, stream:', stream);
self.stream = stream;
// Start mic monitoring for video mode
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
// Store references if needed
});
// Show controls for overlay
self.show_controls();
// Ensure playback controls remain hidden (show_controls adds the class to all)
var pb_controls = container.querySelector('.mc-result-controls');
if (pb_controls) {
pb_controls.style.display = 'none';
pb_controls.classList.remove('mc-controls-visible');
}
// Show flip button again if multiple cameras available
if (self.has_multiple_cameras) {
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = '';
// Dispatch UI state event
self.dispatch_ui_state('flip', 'on');
}
// Dispatch UI state events for video buttons
self.dispatch_ui_state('start', 'on');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'on');
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'off');
self.dispatch_ui_state('playback-stop', 'off');
self.log('[video_retake_internal] Retake complete, ready to record');
},
function(error) {
self.log('[video_retake_internal] stream_start_internal FAILED:', error, 'error');
}
);
// Hide settings panel
var settings_panel = container.querySelector('.mc-settings');
if (settings_panel) settings_panel.style.display = 'none';
// Dispatch custom event
self.dispatch_event('video-retake');
// Trigger callback if provided
if (self.config.on_video_retake) {
self.config.on_video_retake();
}
};
/**
* Cancel video retake (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} result_container - The result container element
* @param {HTMLElement} preview_container - The preview container element
* @private
*/
MediaCapture.prototype.video_cancel_internal = function(container) {
var self = this;
self.log('[video_cancel_internal] Starting cancel');
self.log('[video_cancel_internal] Current stream:', self.stream);
self.log('[video_cancel_internal] Current media_recorder:', self.media_recorder);
self.log('[video_cancel_internal] is_recording:', self.is_recording);
// Stop the camera stream
self.log('[video_cancel_internal] Stopping stream');
self.stream_stop_internal(self.video_element);
self.stream = null;
// CRITICAL: Reset recording state when canceling
self.is_recording = false;
self.media_recorder = null;
self.timer_interval = null;
// Restore the backed up blob and filename
self.blob = self.blob_backup;
self.filename = self.filename_backup;
self.log('[video_cancel_internal] Restored blob:', self.blob);
self.log('[video_cancel_internal] Restored video URL:', self.video_url_backup);
self.log('[video_cancel_internal] Reset is_recording to:', self.is_recording);
// Restore the video in the video element (stays in preview container)
var video_element = self.video_element;
if (video_element && self.video_url_backup) {
video_element.autoplay = false; // Don't autoplay when restoring previous recording
video_element.src = self.video_url_backup;
video_element.load();
}
// For video mode, keep preview container visible (video element is there)
// Just switch from overlay controls to playback controls
var overlay_controls = container.querySelector('.mc-capture-controls');
var playback_controls = container.querySelector('.mc-result-controls');
if (overlay_controls) overlay_controls.style.display = 'none';
if (playback_controls) playback_controls.style.display = '';
var preview_container = container.querySelector('.mc-preview');
if (preview_container) preview_container.style.display = '';
// Dispatch result-show event
self.dispatch_event('result-show');
// Trigger callback if provided
if (self.config.on_result_show) {
self.config.on_result_show();
}
// Hide cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = 'none';
// Hide flip button when viewing result
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Dispatch UI state event
self.dispatch_ui_state('flip', 'off');
// Dispatch UI state events for video buttons
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'on');
self.dispatch_ui_state('playback-stop', 'on');
// Clear backup data
self.blob_backup = null;
self.filename_backup = null;
self.video_url_backup = null;
// Dispatch custom event
self.dispatch_event('video-cancel');
// Trigger callback if provided
if (self.config.on_video_cancel) {
self.config.on_video_cancel();
}
// Move focus to retake button for keyboard users
var retake_btn = container.querySelector('.mc-retake-btn');
if (retake_btn) retake_btn.focus();
self.log('[video_cancel_internal] Cancel complete');
};
/**
* Start audio recording (internal method)
* @param {HTMLElement} container - The container element
* @param {MediaStream} stream - The media stream
* @param {number} max_duration - Maximum recording duration
* @private
*/
MediaCapture.prototype.audio_start_internal = function(container, stream, max_duration) {
var self = this;
// Mark as recording
self.is_recording = true;
self.recorded_chunks = [];
// Start the recording
self.start_recording(
stream,
container,
max_duration,
function(recorder, interval) {
self.media_recorder = recorder;
self.timer_interval = interval;
},
function(chunk) {
self.recorded_chunks.push(chunk);
},
'audio',
self.config.audio_format
);
// Update record button to stop state
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) {
record_btn.innerHTML = self.config.icon_stop;
record_btn.setAttribute('aria-label', self.config.label_stop);
record_btn.setAttribute('title', self.config.label_stop);
record_btn.classList.add('mc-recording');
}
// Show pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) pause_btn.style.display = '';
// Disable all settings buttons during recording
self.log('[audio_start_internal] Disabling settings buttons');
var settings_btns = container.querySelectorAll('.mc-settings-btn');
self.log('[audio_start_internal] Found', settings_btns.length, 'settings buttons');
settings_btns.forEach(function(btn, index) {
self.log('[audio_start_internal] Button', index, '- before:', btn.disabled, 'parent:', btn.parentElement.className);
btn.disabled = true;
self.log('[audio_start_internal] Button', index, '- after:', btn.disabled);
});
// Dispatch UI state events
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'on');
self.dispatch_ui_state('pause', 'on');
self.dispatch_ui_state('settings-open', 'off');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('audio-start');
// Trigger callback if provided
if (self.config.on_audio_started) {
self.config.on_audio_started();
}
// Announce to screen readers
self.announce_to_screen_reader('Recording started');
};
/**
* Stop audio recording (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLAudioElement} audio_element - The audio element
* @param {Function} callback - Callback function when stop completes
* @private
*/
MediaCapture.prototype.audio_stop_internal = function(container, audio_element, callback) {
var self = this;
self.stop_recording(
self.media_recorder,
self.timer_interval,
self.recorded_chunks,
container,
null, // No video element for audio mode
function() {
self.media_recorder = null;
self.timer_interval = null;
self.is_recording = false;
self.stream = null;
// Hide and reset pause button
var pause_btn = container.querySelector('.mc-pause-btn');
if (pause_btn) {
pause_btn.style.display = 'none';
pause_btn.classList.remove('mc-paused');
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
}
// Remove paused class from timer
var timer = container.querySelector('.mc-timer');
if (timer) timer.classList.remove('mc-paused');
// Re-enable all settings buttons after recording stops
self.log('[audio_stop_internal] Re-enabling settings buttons');
var settings_btns = container.querySelectorAll('.mc-settings-btn');
self.log('[audio_stop_internal] Found', settings_btns.length, 'settings buttons');
settings_btns.forEach(function(btn, index) {
self.log('[audio_stop_internal] Button', index, '- before:', btn.disabled, 'parent:', btn.parentElement.className);
btn.disabled = false;
self.log('[audio_stop_internal] Button', index, '- after:', btn.disabled);
});
// Dispatch UI state events
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'on');
self.dispatch_ui_state('playback-stop', 'off');
self.dispatch_ui_state('settings-open', 'on');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('audio-stop', {
blob: self.blob,
filename: self.filename,
duration: self.media_recorder ? self.media_recorder.recorded_duration : 0
});
// Trigger callback if provided
if (self.config.on_audio_stopped) {
self.config.on_audio_stopped(self.blob, self.filename);
}
// Announce to screen readers
self.announce_to_screen_reader('Recording stopped');
if (callback) {
callback();
}
}
);
};
/**
* Pause audio recording (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.audio_pause_internal = function(container) {
var self = this;
var pause_btn = container.querySelector('.mc-pause-btn');
var timer = container.querySelector('.mc-timer');
// Pause recording
self.media_recorder.pause();
// Pause the timer intervals
if (self.timer_interval) {
clearInterval(self.timer_interval);
self.timer_interval = null;
}
if (self.media_recorder.duration_interval) {
clearInterval(self.media_recorder.duration_interval);
self.media_recorder.duration_interval = null;
}
// Store the paused time for resume
self.media_recorder.paused_time = Date.now();
// Update button to resume/play state
if (pause_btn) {
pause_btn.innerHTML = self.config.icon_play;
pause_btn.setAttribute('aria-label', self.config.label_resume);
pause_btn.setAttribute('title', self.config.label_resume);
pause_btn.classList.add('mc-paused');
}
// Add paused class to timer for visual feedback
if (timer) timer.classList.add('mc-paused');
// Dispatch UI state events
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'on');
// Dispatch custom event
self.dispatch_event('audio-pause');
// Trigger callback if provided
if (self.config.on_audio_paused) {
self.config.on_audio_paused();
}
};
/**
* Resume audio recording (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.audio_resume_internal = function(container) {
var self = this;
var pause_btn = container.querySelector('.mc-pause-btn');
var timer = container.querySelector('.mc-timer');
var TIMER_UPDATE_INTERVAL = 1000;
var DURATION_UPDATE_INTERVAL = 100;
var MILLISECONDS_PER_SECOND = 1000;
var SELECTORS = { RECORD_BTN: '.mc-record-btn' };
// Resume recording
self.media_recorder.resume();
// Calculate time elapsed during pause and adjust start_time
var pause_duration = Date.now() - self.media_recorder.paused_time;
self.media_recorder.start_time += pause_duration;
// Resume the timer display
var current_seconds = Math.floor((Date.now() - self.media_recorder.start_time) / 1000);
var timer_remaining_element = container.querySelector('.mc-timer-remaining');
var has_remaining_display = timer_remaining_element !== null;
var max_duration = self.config.max_duration;
self.timer_interval = setInterval(function() {
// Safety check: if media_recorder is null, clear interval and stop
if (!self.media_recorder) {
clearInterval(self.timer_interval);
return;
}
current_seconds++;
var mins = Math.floor(current_seconds / 60);
var secs = current_seconds % 60;
// Update elapsed time only
var timer_elapsed = container.querySelector('.mc-timer-elapsed');
if (timer_elapsed) {
timer_elapsed.textContent = self.pad_zero(mins) + ':' + self.pad_zero(secs);
}
// Update remaining time if element exists
if (has_remaining_display) {
var remaining_seconds = max_duration - current_seconds;
if (remaining_seconds > 0) {
var remaining_mins = Math.floor(remaining_seconds / 60);
var remaining_secs = remaining_seconds % 60;
timer_remaining_element.textContent = self.pad_zero(remaining_mins) + ':' + self.pad_zero(remaining_secs);
} else {
timer_remaining_element.textContent = '00:00';
}
}
// Auto-stop at max duration
if (current_seconds >= max_duration) {
var record_btn = container.querySelector(SELECTORS.RECORD_BTN);
if (record_btn) record_btn.click();
}
}, TIMER_UPDATE_INTERVAL);
// Resume duration tracking
var duration_interval = setInterval(function() {
// Safety check: if media_recorder is null, clear interval and stop
if (!self.media_recorder) {
clearInterval(duration_interval);
return;
}
var elapsed_ms = Date.now() - self.media_recorder.start_time;
self.media_recorder.recorded_duration = elapsed_ms / MILLISECONDS_PER_SECOND;
}, DURATION_UPDATE_INTERVAL);
self.media_recorder.duration_interval = duration_interval;
// Update button back to pause state
if (pause_btn) {
pause_btn.innerHTML = self.config.icon_pause;
pause_btn.setAttribute('aria-label', self.config.label_pause);
pause_btn.setAttribute('title', self.config.label_pause);
pause_btn.classList.remove('mc-paused');
}
// Remove paused class from timer
if (timer) timer.classList.remove('mc-paused');
// Dispatch UI state events
self.dispatch_ui_state('pause', 'on');
self.dispatch_ui_state('resume', 'off');
// Dispatch custom event
self.dispatch_event('audio-resume');
// Trigger callback if provided
if (self.config.on_audio_resumed) {
self.config.on_audio_resumed();
}
};
/**
* Retake audio (internal method)
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.audio_retake_internal = function(container) {
var self = this;
// Backup the current blob and filename before clearing (for cancel functionality)
self.blob_backup = self.blob;
self.filename_backup = self.filename;
// Backup the current audio URL if it exists
var audio_element = self.audio_element;
if (audio_element && audio_element.src) {
self.audio_url_backup = audio_element.src;
}
self.blob = null;
self.filename = null;
// Hide download option
self.hide_download_option(container);
// Hide result and show preview container
var preview_container = container.querySelector('.mc-capture');
if (preview_container) preview_container.style.display = 'none';
if (preview_container) preview_container.style.display = '';
// Dispatch result-hide event
self.dispatch_event('result-hide');
// Trigger callback if provided
if (self.config.on_result_hide) {
self.config.on_result_hide();
}
// Show cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = '';
// Hide playback controls
var playback_controls = container.querySelector('.mc-result-controls');
if (playback_controls) {
playback_controls.style.display = 'none';
playback_controls.classList.remove('mc-controls-visible');
}
// Show overlay controls
var overlay_controls = container.querySelector('.mc-capture-controls');
if (overlay_controls) {
overlay_controls.style.display = '';
}
// Reset and show record button
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) {
record_btn.innerHTML = self.config.icon_record;
record_btn.setAttribute('aria-label', self.config.label_start_recording);
record_btn.setAttribute('title', self.config.label_start_recording);
record_btn.classList.remove('mc-recording');
record_btn.style.display = '';
record_btn.focus();
}
// Restart the microphone stream for re-recording
self.stream_start_internal(
container,
null, // No video element for audio mode
null, // No facing mode for audio
null, // No video width
null, // No video height
self.config.mode,
null, // No video device
self.selected_audio_device,
function(stream) {
self.stream = stream;
// Start mic monitoring for audio mode
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
// Store references if needed
});
// Show controls for overlay
self.show_controls();
// Ensure playback controls remain hidden
var pb_controls = container.querySelector('.mc-result-controls');
if (pb_controls) {
pb_controls.style.display = 'none';
pb_controls.classList.remove('mc-controls-visible');
}
// Dispatch UI state events for audio buttons
self.dispatch_ui_state('start', 'on');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'on');
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'off');
self.dispatch_ui_state('playback-stop', 'off');
}
);
// Hide settings panel
var settings_panel = container.querySelector('.mc-settings');
if (settings_panel) settings_panel.style.display = 'none';
// Dispatch custom event
self.dispatch_event('audio-retake');
// Trigger callback if provided
if (self.config.on_audio_retake) {
self.config.on_audio_retake();
}
};
/**
* Cancel audio retake (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} result_container - The result container element
* @param {HTMLElement} preview_container - The preview container element
* @private
*/
MediaCapture.prototype.audio_cancel_internal = function(container) {
var self = this;
// Stop the microphone stream (audio mode doesn't use video_element)
if (self.stream) {
var tracks = self.stream.getTracks();
tracks.forEach(function(track) {
track.stop();
});
self.stream = null;
}
// CRITICAL: Reset recording state when canceling
self.is_recording = false;
self.media_recorder = null;
self.timer_interval = null;
// Restore the backed up blob and filename
self.blob = self.blob_backup;
self.filename = self.filename_backup;
// Restore the audio if it was backed up
var audio_element = self.audio_element;
if (audio_element && self.audio_url_backup) {
audio_element.src = self.audio_url_backup;
audio_element.load();
}
// Switch from overlay controls to playback controls
var overlay_controls = container.querySelector('.mc-capture-controls');
var playback_controls = container.querySelector('.mc-result-controls');
if (overlay_controls) overlay_controls.style.display = 'none';
if (playback_controls) playback_controls.style.display = '';
var preview_container = container.querySelector('.mc-preview');
if (preview_container) preview_container.style.display = '';
// Dispatch result-show event
self.dispatch_event('result-show');
// Trigger callback if provided
if (self.config.on_result_show) {
self.config.on_result_show();
}
// Hide cancel button
var cancel_btn = container.querySelector('.mc-cancel-btn');
if (cancel_btn) cancel_btn.style.display = 'none';
// Dispatch UI state events for audio buttons
self.dispatch_ui_state('start', 'off');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'on');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'on');
self.dispatch_ui_state('playback-stop', 'on');
// Clear backup data
self.blob_backup = null;
self.filename_backup = null;
self.audio_url_backup = null;
// Dispatch custom event
self.dispatch_event('audio-cancel');
// Trigger callback if provided
if (self.config.on_audio_cancel) {
self.config.on_audio_cancel();
}
// Move focus to retake button for keyboard users
var retake_btn = container.querySelector('.mc-retake-btn');
if (retake_btn) retake_btn.focus();
};
/**
* Open settings panel (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} settings_panel - The settings panel element
* @param {HTMLElement} settings_btn - The settings button element
* @private
*/
MediaCapture.prototype.settings_open_internal = function(container, settings_panel, settings_btn) {
var self = this;
// Position the panel before opening
self.position_settings_panel(settings_panel, settings_btn);
self.slide_down(settings_panel, 300, function() {
// Focus first menu item in main menu
var first_item = settings_panel.querySelector('[data-menu="main"] .mc-settings-menu-item:not([disabled])');
if (first_item) {
first_item.focus();
}
});
if (settings_btn) {
settings_btn.setAttribute('aria-expanded', 'true');
}
// Dispatch UI state events
self.dispatch_ui_state('settings-open', 'off');
self.dispatch_ui_state('settings-close', 'on');
// Dispatch custom event
self.dispatch_event('settings-open');
// Trigger callback if provided
if (self.config.on_settings_opened) {
self.config.on_settings_opened();
}
};
/**
* Position settings panel based on available viewport space
* @param {HTMLElement} settings_panel - The settings panel element
* @param {HTMLElement} settings_btn - The settings button element
* @private
*/
MediaCapture.prototype.position_settings_panel = function(settings_panel, settings_btn) {
if (!settings_panel || !settings_btn) {
return;
}
// Get button position in viewport (using fixed positioning like native audio controls)
var btn_rect = settings_btn.getBoundingClientRect();
// Gap between button and panel
var gap = 8;
// Get estimated panel height
var panel_estimated_height = 200;
// Calculate space above and below the button in viewport
var space_above = btn_rect.top;
var space_below = window.innerHeight - btn_rect.bottom;
// Minimum space required to show panel
var min_space_required = panel_estimated_height + 20;
// Remove any existing position classes
settings_panel.classList.remove('mc-position-above', 'mc-position-below');
// Calculate right position (align with right edge of button)
var right_position = window.innerWidth - btn_rect.right;
settings_panel.style.right = right_position + 'px';
// Decide positioning: prefer above if there's enough space, otherwise show below
if (space_above >= min_space_required) {
settings_panel.classList.add('mc-position-above');
// Position above the button with gap
settings_panel.style.bottom = (window.innerHeight - btn_rect.top + gap) + 'px';
settings_panel.style.top = 'auto';
} else if (space_below >= min_space_required) {
settings_panel.classList.add('mc-position-below');
// Position below the button with gap
settings_panel.style.top = (btn_rect.bottom + gap) + 'px';
settings_panel.style.bottom = 'auto';
} else {
// If neither has enough space, use whichever has more
if (space_above > space_below) {
settings_panel.classList.add('mc-position-above');
settings_panel.style.bottom = (window.innerHeight - btn_rect.top + gap) + 'px';
settings_panel.style.top = 'auto';
} else {
settings_panel.classList.add('mc-position-below');
settings_panel.style.top = (btn_rect.bottom + gap) + 'px';
settings_panel.style.bottom = 'auto';
}
}
};
/**
* Show settings submenu
* @param {HTMLElement} settings_panel - The settings panel element
* @param {string} submenu_name - Name of submenu to show ('video-input' or 'audio-input')
* @private
*/
MediaCapture.prototype.show_settings_submenu = function(settings_panel, submenu_name) {
// Hide main menu
var main_menu = settings_panel.querySelector('[data-menu="main"]');
if (main_menu) {
main_menu.style.display = 'none';
}
// Show submenu
var submenu = settings_panel.querySelector('[data-menu="' + submenu_name + '"]');
if (submenu) {
submenu.style.display = 'block';
// Focus first menu item in submenu (back button)
setTimeout(function() {
var first_item = submenu.querySelector('.mc-settings-menu-item');
if (first_item) first_item.focus();
}, 0);
}
};
/**
* Hide settings submenu and show main menu
* @param {HTMLElement} settings_panel - The settings panel element
* @private
*/
MediaCapture.prototype.hide_settings_submenu = function(settings_panel) {
// Hide all submenus
var submenus = settings_panel.querySelectorAll('.mc-settings-menu-sub');
submenus.forEach(function(submenu) {
submenu.style.display = 'none';
});
// Show main menu
var main_menu = settings_panel.querySelector('[data-menu="main"]');
if (main_menu) {
main_menu.style.display = 'block';
// Focus first menu item in main menu
setTimeout(function() {
var first_item = main_menu.querySelector('.mc-settings-menu-item');
if (first_item) first_item.focus();
}, 0);
}
};
/**
* Select a device and restart stream
* @param {HTMLElement} container - The container element
* @param {HTMLVideoElement} video_element - The video element
* @param {string} facing_mode - The facing mode
* @param {number} video_width - Video width
* @param {number} video_height - Video height
* @param {string} mode - Capture mode
* @param {string} device_type - 'video' or 'audio'
* @param {string} device_id - Device ID to select
* @param {AudioContext} audio_context - Audio context reference
* @param {AnalyserNode} analyser - Analyser node reference
* @private
*/
MediaCapture.prototype.select_device = function(container, video_element, facing_mode, video_width,
video_height, mode, device_type, device_id,
audio_context, analyser) {
var self = this;
// Prevent device switching during recording
if (self.is_recording) {
return;
}
// Update selected device
if (device_type === 'video') {
self.selected_video_device = device_id || null;
} else if (device_type === 'audio') {
self.selected_audio_device = device_id || null;
}
// If we're in playback mode (have recorded content), just update the device selection
// without restarting the stream. The new device will be used on next retake.
if (self.blob) {
// Re-enumerate devices to update checkmarks
self.enumerate_devices(container, mode);
return;
}
// Restart stream with new device
self.stream_stop_internal(video_element);
self.stream_start_internal(
container,
video_element,
facing_mode,
video_width,
video_height,
mode,
self.selected_video_device,
self.selected_audio_device,
function(stream) {
self.stream = stream;
if (mode === 'video' && device_type === 'audio') {
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
audio_context = context;
analyser = analyser_node;
});
}
// Re-enumerate devices to update checkmarks
self.enumerate_devices(container, mode);
}
);
};
/**
* Enable download option in settings menu
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.show_download_option = function(container) {
if (!this.config.settings_download_enabled) {
return;
}
var download_item = container.querySelector('.mc-download-item');
if (download_item) {
download_item.disabled = false;
}
this.dispatch_ui_state('download', 'on');
};
/**
* Disable download option in settings menu
* @param {HTMLElement} container - The container element
* @private
*/
MediaCapture.prototype.hide_download_option = function(container) {
if (!this.config.settings_download_enabled) {
return;
}
var download_item = container.querySelector('.mc-download-item');
if (download_item) {
download_item.disabled = true;
}
this.dispatch_ui_state('download', 'off');
};
/**
* Close settings panel (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLElement} settings_panel - The settings panel element
* @param {HTMLElement} settings_btn - The settings button element
* @private
*/
MediaCapture.prototype.settings_close_internal = function(container, settings_panel, settings_btn) {
var self = this;
// Reset to main menu when closing
self.hide_settings_submenu(settings_panel);
self.slide_up(settings_panel, 300);
// Focus settings button after animation completes
// If settings_btn is not provided or not visible, find the visible one
if (!settings_btn || settings_btn.style.display === 'none') {
// Query all settings buttons and find the visible one
var all_settings_btns = container.querySelectorAll('.mc-settings-btn');
for (var i = 0; i < all_settings_btns.length; i++) {
if (all_settings_btns[i].style.display !== 'none' && all_settings_btns[i].offsetParent !== null) {
settings_btn = all_settings_btns[i];
break;
}
}
}
if (settings_btn) {
settings_btn.setAttribute('aria-expanded', 'false');
setTimeout(function() {
settings_btn.focus();
}, 300);
}
// Dispatch UI state events
self.dispatch_ui_state('settings-open', 'on');
self.dispatch_ui_state('settings-close', 'off');
// Dispatch custom event
self.dispatch_event('settings-close');
// Trigger callback if provided
if (self.config.on_settings_closed) {
self.config.on_settings_closed();
}
};
/**
* Flip camera (internal method)
* @param {HTMLElement} container - The container element
* @param {HTMLVideoElement} video_element - The video element
* @param {string} mode - The capture mode
* @param {Function} callback - Callback function when flip completes
* @private
*/
MediaCapture.prototype.stream_flip_internal = function(container, video_element, mode, callback) {
var self = this;
// Toggle facing mode
var new_facing_mode = (self.config.facing_mode === 'user') ? 'environment' : 'user';
self.config.facing_mode = new_facing_mode;
// For photo mode, hide result container and show preview container
if (mode === 'photo') {
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
if (preview_container) preview_container.style.display = 'none';
if (preview_container) preview_container.style.display = '';
}
// Switch camera
self.stream_stop_internal(video_element);
self.stream_start_internal(
container,
video_element,
new_facing_mode,
self.video_width,
self.video_height,
mode,
null,
self.selected_audio_device,
function(new_stream) {
self.stream = new_stream;
// Dispatch custom event
self.dispatch_event('stream-flip', {
facing_mode: new_facing_mode,
stream: new_stream
});
// Trigger callback if provided
if (self.config.on_stream_flipped) {
self.config.on_stream_flipped(new_facing_mode);
}
if (callback) {
callback(new_stream);
}
}
);
};
/**
* Start the camera/stream
* Public method to start the camera stream programmatically
* @returns {Promise} Promise that resolves when stream is started
*/
MediaCapture.prototype.stream_start = function() {
var self = this;
self.log('[stream_start] Called');
return new Promise(function(resolve, reject) {
self.log('[stream_start] Inside promise, is_initialized:', self.is_initialized);
if (!self.is_initialized) {
self.log('[stream_start] NOT initialized, rejecting');
reject(new Error('MediaCapture must be initialized before starting stream'));
return;
}
self.log('[stream_start] Initialized, proceeding...');
var container = self.element;
var video_element = self.video_element;
self.log('[stream_start] video_element:', video_element);
self.log('[stream_start] mode:', self.config.mode);
var start_screen = container.querySelector('.mc-start');
var preview_container = container.querySelector('.mc-capture');
// Hide start screen, show preview
if (start_screen) start_screen.style.display = 'none';
if (preview_container) preview_container.style.display = '';
self.log('[stream_start] About to call stream_start_internal');
self.log('[stream_start] Params:', {
mode: self.config.mode,
facing_mode: self.config.facing_mode,
video_device: self.selected_video_device,
audio_device: self.selected_audio_device
});
self.stream_start_internal(
container,
video_element,
self.config.facing_mode,
self.video_width,
self.video_height,
self.config.mode,
self.selected_video_device,
self.selected_audio_device,
function(stream) {
self.stream = stream;
// Start mic monitoring for video and audio modes
if (self.config.mode === 'video' || self.config.mode === 'audio') {
self.start_mic_monitoring(container, stream, function(context, analyser_node) {
// Store references if needed
});
}
// Check for multiple cameras on mobile (both photo and video modes)
if (self.is_mobile && !self.has_checked_cameras) {
self.has_checked_cameras = true;
self.check_camera_flip_availability(container);
}
// Show controls when stream starts
self.show_controls();
// Focus on appropriate button based on mode
if (self.config.mode === 'photo') {
var capture_btn = container.querySelector('.mc-capture-btn');
if (capture_btn) capture_btn.focus();
} else if (self.config.mode === 'video' || self.config.mode === 'audio') {
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) record_btn.focus();
}
// Dispatch UI state events based on capture mode
if (self.config.mode === 'photo') {
self.dispatch_ui_state('capture', 'on');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'off');
} else if (self.config.mode === 'video') {
self.dispatch_ui_state('start', 'on');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'off');
self.dispatch_ui_state('playback-stop', 'off');
} else if (self.config.mode === 'audio') {
self.dispatch_ui_state('start', 'on');
self.dispatch_ui_state('stop', 'off');
self.dispatch_ui_state('pause', 'off');
self.dispatch_ui_state('resume', 'off');
self.dispatch_ui_state('retake', 'off');
self.dispatch_ui_state('cancel', 'off');
self.dispatch_ui_state('playback-play', 'off');
self.dispatch_ui_state('playback-pause', 'off');
self.dispatch_ui_state('playback-toggle', 'off');
self.dispatch_ui_state('playback-stop', 'off');
}
// Settings button should be enabled when stream is active
self.dispatch_ui_state('settings-open', 'on');
self.dispatch_ui_state('settings-close', 'off');
// Stream control UI states
self.dispatch_ui_state('stream-start', 'off');
self.dispatch_ui_state('stream-stop', 'on');
// Dispatch custom event
self.dispatch_event('stream-start', { stream: stream });
resolve(stream);
},
function(error) {
// Show error screen
if (preview_container) preview_container.style.display = 'none';
var error_screen = container.querySelector('.mc-error');
if (error_screen) error_screen.style.display = '';
reject(error);
}
);
});
};
/**
* Capture a photo
* Public method to capture a photo from the video stream programmatically
* @returns {Promise} Promise that resolves with {blob, filename, metadata}
*/
MediaCapture.prototype.photo_capture = function() {
var self = this;
self.log('Photo capture initiated');
return new Promise(function(resolve, reject) {
if (!self.is_initialized) {
self.log('Photo capture failed: not initialized', 'error');
reject(new Error('MediaCapture must be initialized before capturing photo'));
return;
}
if (self.config.mode !== 'photo') {
self.log('Photo capture failed: not in photo mode', 'error');
reject(new Error('MediaCapture must be in photo mode to capture photos'));
return;
}
if (!self.is_stream_active()) {
self.log('Photo capture failed: stream not active', 'error');
reject(new Error('Camera stream must be active before capturing photo'));
return;
}
self.log('Photo capture: capturing image from canvas');
var container = self.element;
var video_element = self.video_element;
var canvas_element = self.canvas_element;
var result_photo = container.querySelector('.mc-photo');
// Play photo sound if enabled
self.play_photo_sound(self.config.photo_sound_enabled);
canvas_element.width = video_element.videoWidth;
canvas_element.height = video_element.videoHeight;
var context = canvas_element.getContext('2d');
context.drawImage(video_element, 0, 0);
var mime_type = self.get_supported_photo_mime_type(self.config.photo_format);
// Convert canvas to blob
canvas_element.toBlob(function(blob) {
if (!blob) {
self.log('Photo capture failed: unable to create blob', 'error');
reject(new Error('Failed to capture photo'));
return;
}
self.log('Photo captured successfully: ' + blob.size + ' bytes');
// Create object URL for preview
var object_url = URL.createObjectURL(blob);
result_photo.src = object_url;
// Store blob and filename
var filename = 'photo-' + Date.now() + '.' + self.config.photo_format;
self.blob = blob;
self.filename = filename;
// Create metadata JSON
var metadata = {
type: 'photo',
width: canvas_element.width,
height: canvas_element.height,
size: blob.size,
format: self.config.photo_format,
quality: self.config.photo_quality,
timestamp: Date.now()
};
// Show result container and hide video/controls, show player/result controls
var preview_container = container.querySelector('.mc-capture');
var settings_panel = container.querySelector('.mc-settings');
var video_element_dom = container.querySelector('.mc-video');
var player_element = container.querySelector('.mc-result');
var overlay_controls = container.querySelector('.mc-capture-controls');
if (preview_container) preview_container.style.display = '';
if (settings_panel) settings_panel.style.display = 'none';
if (video_element_dom) video_element_dom.style.display = 'none';
if (player_element) player_element.style.display = '';
if (overlay_controls) overlay_controls.style.display = 'none';
// Hide flip button when viewing result
var flip_btn = container.querySelector('.mc-flip-btn');
if (flip_btn) flip_btn.style.display = 'none';
// Dispatch UI state events
self.dispatch_ui_state('flip', 'off');
self.dispatch_ui_state('capture', 'off');
self.dispatch_ui_state('retake', 'on');
self.dispatch_ui_state('cancel', 'off');
// Dispatch result-show event
self.dispatch_event('result-show');
// Trigger result-show callback if provided
if (self.config.on_result_show) {
self.config.on_result_show();
}
// Trigger flash effect in result container
var flash_overlay = preview_container.querySelector('.mc-flash');
if (flash_overlay) {
flash_overlay.classList.add('mc-flash-active');
setTimeout(function() {
flash_overlay.classList.remove('mc-flash-active');
}, 50);
}
// Move focus to retake button for keyboard users
var retake_btn = container.querySelector('.mc-retake-btn');
if (retake_btn) retake_btn.focus();
// Trigger callback if provided
if (self.config.on_photo_captured) {
self.config.on_photo_captured(blob, filename, metadata);
}
// Dispatch custom event
self.dispatch_event('photo-capture', {
blob: blob,
filename: filename,
metadata: metadata
});
resolve({
blob: blob,
filename: filename,
metadata: metadata
});
}, mime_type, self.config.photo_quality);
});
};
/**
* Stop the camera/stream
* Public method to stop the camera stream programmatically
*/
MediaCapture.prototype.stream_stop = function() {
this.log('Stream stop initiated');
if (!this.is_initialized) {
this.log('Stream stop failed: not initialized', 'error');
return;
}
var container = this.element;
var video_element = this.video_element;
var preview_container = container.querySelector('.mc-capture');
var result_container = preview_container; // Use preview_container
var start_screen = container.querySelector('.mc-start');
// Stop the stream
this.log('Stopping media stream');
this.stream_stop_internal(video_element);
this.stream = null;
// Hide preview and result, show start screen
if (preview_container) preview_container.style.display = 'none';
if (preview_container) preview_container.style.display = 'none';
if (start_screen) start_screen.style.display = '';
this.log('Resetting all UI states');
// Dispatch UI state events - all UI elements should be disabled when stream stops
this.dispatch_ui_state('flip', 'off');
this.dispatch_ui_state('capture', 'off');
this.dispatch_ui_state('start', 'off');
this.dispatch_ui_state('stop', 'off');
this.dispatch_ui_state('retake', 'off');
this.dispatch_ui_state('cancel', 'off');
this.dispatch_ui_state('settings-open', 'off');
this.dispatch_ui_state('settings-close', 'off');
// Stream control UI states
this.dispatch_ui_state('stream-start', 'on');
this.dispatch_ui_state('stream-stop', 'off');
// Dispatch custom event
this.dispatch_event('stream-stop');
this.log('Stream stopped successfully');
};
/**
* Flip the camera
* Public method to flip between front and back cameras programmatically
*/
MediaCapture.prototype.stream_flip = function() {
this.log('Camera flip initiated');
if (!this.is_initialized) {
this.log('Stream flip failed: not initialized', 'error');
return;
}
// Check if stream is active
if (!this.stream || !this.stream.active) {
this.log('stream_flip() can only be called when stream is active', 'warn');
return;
}
// Check if multiple cameras available
if (!this.has_multiple_cameras) {
this.log('stream_flip() requires multiple cameras', 'warn');
return;
}
// Check if result is currently showing
var container = this.element;
var preview_container = container.querySelector('.mc-capture');
if (preview_container && preview_container.style.display !== 'none') {
this.log('stream_flip() cannot be called while result is showing', 'warn');
return;
}
// For video mode, don't allow flipping during recording
if (this.config.mode === 'video' && this.is_recording) {
this.log('stream_flip() cannot be called during recording', 'warn');
return;
}
var video_element = this.video_element;
var self = this;
this.log('Flipping camera');
this.stream_flip_internal(container, video_element, this.config.mode, function(new_stream) {
self.log('Camera flipped successfully');
self.dispatch_event('stream-flipped');
self.dispatch_ui_state('flip', 'on');
// For video mode, restart mic monitoring
if (self.config.mode === 'video') {
self.start_mic_monitoring(container, new_stream, function(context, analyser_node) {
// Store references if needed
});
}
});
};
/**
* Retake a photo
* Public method to retake a photo programmatically
*/
MediaCapture.prototype.photo_retake = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'photo') {
self.log('photo_retake() can only be called in photo mode');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.photo_retake_internal(container, result_container, preview_container);
// Move focus to capture button for keyboard users
var capture_btn = container.querySelector('.mc-capture-btn');
if (capture_btn) capture_btn.focus();
};
/**
* Cancel photo retake
* Public method to cancel a photo retake and restore the previous photo
*/
MediaCapture.prototype.photo_cancel = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'photo') {
self.log('photo_cancel() can only be called in photo mode');
return;
}
// Only allow cancel if there's a backup
if (!this.blob_backup) {
self.log('photo_cancel() can only be called after photo_retake()');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.photo_cancel_internal(container, result_container, preview_container);
};
/**
* Start video recording
* Public method to start recording video programmatically
* @returns {Promise} Promise that resolves when recording starts
*/
MediaCapture.prototype.video_start = function() {
var self = this;
self.log('Video recording start initiated');
return new Promise(function(resolve, reject) {
if (!self.is_initialized) {
self.log('Video start failed: not initialized', 'error');
reject(new Error('MediaCapture must be initialized before starting video'));
return;
}
if (self.config.mode !== 'video') {
self.log('Video start failed: not in video mode', 'error');
reject(new Error('video_start() can only be called in video mode'));
return;
}
// Check if stream is active
if (!self.stream || !self.stream.active) {
self.log('Video start failed: stream not active', 'error');
reject(new Error('Stream must be active before starting video recording'));
return;
}
// Check if already recording
if (self.is_recording) {
self.log('Video start failed: already recording', 'error');
reject(new Error('Video recording is already in progress'));
return;
}
self.log('Video recording: starting MediaRecorder');
var container = self.element;
// Call internal method
self.video_start_internal(container, self.stream, self.config.max_duration);
self.log('Video recording: started successfully');
resolve();
});
};
/**
* Stop video recording
* Public method to stop recording video programmatically
* @returns {Promise} Promise that resolves with video data when recording stops
*/
MediaCapture.prototype.video_stop = function() {
var self = this;
self.log('Video recording stop initiated');
return new Promise(function(resolve, reject) {
if (!self.is_initialized) {
self.log('Video stop failed: not initialized', 'error');
reject(new Error('MediaCapture must be initialized before stopping video'));
return;
}
if (self.config.mode !== 'video') {
self.log('Video stop failed: not in video mode', 'error');
reject(new Error('video_stop() can only be called in video mode'));
return;
}
// Check if recording
if (!self.is_recording) {
self.log('Video stop failed: not recording', 'error');
reject(new Error('No video recording in progress'));
return;
}
if (!self.media_recorder || self.media_recorder.state === 'inactive') {
self.log('Video stop failed: MediaRecorder not active', 'error');
reject(new Error('MediaRecorder is not active'));
return;
}
self.log('Video recording: stopping MediaRecorder');
var container = self.element;
var video_element = self.video_element;
// Call internal method
self.video_stop_internal(container, video_element, function() {
self.log('Video recording: stopped successfully');
resolve({
blob: self.blob,
filename: self.filename
});
});
});
};
/**
* Pause video recording
* Public method to pause recording video programmatically
*/
MediaCapture.prototype.video_pause = function() {
var self = this;
if (!self.is_initialized) {
self.log('MediaCapture must be initialized before pausing video');
return;
}
if (self.config.mode !== 'video') {
self.log('video_pause() can only be called in video mode');
return;
}
// Check if recording
if (!self.is_recording) {
self.log('No video recording in progress to pause');
return;
}
// Check if media recorder exists and is recording
if (!self.media_recorder || self.media_recorder.state !== 'recording') {
self.log('MediaRecorder is not in recording state');
return;
}
var container = self.element;
// Call internal method
self.video_pause_internal(container);
};
/**
* Resume video recording
* Public method to resume recording video programmatically
*/
MediaCapture.prototype.video_resume = function() {
var self = this;
if (!self.is_initialized) {
self.log('MediaCapture must be initialized before resuming video');
return;
}
if (self.config.mode !== 'video') {
self.log('video_resume() can only be called in video mode');
return;
}
// Check if recording
if (!self.is_recording) {
self.log('No video recording in progress to resume');
return;
}
// Check if media recorder exists and is paused
if (!self.media_recorder || self.media_recorder.state !== 'paused') {
self.log('MediaRecorder is not in paused state');
return;
}
var container = self.element;
// Call internal method
self.video_resume_internal(container);
};
/**
* Retake video
* Public method to retake a video programmatically
*/
MediaCapture.prototype.video_retake = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video') {
self.log('video_retake() can only be called in video mode');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.video_retake_internal(container, result_container, preview_container);
// Move focus to record button for keyboard users
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) record_btn.focus();
};
/**
* Cancel video retake
* Public method to cancel a video retake and restore the previous video
*/
MediaCapture.prototype.video_cancel = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video') {
self.log('video_cancel() can only be called in video mode');
return;
}
// Only allow cancel if there's a backup
if (!this.blob_backup) {
self.log('video_cancel() can only be called after video_retake()');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.video_cancel_internal(container, result_container, preview_container);
};
/**
* Start audio recording
* Public method to start recording audio programmatically
* @returns {Promise} Promise that resolves when recording starts
*/
MediaCapture.prototype.audio_start = function() {
var self = this;
self.log('Audio recording start initiated');
return new Promise(function(resolve, reject) {
if (!self.is_initialized) {
self.log('Audio start failed: not initialized', 'error');
reject(new Error('MediaCapture must be initialized before starting audio'));
return;
}
if (self.config.mode !== 'audio') {
self.log('Audio start failed: not in audio mode', 'error');
reject(new Error('audio_start() can only be called in audio mode'));
return;
}
// Check if stream is active
if (!self.stream || !self.stream.active) {
self.log('Audio start failed: stream not active', 'error');
reject(new Error('Stream must be active before starting audio recording'));
return;
}
// Check if already recording
if (self.is_recording) {
self.log('Audio start failed: already recording', 'error');
reject(new Error('Audio recording is already in progress'));
return;
}
self.log('Audio recording: starting MediaRecorder');
var container = self.element;
// Call internal method
self.audio_start_internal(container, self.stream, self.config.max_duration);
self.log('Audio recording: started successfully');
resolve();
});
};
/**
* Stop audio recording
* Public method to stop recording audio programmatically
* @returns {Promise} Promise that resolves with audio data when recording stops
*/
MediaCapture.prototype.audio_stop = function() {
var self = this;
self.log('Audio recording stop initiated');
return new Promise(function(resolve, reject) {
if (!self.is_initialized) {
self.log('Audio stop failed: not initialized', 'error');
reject(new Error('MediaCapture must be initialized before stopping audio'));
return;
}
if (self.config.mode !== 'audio') {
self.log('Audio stop failed: not in audio mode', 'error');
reject(new Error('audio_stop() can only be called in audio mode'));
return;
}
// Check if recording
if (!self.is_recording) {
self.log('Audio stop failed: not recording', 'error');
reject(new Error('No audio recording in progress'));
return;
}
if (!self.media_recorder || self.media_recorder.state === 'inactive') {
self.log('Audio stop failed: MediaRecorder not active', 'error');
reject(new Error('MediaRecorder is not active'));
return;
}
self.log('Audio recording: stopping MediaRecorder');
var container = self.element;
var audio_element = self.audio_element;
// Call internal method
self.audio_stop_internal(container, audio_element, function() {
self.log('Audio recording: stopped successfully');
resolve({
blob: self.blob,
filename: self.filename
});
});
});
};
/**
* Pause audio recording
* Public method to pause recording audio programmatically
*/
MediaCapture.prototype.audio_pause = function() {
var self = this;
if (!self.is_initialized) {
self.log('MediaCapture must be initialized before pausing audio');
return;
}
if (self.config.mode !== 'audio') {
self.log('audio_pause() can only be called in audio mode');
return;
}
// Check if recording
if (!self.is_recording) {
self.log('No audio recording in progress to pause');
return;
}
// Check if media recorder exists and is recording
if (!self.media_recorder || self.media_recorder.state !== 'recording') {
self.log('MediaRecorder is not in recording state');
return;
}
var container = self.element;
// Call internal method
self.audio_pause_internal(container);
};
/**
* Resume audio recording
* Public method to resume recording audio programmatically
*/
MediaCapture.prototype.audio_resume = function() {
var self = this;
if (!self.is_initialized) {
self.log('MediaCapture must be initialized before resuming audio');
return;
}
if (self.config.mode !== 'audio') {
self.log('audio_resume() can only be called in audio mode');
return;
}
// Check if recording
if (!self.is_recording) {
self.log('No audio recording in progress to resume');
return;
}
// Check if media recorder exists and is paused
if (!self.media_recorder || self.media_recorder.state !== 'paused') {
self.log('MediaRecorder is not in paused state');
return;
}
var container = self.element;
// Call internal method
self.audio_resume_internal(container);
};
/**
* Retake audio
* Public method to retake an audio recording programmatically
*/
MediaCapture.prototype.audio_retake = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'audio') {
self.log('audio_retake() can only be called in audio mode');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.audio_retake_internal(container, result_container, preview_container);
// Move focus to record button for keyboard users
var record_btn = container.querySelector('.mc-record-btn');
if (record_btn) record_btn.focus();
};
/**
* Cancel audio retake
* Public method to cancel an audio retake and restore the previous audio
*/
MediaCapture.prototype.audio_cancel = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'audio') {
self.log('audio_cancel() can only be called in audio mode');
return;
}
// Only allow cancel if there's a backup
if (!this.blob_backup) {
self.log('audio_cancel() can only be called after audio_retake()');
return;
}
var container = this.element;
var result_container = preview_container; // Use preview_container
var preview_container = container.querySelector('.mc-capture');
this.audio_cancel_internal(container, result_container, preview_container);
};
/**
* Play recorded video
* Public method to play the recorded video playback
*/
MediaCapture.prototype.playback_play = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video' && this.config.mode !== 'audio') {
self.log('playback_play() can only be called in video or audio mode');
return;
}
var container = this.element;
var media_element = this.config.mode === 'video'
? container.querySelector('.mc-video')
: container.querySelector('.mc-audio');
var play_btn = container.querySelector('.mc-play-btn');
if (!media_element) {
return;
}
// Only play if media is paused and has content
if (media_element.paused && media_element.src) {
media_element.play();
if (play_btn) {
play_btn.innerHTML = this.config.icon_pause;
play_btn.setAttribute('aria-label', this.config.label_pause);
}
// Dispatch UI state events
this.dispatch_ui_state('playback-play', 'off');
this.dispatch_ui_state('playback-pause', 'on');
this.dispatch_ui_state('playback-stop', 'on');
}
};
/**
* Pause recorded video or audio
* Public method to pause the recorded video or audio playback
*/
MediaCapture.prototype.playback_pause = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video' && this.config.mode !== 'audio') {
self.log('playback_pause() can only be called in video or audio mode');
return;
}
var container = this.element;
var media_element = this.config.mode === 'video'
? container.querySelector('.mc-video')
: container.querySelector('.mc-audio');
var play_btn = container.querySelector('.mc-play-btn');
if (!media_element) {
return;
}
// Only pause if media is playing
if (!media_element.paused) {
media_element.pause();
if (play_btn) {
play_btn.innerHTML = this.config.icon_play;
play_btn.setAttribute('aria-label', this.config.label_play);
}
// Dispatch UI state events
this.dispatch_ui_state('playback-play', 'on');
this.dispatch_ui_state('playback-pause', 'off');
this.dispatch_ui_state('playback-stop', 'off');
}
};
/**
* Toggle play/pause of recorded video or audio
* Public method to toggle between play and pause states
*/
MediaCapture.prototype.playback_toggle = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video' && this.config.mode !== 'audio') {
self.log('playback_toggle() can only be called in video or audio mode');
return;
}
var container = this.element;
var media_element = this.config.mode === 'video'
? container.querySelector('.mc-video')
: container.querySelector('.mc-audio');
if (!media_element || !media_element.src) {
return;
}
if (media_element.paused) {
this.playback_play();
} else {
this.playback_pause();
}
};
/**
* Stop recorded video or audio playback
* Public method to stop the media and reset to beginning
*/
MediaCapture.prototype.playback_stop = function() {
if (!this.is_initialized) {
return;
}
if (this.config.mode !== 'video' && this.config.mode !== 'audio') {
self.log('playback_stop() can only be called in video or audio mode');
return;
}
var container = this.element;
var media_element = this.config.mode === 'video'
? container.querySelector('.mc-video')
: container.querySelector('.mc-audio');
var play_btn = container.querySelector('.mc-play-btn');
if (!media_element || !media_element.src) {
return;
}
// Pause the media and reset to beginning
media_element.pause();
media_element.currentTime = 0;
if (play_btn) {
play_btn.innerHTML = this.config.icon_play;
play_btn.setAttribute('aria-label', this.config.label_play);
}
// Dispatch UI state events
this.dispatch_ui_state('playback-play', 'on');
this.dispatch_ui_state('playback-pause', 'off');
this.dispatch_ui_state('playback-stop', 'off');
};
/**
* Open settings panel
* Public method to open the settings panel programmatically
*/
MediaCapture.prototype.settings_open = function() {
this.log('Opening settings panel');
if (!this.is_initialized) {
this.log('Settings open failed: not initialized', 'error');
return;
}
var container = this.element;
var settings_panel = container.querySelector('.mc-settings');
// Find the visible settings button (not the first one, which might be hidden)
var settings_btns = container.querySelectorAll('.mc-settings-btn');
var settings_btn = null;
for (var i = 0; i < settings_btns.length; i++) {
var btn = settings_btns[i];
// Check if button is visible (not in a hidden parent)
if (btn.offsetParent !== null) {
settings_btn = btn;
break;
}
}
if (!settings_panel) {
this.log('Settings panel element not found', 'error');
return;
}
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (!is_visible) {
this.log('Settings panel: showing');
this.settings_open_internal(container, settings_panel, settings_btn);
this.dispatch_ui_state('settings-open', 'off');
this.dispatch_ui_state('settings-close', 'on');
}
};
/**
* Close settings panel
* Public method to close the settings panel programmatically
*/
MediaCapture.prototype.settings_close = function() {
this.log('Closing settings panel');
if (!this.is_initialized) {
this.log('Settings close failed: not initialized', 'error');
return;
}
var container = this.element;
var settings_panel = container.querySelector('.mc-settings');
// Find the visible settings button (not the first one, which might be hidden)
var settings_btns = container.querySelectorAll('.mc-settings-btn');
var settings_btn = null;
for (var i = 0; i < settings_btns.length; i++) {
var btn = settings_btns[i];
// Check if button is visible (not in a hidden parent)
if (btn.offsetParent !== null) {
settings_btn = btn;
break;
}
}
if (!settings_panel) {
this.log('Settings panel element not found', 'error');
return;
}
var is_visible = settings_panel.style.display !== 'none' && settings_panel.style.display !== '';
if (is_visible) {
this.log('Settings panel: hiding');
this.settings_close_internal(container, settings_panel, settings_btn);
this.dispatch_ui_state('settings-open', 'on');
this.dispatch_ui_state('settings-close', 'off');
}
};
/**
* Show controls
* Public method to show the overlay controls
*/
MediaCapture.prototype.show_controls = function() {
if (!this.is_initialized) {
return;
}
var container = this.element;
var overlay_controls = container.querySelector('.mc-capture-controls');
var playback_controls = container.querySelector('.mc-result-controls');
var result_controls = container.querySelector('.mc-result-controls');
if (overlay_controls) overlay_controls.classList.add('mc-controls-visible');
if (playback_controls) playback_controls.classList.add('mc-controls-visible');
if (result_controls) result_controls.classList.add('mc-controls-visible');
};
/**
* Hide controls
* Public method to hide the overlay controls
*/
MediaCapture.prototype.hide_controls = function() {
if (!this.is_initialized) {
return;
}
var container = this.element;
var overlay_controls = container.querySelector('.mc-capture-controls');
var playback_controls = container.querySelector('.mc-result-controls');
var result_controls = container.querySelector('.mc-result-controls');
if (overlay_controls) overlay_controls.classList.remove('mc-controls-visible');
if (playback_controls) playback_controls.classList.remove('mc-controls-visible');
if (result_controls) result_controls.classList.remove('mc-controls-visible');
};
/**
* Get the current media stream
* @returns {MediaStream|null} The active media stream
*/
MediaCapture.prototype.get_stream = function() {
return this.stream;
};
/**
* Check if the stream is currently active
* @returns {boolean} True if stream is active
*/
MediaCapture.prototype.is_stream_active = function() {
return this.stream !== null && this.stream.active;
};
/**
* Update configuration options
* @param {Object} new_config - New configuration options to merge
*/
MediaCapture.prototype.set_config = function(new_config) {
this.config = Object.assign({}, this.config, new_config || {});
};
/**
* Get current configuration
* @returns {Object} Current configuration object
*/
MediaCapture.prototype.get_config = function() {
return Object.assign({}, this.config);
};
/**
* Check if video is currently playing
* @returns {boolean} True if video is playing
*/
MediaCapture.prototype.is_playing = function() {
if (!this.is_initialized) {
return false;
}
if (this.config.mode === 'video') {
var video_element = this.element.querySelector('.mc-video');
return video_element && !video_element.paused && video_element.src && video_element.currentTime > 0;
} else if (this.config.mode === 'audio') {
var audio_element = this.element.querySelector('.mc-audio');
return audio_element && !audio_element.paused && audio_element.src && audio_element.currentTime > 0;
}
return false;
};
/**
* Check if a recorded video is available
* @returns {boolean} True if a recorded video exists
*/
MediaCapture.prototype.has_video = function() {
if (!this.is_initialized || this.config.mode !== 'video') {
return false;
}
return this.blob !== null;
};
/**
* Check if a captured photo is available
* @returns {boolean} True if a captured photo exists
*/
MediaCapture.prototype.has_photo = function() {
if (!this.is_initialized || this.config.mode !== 'photo') {
return false;
}
return this.blob !== null;
};
/**
* Check if audio has been captured
* @returns {boolean} True if audio has been captured, false otherwise
* @public
*/
MediaCapture.prototype.has_audio = function() {
if (!this.is_initialized || this.config.mode !== 'audio') {
return false;
}
return this.blob !== null;
};
/**
* Get the captured media as a Blob
* @returns {Blob|null} The captured photo or video blob, or null if none exists
*/
MediaCapture.prototype.get_blob = function() {
return this.blob || null;
};
/**
* Get the captured media as a data URL
* @param {Function} callback - Callback function that receives the data URL
*/
MediaCapture.prototype.get_data_url = function(callback) {
if (!this.blob) {
callback(null);
return;
}
var reader = new FileReader();
reader.onloadend = function() {
callback(reader.result);
};
reader.readAsDataURL(this.blob);
};
/**
* Get the captured media as an Object URL
* @returns {string|null} Object URL for the blob, or null if none exists
*/
MediaCapture.prototype.get_object_url = function() {
if (!this.blob) {
return null;
}
return URL.createObjectURL(this.blob);
};
/**
* Get the MIME type of the captured media
* @returns {string|null} MIME type of the blob, or null if none exists
*/
MediaCapture.prototype.get_mime_type = function() {
if (!this.blob) {
return null;
}
return this.blob.type;
};
/**
* Get the filename that would be used for the captured media
* @returns {string|null} Generated filename, or null if no media captured
*/
MediaCapture.prototype.get_filename = function() {
if (!this.blob) {
return null;
}
var timestamp = new Date().toISOString().replace(/[:.]/g, '-');
var extension = this.config.mode === 'photo'
? this.config.photo_format
: this.config.video_format;
return this.config.mode + '_' + timestamp + '.' + extension;
};
/**
* Download the captured media
* @param {string} filename - Optional custom filename
*/
MediaCapture.prototype.download = function(filename) {
if (!this.blob) {
return;
}
var link = document.createElement('a');
link.href = URL.createObjectURL(this.blob);
link.download = filename || this.get_filename();
link.click();
URL.revokeObjectURL(link.href);
};
/**
* Get metadata about the captured media
* @returns {Object|null} Object containing metadata, or null if no media captured
*/
MediaCapture.prototype.get_meta = function() {
if (!this.blob) {
return null;
}
var meta = {
type: this.config.mode, // 'photo', 'video', or 'audio'
size: this.blob.size, // size in bytes
mime_type: this.blob.type,
timestamp: new Date().toISOString(),
filename: this.get_filename()
};
// Add mode-specific metadata
if (this.config.mode === 'photo') {
meta.format = this.config.photo_format;
meta.quality = this.config.photo_quality;
} else if (this.config.mode === 'video') {
meta.format = this.config.video_format;
// Try to get duration from video element if available
var video_element = this.element.querySelector('.mc-video');
if (video_element && video_element.duration) {
meta.duration = video_element.duration; // in seconds
}
} else if (this.config.mode === 'audio') {
meta.format = this.config.audio_format;
// Try to get duration from audio element if available
var audio_element = this.element.querySelector('.mc-audio');
if (audio_element && audio_element.duration) {
meta.duration = audio_element.duration; // in seconds
}
}
// Add resolution if it was set
if (this.config.resolution) {
meta.resolution = this.config.resolution;
}
// Add facing mode that was used
meta.facing_mode = this.config.facing_mode;
return meta;
};
/**
* Get supported audio and video formats for the current browser
* @returns {Object} Object with supported audio and video formats
* @public
*/
MediaCapture.prototype.get_supported_formats = function() {
var audio_formats = {
webm: false,
mp4: false,
ogg: false,
wav: false
};
var video_formats = {
webm: false,
mp4: false
};
// Check audio format support
var audio_mimes = {
webm: ['audio/webm;codecs=opus', 'audio/webm'],
mp4: ['audio/mp4', 'audio/mp4;codecs=mp4a.40.2'],
ogg: ['audio/ogg;codecs=opus', 'audio/ogg'],
wav: ['audio/wav']
};
for (var audio_format in audio_mimes) {
for (var i = 0; i < audio_mimes[audio_format].length; i++) {
if (MediaRecorder.isTypeSupported(audio_mimes[audio_format][i])) {
audio_formats[audio_format] = true;
break;
}
}
}
// Check video format support
var video_mimes = {
webm: ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'],
mp4: ['video/mp4;codecs=h264,aac', 'video/mp4']
};
for (var video_format in video_mimes) {
for (var j = 0; j < video_mimes[video_format].length; j++) {
if (MediaRecorder.isTypeSupported(video_mimes[video_format][j])) {
video_formats[video_format] = true;
break;
}
}
}
return {
audio: audio_formats,
video: video_formats
};
};
/**
* Check if QR code scanning is supported in the current browser
* Uses the native BarcodeDetector API
* @returns {Promise<boolean>} Promise resolving to true if QR scanning is supported
* @public
*/
MediaCapture.prototype.is_qr_supported = function() {
if (!('BarcodeDetector' in window)) {
return Promise.resolve(false);
}
return BarcodeDetector.getSupportedFormats()
.then(function(formats) {
return formats.indexOf('qr_code') !== -1;
})
.catch(function() {
return false;
});
};
/**
* Start QR code scanning against the live video stream
* Only valid in photo mode with qr_scan_enabled = true
* Fires on_qr_detected callback and 'qr-detected' custom event when a code is found
* @public
*/
MediaCapture.prototype.qr_scan_start = function() {
var self = this;
if (!this.is_initialized) {
this.log('qr_scan_start() failed: not initialized', 'warn');
return;
}
if (this.config.mode !== 'photo') {
this.log('qr_scan_start() is only available in photo mode', 'warn');
return;
}
if (this.qr_scan_active) {
this.log('qr_scan_start() already scanning', 'warn');
return;
}
if (!('BarcodeDetector' in window)) {
this.log('qr_scan_start() BarcodeDetector API not available in this browser', 'warn');
return;
}
var video_element = this.video_element;
if (!video_element) {
this.log('qr_scan_start() no video element found', 'warn');
return;
}
// Instantiate BarcodeDetector once and reuse it
try {
this.qr_barcode_detector = new BarcodeDetector({ formats: ['qr_code'] });
} catch(e) {
this.log('qr_scan_start() BarcodeDetector instantiation failed: ' + e.message, 'error');
return;
}
this.qr_scan_active = true;
this.log('QR scanning started');
// Show overlay if present
var container = this.element;
var qr_overlay = container.querySelector('.mc-qr-overlay');
if (qr_overlay) qr_overlay.style.display = '';
// Trigger callback
if (typeof this.config.on_qr_scan_start === 'function') {
this.config.on_qr_scan_start();
}
// Dispatch event
this.dispatch_event('qr-scan-start');
// Screen reader announcement
var qr_sr = container.querySelector('.mc-qr-sr');
if (qr_sr) qr_sr.textContent = this.config.label_qr_scanning;
// Scanning loop
var scan_interval = this.config.qr_scan_interval;
var video_el = this.video_element;
this.qr_scan_interval_id = setInterval(function() {
self.qr_scan_frame(video_el);
}, scan_interval);
};
/**
* Stop QR code scanning
* @public
*/
MediaCapture.prototype.qr_scan_stop = function() {
if (!this.qr_scan_active) {
return;
}
if (this.qr_scan_interval_id) {
clearInterval(this.qr_scan_interval_id);
this.qr_scan_interval_id = null;
}
this.qr_scan_active = false;
this.qr_barcode_detector = null;
// Hide overlay if present
var container = this.element;
var qr_overlay = container.querySelector('.mc-qr-overlay');
if (qr_overlay) qr_overlay.style.display = 'none';
// Clear screen reader announcement
var qr_sr = container.querySelector('.mc-qr-sr');
if (qr_sr) qr_sr.textContent = '';
this.log('QR scanning stopped');
// Trigger callback
if (typeof this.config.on_qr_scan_stop === 'function') {
this.config.on_qr_scan_stop();
}
// Dispatch event
this.dispatch_event('qr-scan-stop');
};
/**
* Process a single video frame for QR codes
* Called on interval by qr_scan_start - not intended for direct use
* @param {HTMLVideoElement} video_element - The live video element to scan
* @private
*/
MediaCapture.prototype.qr_scan_frame = function(video_element) {
var self = this;
// Don't scan if video isn't ready
if (!video_element || video_element.readyState < 2 || !this.qr_barcode_detector) {
return;
}
// Don't scan if stream is gone
if (!this.stream || !this.stream.active) {
return;
}
this.qr_barcode_detector.detect(video_element)
.then(function(barcodes) {
if (!barcodes || barcodes.length === 0) {
return;
}
var result = barcodes[0];
var raw_value = result.rawValue;
self.log('QR code detected: ' + raw_value);
// Play beep if enabled
if (self.config.qr_scan_beep) {
self.play_qr_beep();
}
// Screen reader announcement
var container = self.element;
var qr_sr = container.querySelector('.mc-qr-sr');
if (qr_sr) qr_sr.textContent = raw_value;
// Pause scanning if configured
if (self.config.qr_scan_pause_on_detect) {
self.qr_scan_stop();
}
// Auto-capture if configured
if (self.config.qr_scan_auto) {
self.photo_capture();
}
// Trigger callback
if (typeof self.config.on_qr_detected === 'function') {
self.config.on_qr_detected({
raw_value: raw_value,
bounding_box: result.boundingBox,
corner_points: result.cornerPoints,
format: result.format
});
}
// Dispatch event
self.dispatch_event('qr-detected', {
raw_value: raw_value,
bounding_box: result.boundingBox,
corner_points: result.cornerPoints,
format: result.format
});
})
.catch(function(error) {
// Detection errors are expected (e.g. frame not ready) - fail silently
self.log('QR detect error: ' + error.message);
});
};
/**
* Play a short beep sound on QR detection
* @private
*/
MediaCapture.prototype.play_qr_beep = function() {
try {
var audio_context = new (window.AudioContext || window.webkitAudioContext)();
var oscillator = audio_context.createOscillator();
var gain_node = audio_context.createGain();
oscillator.connect(gain_node);
gain_node.connect(audio_context.destination);
oscillator.frequency.value = 1800;
oscillator.type = 'sine';
var now = audio_context.currentTime;
gain_node.gain.setValueAtTime(0.2, now);
gain_node.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
oscillator.start(now);
oscillator.stop(now + 0.1);
} catch(e) {
// Fail silently
}
};
// Expose MediaCapture to global scope
window.MediaCapture = MediaCapture;
})(window);