Spaces:
Running
Running
| <html> | |
| <head> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: transparent; font-family: -apple-system, 'Inter', sans-serif; } | |
| #container { width: 100%; position: relative; border-radius: 12px; overflow: hidden; background: #0a0a1a; } | |
| video { | |
| width: 100%; display: block; | |
| border-radius: 12px; | |
| background: #0a0a1a; | |
| min-height: 480px; | |
| max-height: 680px; | |
| object-fit: cover; | |
| } | |
| #overlay { | |
| position: absolute; top: 10px; left: 10px; | |
| display: flex; gap: 8px; align-items: center; z-index: 10; | |
| } | |
| .badge { | |
| padding: 5px 12px; border-radius: 8px; font-size: 12px; | |
| font-weight: 700; letter-spacing: 0.5px; | |
| backdrop-filter: blur(4px); | |
| } | |
| .badge-live { background: rgba(255,50,50,0.9); color: white; animation: pulse 1.5s infinite; } | |
| .badge-ready { background: rgba(100,100,100,0.85); color: #ddd; } | |
| .badge-ended { background: rgba(0,180,100,0.85); color: white; } | |
| .badge-timer { background: rgba(0,0,0,0.7); color: #00D4FF; font-variant-numeric: tabular-nums; } | |
| @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } | |
| #controls { text-align: center; margin-top: 12px; } | |
| button { | |
| padding: 12px 32px; border: none; border-radius: 12px; | |
| font-size: 15px; font-weight: 700; cursor: pointer; | |
| transition: all 0.2s; letter-spacing: 0.5px; | |
| } | |
| #startBtn { | |
| background: linear-gradient(90deg, #9B6FCE, #6C63FF); | |
| color: white; box-shadow: 0 4px 15px rgba(108,99,255,0.3); | |
| } | |
| #startBtn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(108,99,255,0.4); } | |
| #stopBtn { | |
| background: linear-gradient(90deg, #FF4444, #CC2200); | |
| color: white; display: none; | |
| box-shadow: 0 4px 15px rgba(255,68,68,0.3); | |
| } | |
| #stopBtn:hover { transform: translateY(-1px); } | |
| #errorMsg { | |
| color: #FF6B6B; font-size: 13px; margin-top: 8px; | |
| display: none; text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <video id="video" autoplay playsinline muted></video> | |
| <div id="overlay"> | |
| <span id="statusBadge" class="badge badge-ready">READY</span> | |
| <span id="timerBadge" class="badge badge-timer" style="display:none">60s</span> | |
| </div> | |
| </div> | |
| <div id="controls"> | |
| <button id="startBtn">▶ START SESSION</button> | |
| <button id="stopBtn">⏹ STOP & VIEW REPORT</button> | |
| </div> | |
| <div id="errorMsg"></div> | |
| <canvas id="canvas" style="display:none"></canvas> | |
| <script> | |
| // ββ Streamlit Component Protocol ββββββββββββββββββββββββββββββββ | |
| function sendToStreamlit(type, data) { | |
| window.parent.postMessage({ isStreamlitMessage: true, type: type, ...data }, "*"); | |
| } | |
| function setComponentValue(value) { | |
| sendToStreamlit("streamlit:setComponentValue", { value: value }); | |
| } | |
| function setFrameHeight(height) { | |
| sendToStreamlit("streamlit:setFrameHeight", { height: height }); | |
| } | |
| function componentReady() { | |
| sendToStreamlit("streamlit:componentReady", { apiVersion: 1 }); | |
| } | |
| // ββ Persistent State (survives Streamlit re-renders) ββββββββββββ | |
| if (!window._ws) { | |
| window._ws = { | |
| stream: null, | |
| capturing: false, | |
| frameInterval: null, | |
| audioRecorder: null, | |
| audioInterval: null, | |
| startTime: 0, | |
| timerInterval: null, | |
| frameCount: 0, | |
| }; | |
| } | |
| const S = window._ws; | |
| // ββ DOM Elements ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const video = document.getElementById('video'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statusBadge = document.getElementById('statusBadge'); | |
| const timerBadge = document.getElementById('timerBadge'); | |
| const startBtn = document.getElementById('startBtn'); | |
| const stopBtn = document.getElementById('stopBtn'); | |
| const errorMsg = document.getElementById('errorMsg'); | |
| // ββ Restore UI if already capturing (after Streamlit re-render) β | |
| if (S.capturing && S.stream) { | |
| video.srcObject = S.stream; | |
| statusBadge.textContent = '\u25CF LIVE'; | |
| statusBadge.className = 'badge badge-live'; | |
| timerBadge.style.display = ''; | |
| startBtn.style.display = 'none'; | |
| stopBtn.style.display = ''; | |
| } | |
| // ββ Start Capture βββββββββββββββββββββββββββββββββββββββββββββββ | |
| startBtn.onclick = async function() { | |
| errorMsg.style.display = 'none'; | |
| try { | |
| S.stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, | |
| audio: true | |
| }); | |
| video.srcObject = S.stream; | |
| S.capturing = true; | |
| S.startTime = Date.now(); | |
| S.frameCount = 0; | |
| canvas.width = 640; | |
| canvas.height = 480; | |
| // Capture + send a video frame every 2 seconds | |
| captureAndSend(); // immediate first frame | |
| S.frameInterval = setInterval(captureAndSend, 2000); | |
| // Audio: record 5-second chunks via MediaRecorder (longer = better STT) | |
| try { | |
| const audioTrack = S.stream.getAudioTracks()[0]; | |
| if (audioTrack) { | |
| const audioStream = new MediaStream([audioTrack]); | |
| startAudioChunk(audioStream); | |
| S.audioInterval = setInterval(function() { | |
| if (S.audioRecorder && S.audioRecorder.state === 'recording') { | |
| S.audioRecorder.stop(); | |
| startAudioChunk(audioStream); | |
| } | |
| }, 5000); | |
| } | |
| } catch(ae) { console.log('[Webcam] Audio setup error:', ae); } | |
| // Timer countdown | |
| updateTimer(); | |
| S.timerInterval = setInterval(updateTimer, 1000); | |
| // UI update | |
| statusBadge.textContent = '\u25CF LIVE'; | |
| statusBadge.className = 'badge badge-live'; | |
| timerBadge.style.display = ''; | |
| startBtn.style.display = 'none'; | |
| stopBtn.style.display = ''; | |
| setComponentValue({ type: 'started', ts: Date.now() }); | |
| // Auto-stop after 62 seconds (small buffer) | |
| setTimeout(function() { if (S.capturing) stopCapture(); }, 62000); | |
| } catch(err) { | |
| errorMsg.textContent = 'Camera/mic access denied: ' + err.message; | |
| errorMsg.style.display = 'block'; | |
| setComponentValue({ type: 'error', message: err.message }); | |
| } | |
| }; | |
| // ββ Stop Capture ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| stopBtn.onclick = function() { stopCapture(); }; | |
| function stopCapture() { | |
| S.capturing = false; | |
| if (S.frameInterval) { clearInterval(S.frameInterval); S.frameInterval = null; } | |
| if (S.audioInterval) { clearInterval(S.audioInterval); S.audioInterval = null; } | |
| if (S.timerInterval) { clearInterval(S.timerInterval); S.timerInterval = null; } | |
| if (S.audioRecorder && S.audioRecorder.state === 'recording') { | |
| try { S.audioRecorder.stop(); } catch(e) {} | |
| } | |
| if (S.stream) { | |
| S.stream.getTracks().forEach(function(t) { t.stop(); }); | |
| S.stream = null; | |
| } | |
| video.srcObject = null; | |
| statusBadge.textContent = 'SESSION ENDED'; | |
| statusBadge.className = 'badge badge-ended'; | |
| timerBadge.style.display = 'none'; | |
| startBtn.style.display = 'none'; | |
| stopBtn.style.display = 'none'; | |
| setComponentValue({ type: 'stopped', ts: Date.now() }); | |
| } | |
| // ββ Frame Capture βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function captureAndSend() { | |
| if (!S.capturing || video.readyState < 2) return; | |
| canvas.width = video.videoWidth || 640; | |
| canvas.height = video.videoHeight || 480; | |
| ctx.drawImage(video, 0, 0); | |
| var dataUrl = canvas.toDataURL('image/jpeg', 0.65); | |
| S.frameCount++; | |
| setComponentValue({ | |
| type: 'frame', | |
| data: dataUrl, | |
| frame: S.frameCount, | |
| ts: Date.now() | |
| }); | |
| } | |
| // ββ Audio Recording βββββββββββββββββββββββββββββββββββββββββββββ | |
| function startAudioChunk(audioStream) { | |
| try { | |
| var mimeType = 'audio/webm;codecs=opus'; | |
| if (!MediaRecorder.isTypeSupported(mimeType)) { | |
| mimeType = 'audio/webm'; | |
| } | |
| if (!MediaRecorder.isTypeSupported(mimeType)) { | |
| mimeType = ''; // browser default | |
| } | |
| S.audioRecorder = new MediaRecorder(audioStream, mimeType ? { mimeType: mimeType } : {}); | |
| S.audioRecorder.ondataavailable = function(e) { | |
| if (e.data.size > 0 && S.capturing) { | |
| var reader = new FileReader(); | |
| reader.onload = function() { | |
| setComponentValue({ | |
| type: 'audio', | |
| data: reader.result, | |
| ts: Date.now() | |
| }); | |
| }; | |
| reader.readAsDataURL(e.data); | |
| } | |
| }; | |
| S.audioRecorder.start(); | |
| } catch(e) { console.log('[Webcam] MediaRecorder error:', e); } | |
| } | |
| // ββ Timer βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateTimer() { | |
| if (!S.capturing) return; | |
| var elapsed = Math.floor((Date.now() - S.startTime) / 1000); | |
| var remaining = Math.max(0, 60 - elapsed); | |
| timerBadge.textContent = remaining + 's'; | |
| if (remaining <= 10) { | |
| timerBadge.style.color = '#FF4444'; | |
| } | |
| if (remaining <= 0) { stopCapture(); } | |
| } | |
| // ββ Handle Streamlit re-render messages βββββββββββββββββββββββββ | |
| window.addEventListener("message", function(event) { | |
| if (event.data && event.data.type === "streamlit:render") { | |
| setFrameHeight(document.body.scrollHeight || 520); | |
| } | |
| }); | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| setFrameHeight(620); | |
| componentReady(); | |
| </script> | |
| </body> | |
| </html> | |