UNPKG

logitech-brio-zoom-control

Version:

Cross-platform native library for controlling Logitech MX Brio camera zoom with 4K video support in Electron applications

712 lines (606 loc) â€ĸ 25.3 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Live Camera Demo with Zoom Controls</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; background: rgba(255, 255, 255, 0.95); border-radius: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.1); padding: 30px; backdrop-filter: blur(10px); } h1 { text-align: center; color: #333; margin-bottom: 30px; font-size: 2.5em; text-shadow: 2px 2px 4px rgba(0,0,0,0.1); } .video-container { position: relative; display: flex; justify-content: center; margin-bottom: 30px; } #video-preview { border-radius: 15px; border: 3px solid #4CAF50; box-shadow: 0 10px 25px rgba(0,0,0,0.2); max-width: 100%; height: auto; background: #000; } .video-overlay { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.7); color: white; padding: 8px 12px; border-radius: 8px; font-size: 14px; font-weight: bold; } .controls-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 20px; } .control-section { background: white; padding: 20px; border-radius: 15px; box-shadow: 0 5px 15px rgba(0,0,0,0.1); border: 1px solid #e0e0e0; } .control-section h3 { color: #333; margin-bottom: 15px; font-size: 1.2em; border-bottom: 2px solid #4CAF50; padding-bottom: 5px; } .button-group { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px; } button { background: linear-gradient(45deg, #4CAF50, #45a049); color: white; border: none; padding: 12px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; transition: all 0.3s ease; box-shadow: 0 4px 8px rgba(0,0,0,0.1); } button:hover { transform: translateY(-2px); box-shadow: 0 6px 12px rgba(0,0,0,0.2); background: linear-gradient(45deg, #45a049, #4CAF50); } button:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; } .primary-btn { background: linear-gradient(45deg, #2196F3, #1976D2); } .primary-btn:hover { background: linear-gradient(45deg, #1976D2, #2196F3); } .danger-btn { background: linear-gradient(45deg, #f44336, #d32f2f); } .danger-btn:hover { background: linear-gradient(45deg, #d32f2f, #f44336); } select { padding: 10px; border: 2px solid #ddd; border-radius: 8px; font-size: 14px; background: white; cursor: pointer; transition: border-color 0.3s ease; } select:focus { outline: none; border-color: #4CAF50; } .zoom-slider-container { margin: 15px 0; } .zoom-slider { width: 100%; height: 8px; border-radius: 4px; background: #ddd; outline: none; -webkit-appearance: none; appearance: none; } .zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #4CAF50; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.3); } .zoom-slider::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: #4CAF50; cursor: pointer; border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3); } .zoom-display { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: bold; color: #333; margin: 10px 0; } .zoom-value { background: #4CAF50; color: white; padding: 5px 15px; border-radius: 20px; min-width: 60px; text-align: center; } .status { padding: 15px; border-radius: 10px; margin: 20px 0; font-weight: bold; text-align: center; animation: slideIn 0.3s ease; } .status.success { background: linear-gradient(45deg, #4CAF50, #45a049); color: white; } .status.error { background: linear-gradient(45deg, #f44336, #d32f2f); color: white; } .status.info { background: linear-gradient(45deg, #2196F3, #1976D2); color: white; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .device-info { background: #f5f5f5; padding: 15px; border-radius: 10px; margin: 15px 0; border-left: 4px solid #4CAF50; } .feature-note { background: linear-gradient(45deg, #FF9800, #F57C00); color: white; padding: 15px; border-radius: 10px; margin: 20px 0; text-align: center; font-weight: bold; } @media (max-width: 768px) { .controls-grid { grid-template-columns: 1fr; } .button-group { justify-content: center; } h1 { font-size: 2em; } } </style> </head> <body> <div class="container"> <h1>đŸŽĨ Live Camera Demo</h1> <div id="status" class="status info"> Click "Start Camera" to begin live preview </div> <div class="feature-note"> <strong>4K Ready!</strong> This demo supports up to 4K UHD video recording with your Logitech MX Brio camera. Browser-based zoom controls included. For hardware-level zoom control, use the Electron version. </div> <div class="video-container"> <video id="video-preview" width="960" height="540" autoplay muted></video> <div class="video-overlay" id="video-info">No camera active</div> </div> <!-- Digital Zoom Section - Right under video --> <div class="control-section" style="margin-bottom: 30px;"> <h3>🔍 Digital Zoom Controls</h3> <div class="zoom-display"> <span>Zoom Level:</span> <span class="zoom-value" id="zoom-display">1.0x</span> </div> <div class="zoom-slider-container"> <input type="range" id="zoom-slider" class="zoom-slider" min="1" max="5" step="0.1" value="1" disabled> </div> <div class="button-group" style="justify-content: center;"> <button id="zoom-in-btn" disabled>Zoom In (+)</button> <button id="zoom-out-btn" disabled>Zoom Out (-)</button> <button id="reset-zoom-btn" disabled>Reset Zoom</button> </div> </div> <div class="controls-grid"> <!-- Camera Control Section --> <div class="control-section"> <h3>📹 Camera Control</h3> <div class="button-group"> <button id="start-camera-btn" class="primary-btn">Start Camera</button> <button id="stop-camera-btn" class="danger-btn" disabled>Stop Camera</button> </div> <div style="margin: 15px 0;"> <label for="camera-select" style="display: block; margin-bottom: 5px; font-weight: bold;">Select Camera:</label> <select id="camera-select" style="width: 100%;"> <option value="">Detecting cameras...</option> </select> </div> <div style="margin: 15px 0;"> <label for="quality-select" style="display: block; margin-bottom: 5px; font-weight: bold;">Video Quality:</label> <select id="quality-select" style="width: 100%;"> <option value="3840x2160">đŸ”Ĩ 4K UHD (3840×2160)</option> <option value="2560x1440">đŸŽ¯ 1440p (2560×1440)</option> <option value="1920x1080" selected>đŸŽŦ 1080p (1920×1080)</option> <option value="1280x720">đŸ“ē 720p (1280×720)</option> <option value="640x480">📱 480p (640×480)</option> <option value="320x240">💾 240p (320×240)</option> </select> </div> </div> <!-- Camera Info Section --> <div class="control-section"> <h3>â„šī¸ Camera Information</h3> <div id="camera-info" class="device-info"> <div><strong>Camera:</strong> <span id="camera-name">None selected</span></div> <div><strong>Resolution:</strong> <span id="camera-resolution">-</span></div> <div><strong>Status:</strong> <span id="camera-status">Inactive</span></div> <div><strong>4K Support:</strong> <span id="uhd-support">✅ Available</span></div> <div><strong>Zoom Support:</strong> <span id="zoom-support">Digital + Hardware (MX Brio)</span></div> </div> </div> <!-- Advanced Controls Section --> <div class="control-section"> <h3>âš™ī¸ Advanced Controls</h3> <div style="margin: 15px 0;"> <label for="fps-select" style="display: block; margin-bottom: 5px; font-weight: bold;">Frame Rate:</label> <select id="fps-select" style="width: 100%;"> <option value="60">60 FPS (1080p/720p)</option> <option value="30" selected>30 FPS (4K/All)</option> <option value="24">24 FPS (Cinematic)</option> <option value="15">15 FPS (Low bandwidth)</option> </select> </div> <div class="button-group"> <button id="fullscreen-btn" disabled>Fullscreen</button> <button id="screenshot-btn" disabled>Screenshot</button> <button id="flip-btn" disabled>Flip Video</button> </div> </div> </div> </div> <script> let videoStream = null; let currentZoom = 1.0; let videoFlipped = false; // DOM elements const videoPreview = document.getElementById('video-preview'); const videoInfo = document.getElementById('video-info'); const statusEl = document.getElementById('status'); const startCameraBtn = document.getElementById('start-camera-btn'); const stopCameraBtn = document.getElementById('stop-camera-btn'); const cameraSelect = document.getElementById('camera-select'); const qualitySelect = document.getElementById('quality-select'); const fpsSelect = document.getElementById('fps-select'); const zoomSlider = document.getElementById('zoom-slider'); const zoomDisplay = document.getElementById('zoom-display'); const zoomInBtn = document.getElementById('zoom-in-btn'); const zoomOutBtn = document.getElementById('zoom-out-btn'); const resetZoomBtn = document.getElementById('reset-zoom-btn'); const fullscreenBtn = document.getElementById('fullscreen-btn'); const screenshotBtn = document.getElementById('screenshot-btn'); const flipBtn = document.getElementById('flip-btn'); const cameraName = document.getElementById('camera-name'); const cameraResolution = document.getElementById('camera-resolution'); const cameraStatus = document.getElementById('camera-status'); // Utility functions function showStatus(message, type = 'info') { statusEl.textContent = message; statusEl.className = `status ${type}`; } function updateZoomDisplay(zoom) { currentZoom = zoom; zoomDisplay.textContent = `${zoom.toFixed(1)}x`; // Only update slider value if it's different to avoid infinite loops if (Math.abs(parseFloat(zoomSlider.value) - zoom) > 0.01) { zoomSlider.value = zoom; } // Apply CSS transform to video (works even without active stream) const transform = `scale(${zoom})${videoFlipped ? ' scaleX(-1)' : ''}`; videoPreview.style.transform = transform; console.log('Updated zoom display to:', zoom, 'Transform:', transform); } function updateCameraInfo(deviceLabel, resolution, status) { cameraName.textContent = deviceLabel || 'Unknown'; cameraResolution.textContent = resolution || '-'; cameraStatus.textContent = status || 'Unknown'; } function updateControlButtons(enabled) { // Keep zoom controls always enabled so users can test them zoomSlider.disabled = false; zoomInBtn.disabled = false; zoomOutBtn.disabled = false; resetZoomBtn.disabled = false; // These require active camera fullscreenBtn.disabled = !enabled; screenshotBtn.disabled = !enabled; flipBtn.disabled = !enabled; stopCameraBtn.disabled = !enabled; startCameraBtn.disabled = enabled; } // Camera discovery async function populateCameraList() { try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter(device => device.kind === 'videoinput'); cameraSelect.innerHTML = ''; if (videoDevices.length === 0) { cameraSelect.innerHTML = '<option value="">No cameras found</option>'; return; } videoDevices.forEach((device, index) => { const option = document.createElement('option'); option.value = device.deviceId; let label = device.label || `Camera ${index + 1}`; if (label.toLowerCase().includes('logitech')) { label += ' đŸŽ¯'; // Mark Logitech cameras } option.textContent = label; cameraSelect.appendChild(option); }); showStatus(`Found ${videoDevices.length} camera(s)`, 'success'); } catch (error) { console.error('Error enumerating devices:', error); showStatus('Error accessing camera list', 'error'); } } // Camera control functions async function startCamera() { try { const quality = qualitySelect.value.split('x'); const fps = parseInt(fpsSelect.value); const deviceId = cameraSelect.value; const width = parseInt(quality[0]); const height = parseInt(quality[1]); // Build constraints with fallback for high resolutions const constraints = { video: { width: { ideal: width, min: 640 }, height: { ideal: height, min: 480 }, frameRate: { ideal: fps, min: 15 } } }; // For 4K, be more specific about constraints if (width >= 3840) { constraints.video.width.max = 4096; constraints.video.height.max = 2304; // 4K typically works better at 30fps or lower if (fps > 30) { constraints.video.frameRate.ideal = 30; showStatus('4K video: Frame rate adjusted to 30fps for optimal performance', 'info'); } } if (deviceId) { constraints.video.deviceId = { exact: deviceId }; } console.log('Requesting video with constraints:', constraints); videoStream = await navigator.mediaDevices.getUserMedia(constraints); videoPreview.srcObject = videoStream; // Get actual video track settings const videoTrack = videoStream.getVideoTracks()[0]; const settings = videoTrack.getSettings(); const selectedDevice = Array.from(cameraSelect.options) .find(opt => opt.value === deviceId); updateCameraInfo( selectedDevice ? selectedDevice.textContent : 'Default Camera', `${settings.width}×${settings.height} @ ${settings.frameRate}fps`, 'Active' ); videoInfo.textContent = `${settings.width}×${settings.height} @ ${Math.round(settings.frameRate)}fps`; updateControlButtons(true); updateZoomDisplay(1.0); showStatus('Camera started successfully', 'success'); } catch (error) { console.error('Error starting camera:', error); showStatus(`Failed to start camera: ${error.message}`, 'error'); } } async function stopCamera() { try { if (videoStream) { videoStream.getTracks().forEach(track => track.stop()); videoStream = null; } videoPreview.srcObject = null; videoInfo.textContent = 'No camera active'; updateCameraInfo('None selected', '-', 'Inactive'); updateControlButtons(false); updateZoomDisplay(1.0); showStatus('Camera stopped', 'info'); } catch (error) { console.error('Error stopping camera:', error); showStatus('Error stopping camera', 'error'); } } // Zoom functions function zoomIn() { const newZoom = Math.min(currentZoom + 0.2, 5.0); updateZoomDisplay(newZoom); } function zoomOut() { const newZoom = Math.max(currentZoom - 0.2, 1.0); updateZoomDisplay(newZoom); } function resetZoom() { updateZoomDisplay(1.0); } // Advanced functions function toggleFullscreen() { if (videoPreview.requestFullscreen) { videoPreview.requestFullscreen(); } else if (videoPreview.webkitRequestFullscreen) { videoPreview.webkitRequestFullscreen(); } else if (videoPreview.msRequestFullscreen) { videoPreview.msRequestFullscreen(); } } function takeScreenshot() { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = videoPreview.videoWidth; canvas.height = videoPreview.videoHeight; ctx.drawImage(videoPreview, 0, 0); const link = document.createElement('a'); link.download = `camera-screenshot-${new Date().getTime()}.png`; link.href = canvas.toDataURL(); link.click(); showStatus('Screenshot saved', 'success'); } function flipVideo() { videoFlipped = !videoFlipped; const transform = `scale(${currentZoom})${videoFlipped ? ' scaleX(-1)' : ''}`; videoPreview.style.transform = transform; flipBtn.textContent = videoFlipped ? 'Unflip Video' : 'Flip Video'; showStatus(videoFlipped ? 'Video flipped' : 'Video unflipped', 'info'); } // Event listeners startCameraBtn.addEventListener('click', startCamera); stopCameraBtn.addEventListener('click', stopCamera); zoomSlider.addEventListener('input', (e) => { const zoomValue = parseFloat(e.target.value); console.log('Zoom slider changed to:', zoomValue); updateZoomDisplay(zoomValue); }); // Also listen for 'change' event for better compatibility zoomSlider.addEventListener('change', (e) => { const zoomValue = parseFloat(e.target.value); console.log('Zoom slider changed (change event) to:', zoomValue); updateZoomDisplay(zoomValue); }); zoomInBtn.addEventListener('click', zoomIn); zoomOutBtn.addEventListener('click', zoomOut); resetZoomBtn.addEventListener('click', resetZoom); fullscreenBtn.addEventListener('click', toggleFullscreen); screenshotBtn.addEventListener('click', takeScreenshot); flipBtn.addEventListener('click', flipVideo); qualitySelect.addEventListener('change', async () => { if (videoStream) { await stopCamera(); await startCamera(); } }); fpsSelect.addEventListener('change', async () => { if (videoStream) { await stopCamera(); await startCamera(); } }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (!videoStream) return; switch(e.key) { case '=': case '+': e.preventDefault(); zoomIn(); break; case '-': e.preventDefault(); zoomOut(); break; case '0': e.preventDefault(); resetZoom(); break; case 'f': case 'F': if (e.ctrlKey) { e.preventDefault(); toggleFullscreen(); } break; case 's': case 'S': if (e.ctrlKey) { e.preventDefault(); takeScreenshot(); } break; } }); // Cleanup on page unload window.addEventListener('beforeunload', () => { if (videoStream) { videoStream.getTracks().forEach(track => track.stop()); } }); // Initialize document.addEventListener('DOMContentLoaded', () => { populateCameraList(); showStatus('Ready! Select a camera and click "Start Camera"', 'info'); // Initialize zoom controls updateZoomDisplay(1.0); updateControlButtons(false); console.log('Page loaded, zoom slider value:', zoomSlider.value); console.log('Zoom controls initialized'); }); </script> </body> </html>