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
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>