Spaces:
Sleeping
Sleeping
| {% extends 'base.html' %} | |
| {% block title %}Live Detection — Constellar{% endblock %} | |
| {% block extra_css %} | |
| <style> | |
| /* ── status bar ── */ | |
| .status-bar { | |
| display: flex; align-items: center; justify-content: center; gap: .625rem; | |
| padding: .75rem 1.25rem; | |
| border-radius: 10px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| font-size: .8125rem; font-weight: 500; | |
| color: var(--muted); | |
| transition: all .25s; | |
| } | |
| .status-dot { | |
| width: 8px; height: 8px; border-radius: 50%; | |
| background: var(--accent); | |
| flex-shrink: 0; | |
| } | |
| .status-bar.status-error { background: rgba(239,68,68,.08); border-color: rgba(239,68,68,.25); color: #fca5a5; } | |
| .status-bar.status-error .status-dot { background: #ef4444; } | |
| .status-bar.status-success { background: rgba(34,197,94,.08); border-color: rgba(34,197,94,.25); color: #86efac; } | |
| .status-bar.status-success .status-dot { background: #22c55e; } | |
| .status-bar.status-info { background: rgba(99,102,241,.07); border-color: rgba(99,102,241,.22); color: #a5b4fc; } | |
| .status-bar.status-info .status-dot { background: var(--accent); animation: dotpulse 1.4s ease-in-out infinite; } | |
| @keyframes dotpulse { 0%,100%{opacity:1} 50%{opacity:.35} } | |
| /* ── video container ── */ | |
| .video-shell { | |
| background: rgba(0,0,0,.45); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| overflow: hidden; | |
| } | |
| .video-container { | |
| position: relative; | |
| width: 100%; height: 0; | |
| padding-bottom: 62%; | |
| } | |
| @media (min-width: 768px) { .video-container { padding-bottom: 56.25%; } } | |
| .video-container video, | |
| .video-container img, | |
| .video-container canvas { | |
| position: absolute; top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| object-fit: cover; | |
| } | |
| /* ── control buttons ── */ | |
| .ctl-btn { | |
| font-family: 'Inter', sans-serif; | |
| font-size: .875rem; font-weight: 600; | |
| display: inline-flex; align-items: center; justify-content: center; gap: .5rem; | |
| padding: .7rem 1.5rem; | |
| min-height: 44px; | |
| border-radius: 8px; border: none; cursor: pointer; | |
| transition: all .18s; | |
| touch-action: manipulation; | |
| } | |
| .ctl-btn.primary { background: var(--accent); color: #fff; } | |
| .ctl-btn.primary:hover:not(:disabled) { background: #818cf8; box-shadow: 0 0 20px rgba(99,102,241,.35); transform: translateY(-1px); } | |
| .ctl-btn.outline { | |
| background: rgba(255,255,255,.04); | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| } | |
| .ctl-btn.outline:hover:not(:disabled) { color: #fff; border-color: rgba(255,255,255,.18); background: rgba(255,255,255,.07); } | |
| .ctl-btn:disabled { opacity: .4; cursor: not-allowed; transform: none ; box-shadow: none ; } | |
| /* ── troubleshoot steps ── */ | |
| .ts-step { display: flex; gap: .875rem; align-items: flex-start; } | |
| .ts-num { | |
| width: 22px; height: 22px; flex-shrink: 0; | |
| border-radius: 6px; | |
| background: rgba(99,102,241,.15); | |
| border: 1px solid rgba(99,102,241,.3); | |
| color: #a5b4fc; | |
| font-size: .6875rem; font-weight: 700; | |
| display: flex; align-items: center; justify-content: center; | |
| margin-top: 2px; | |
| } | |
| .ts-step h4 { font-size: .8125rem; font-weight: 600; margin: 0 0 .25rem; color: var(--text); } | |
| .ts-step p { font-size: .75rem; color: var(--muted); line-height: 1.6; margin: 0; } | |
| .ts-step code { | |
| background: rgba(255,255,255,.06); | |
| padding: .1rem .375rem; border-radius: 4px; | |
| font-size: .6875rem; | |
| } | |
| /* ── fullscreen ── */ | |
| .fullscreen-controls { | |
| position: fixed; bottom: 20px; left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 1000; | |
| display: flex; gap: .5rem; | |
| background: rgba(6,13,31,.9); | |
| border: 1px solid var(--border); | |
| padding: .625rem; | |
| border-radius: 12px; | |
| backdrop-filter: blur(16px); | |
| } | |
| .fullscreen-controls .ctl-btn { padding: .5rem 1rem; font-size: .78125rem; min-height: 38px; } | |
| .fullscreen-active { | |
| position: fixed ; | |
| top: 0 ; left: 0 ; | |
| width: 100vw ; height: 100vh ; | |
| z-index: 999 ; | |
| background: black ; | |
| padding-bottom: 0 ; | |
| border-radius: 0 ; | |
| } | |
| </style> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="section" style="max-width:900px; padding-top:3.5rem; padding-bottom:2rem;"> | |
| <!-- ════ HEADER ════ --> | |
| <div style="text-align:center; margin-bottom:2rem;"> | |
| <div class="tag" style="margin-bottom:1.25rem;">Real-Time Analysis</div> | |
| <h1 style="font-family:'Orbitron',monospace; font-size:clamp(1.5rem,3.5vw,2.25rem); font-weight:900; margin:0 0 .875rem; letter-spacing:-.01em;"> | |
| Live Constellation Detection | |
| </h1> | |
| <p style="font-size:.9375rem; color:var(--muted); line-height:1.7; max-width:520px; margin:0 auto;"> | |
| Point your camera at the night sky — detected constellations are outlined and labeled directly on the feed. | |
| </p> | |
| </div> | |
| <!-- ════ STATUS ════ --> | |
| <div id="status" class="status-bar status-info" style="margin-bottom:1rem;"> | |
| <div class="status-content" style="display:flex; align-items:center; gap:.625rem;"> | |
| <div class="status-dot status-icon"></div> | |
| <span>Press "Start Camera" to begin</span> | |
| </div> | |
| </div> | |
| <!-- ════ VIDEO ════ --> | |
| <div class="video-shell" style="margin-bottom:1rem;"> | |
| <div id="videoContainer" class="video-container"> | |
| <video id="video" autoplay playsinline muted style="display:none;"></video> | |
| <canvas id="canvas" width="640" height="480" class="hidden"></canvas> | |
| <img id="result" style="display:none;" alt="Detection results" /> | |
| <!-- placeholder --> | |
| <div id="placeholder" style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:1.5rem; text-align:center;"> | |
| <div style="width:56px; height:56px; border-radius:14px; background:rgba(99,102,241,.1); border:1px solid rgba(99,102,241,.22); display:flex; align-items:center; justify-content:center; margin-bottom:1rem;"> | |
| <svg width="24" height="24" fill="none" stroke="#a5b4fc" stroke-width="1.6" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"/> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"/> | |
| </svg> | |
| </div> | |
| <h3 style="font-size:.9375rem; font-weight:600; margin:0 0 .375rem; color:var(--text);">Camera preview</h3> | |
| <p style="font-size:.78125rem; color:var(--muted); max-width:280px; margin:0; line-height:1.6;">Your camera feed will appear here. Point toward the night sky for best results.</p> | |
| </div> | |
| <!-- fullscreen toggle --> | |
| <button id="fullscreenToggle" style="display:none; position:absolute; top:12px; right:12px; background:rgba(6,13,31,.7); border:1px solid var(--border); color:#fff; padding:.5rem; border-radius:8px; cursor:pointer; backdrop-filter:blur(8px);"> | |
| <svg class="w-5 h-5" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ════ CONTROLS ════ --> | |
| <div class="glass-card" style="padding:1.5rem; margin-bottom:1rem;"> | |
| <div style="display:flex; flex-wrap:wrap; gap:.625rem; justify-content:center;"> | |
| <button id="startCamera" class="ctl-btn primary"> | |
| <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M15 10l4.553-2.276A1 1 0 0 1 21 8.618v6.764a1 1 0 0 1-1.447.894L15 14M5 18h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"/></svg> | |
| Start Camera | |
| </button> | |
| <button id="switchCamera" disabled class="ctl-btn outline"> | |
| <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 0 0 4.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 0 1-15.357-2m15.357 2H15"/></svg> | |
| Switch Camera | |
| </button> | |
| <button id="startDetection" disabled class="ctl-btn primary"> | |
| <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path stroke-linecap="round" d="m21 21-4.35-4.35"/></svg> | |
| Start Detection | |
| </button> | |
| <button id="stopDetection" disabled class="ctl-btn outline"> | |
| <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg> | |
| Stop | |
| </button> | |
| <button id="showTroubleshooting" class="ctl-btn outline"> | |
| <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg> | |
| Help | |
| </button> | |
| </div> | |
| <p style="text-align:center; font-size:.6875rem; color:rgba(255,255,255,.2); margin:1rem 0 0;"> | |
| Shortcuts: <kbd style="background:rgba(255,255,255,.06); padding:.1rem .375rem; border-radius:4px;">Space</kbd> detect · | |
| <kbd style="background:rgba(255,255,255,.06); padding:.1rem .375rem; border-radius:4px;">F</kbd> fullscreen · | |
| <kbd style="background:rgba(255,255,255,.06); padding:.1rem .375rem; border-radius:4px;">C</kbd> switch camera | |
| </p> | |
| </div> | |
| <!-- ════ TROUBLESHOOTING ════ --> | |
| <div id="troubleshooting" class="glass-card" style="padding:1.75rem; margin-bottom:1rem; display:none;"> | |
| <h3 style="font-size:.9375rem; font-weight:700; margin:0 0 1.25rem; color:var(--text);">Camera Troubleshooting</h3> | |
| <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:1rem 2rem; margin-bottom:1.25rem;"> | |
| <div class="ts-step"> | |
| <div class="ts-num">1</div> | |
| <div> | |
| <h4>HTTPS required</h4> | |
| <p>Camera access needs <code>https://</code> or <code>localhost</code>. Check your address bar.</p> | |
| </div> | |
| </div> | |
| <div class="ts-step"> | |
| <div class="ts-num">2</div> | |
| <div> | |
| <h4>Camera permission</h4> | |
| <p>Allow camera access when prompted, or reset it in your browser's site settings.</p> | |
| </div> | |
| </div> | |
| <div class="ts-step"> | |
| <div class="ts-num">3</div> | |
| <div> | |
| <h4>Camera in use</h4> | |
| <p>Close other apps that may be holding the camera (video calls, camera app).</p> | |
| </div> | |
| </div> | |
| <div class="ts-step"> | |
| <div class="ts-num">4</div> | |
| <div> | |
| <h4>iPhone / iPad</h4> | |
| <p>Use Safari on iOS for the most reliable camera support.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="background:rgba(255,255,255,.03); border:1px solid var(--border); border-radius:8px; padding:.875rem 1rem; font-size:.71875rem; color:var(--muted); line-height:1.8; word-break:break-all;"> | |
| <div><span style="color:#a5b4fc; font-weight:600;">URL:</span> <code id="currentUrl"></code></div> | |
| <div><span style="color:#a5b4fc; font-weight:600;">Device:</span> <code id="deviceInfo"></code></div> | |
| </div> | |
| </div> | |
| <!-- ════ TIPS ════ --> | |
| <div class="glass-card" style="padding:1.5rem;"> | |
| <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:1.25rem;"> | |
| <div style="display:flex; gap:.75rem; align-items:flex-start;"> | |
| <svg width="16" height="16" fill="none" stroke="#a5b4fc" stroke-width="1.8" viewBox="0 0 24 24" style="flex-shrink:0; margin-top:2px;"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"/></svg> | |
| <div> | |
| <h3 style="font-size:.8125rem; font-weight:600; margin:0 0 .25rem;">Dark conditions</h3> | |
| <p style="font-size:.75rem; color:var(--muted); line-height:1.6; margin:0;">Use at night, away from bright city lights.</p> | |
| </div> | |
| </div> | |
| <div style="display:flex; gap:.75rem; align-items:flex-start;"> | |
| <svg width="16" height="16" fill="none" stroke="#a5b4fc" stroke-width="1.8" viewBox="0 0 24 24" style="flex-shrink:0; margin-top:2px;"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3"/></svg> | |
| <div> | |
| <h3 style="font-size:.8125rem; font-weight:600; margin:0 0 .25rem;">Hold steady</h3> | |
| <p style="font-size:.75rem; color:var(--muted); line-height:1.6; margin:0;">A stable device gives clearer frames for detection.</p> | |
| </div> | |
| </div> | |
| <div style="display:flex; gap:.75rem; align-items:flex-start;"> | |
| <svg width="16" height="16" fill="none" stroke="#a5b4fc" stroke-width="1.8" viewBox="0 0 24 24" style="flex-shrink:0; margin-top:2px;"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg> | |
| <div> | |
| <h3 style="font-size:.8125rem; font-weight:600; margin:0 0 .25rem;">Unobstructed view</h3> | |
| <p style="font-size:.75rem; color:var(--muted); line-height:1.6; margin:0;">Avoid clouds, trees, and buildings in the frame.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ════ FULLSCREEN CONTROLS ════ --> | |
| <div id="fullscreenControls" class="fullscreen-controls" style="display:none;"> | |
| <button id="fullscreenStartDetection" class="ctl-btn primary">Detect</button> | |
| <button id="fullscreenStopDetection" class="ctl-btn outline">Stop</button> | |
| <button id="fullscreenSwitchCamera" class="ctl-btn outline">Switch</button> | |
| <button id="exitFullscreen" class="ctl-btn outline">Exit</button> | |
| </div> | |
| {% endblock %} | |
| {% block extra_js %} | |
| <script> | |
| const video = document.getElementById('video'); | |
| const canvas = document.getElementById('canvas'); | |
| const resultImg = document.getElementById('result'); | |
| const placeholder = document.getElementById('placeholder'); | |
| const videoContainer = document.getElementById('videoContainer'); | |
| const startCameraBtn = document.getElementById('startCamera'); | |
| const switchCameraBtn = document.getElementById('switchCamera'); | |
| const startDetectionBtn = document.getElementById('startDetection'); | |
| const stopDetectionBtn = document.getElementById('stopDetection'); | |
| const showTroubleshootingBtn = document.getElementById('showTroubleshooting'); | |
| const fullscreenToggle = document.getElementById('fullscreenToggle'); | |
| const fullscreenControls = document.getElementById('fullscreenControls'); | |
| const statusDiv = document.getElementById('status'); | |
| const troubleshootingDiv = document.getElementById('troubleshooting'); | |
| const ctx = canvas.getContext('2d'); | |
| let streaming = false; | |
| let cameraStream = null; | |
| let detectionActive = false; | |
| let currentFacingMode = 'environment'; | |
| let availableCameras = []; | |
| let currentCameraIndex = 0; | |
| let isFullscreen = false; | |
| function isMobile() { | |
| return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); | |
| } | |
| function updateStatus(message, type = 'info') { | |
| const statusText = statusDiv.querySelector('.status-content span'); | |
| statusText.textContent = message; | |
| statusDiv.className = 'status-bar'; | |
| statusDiv.classList.add( | |
| type === 'error' ? 'status-error' : | |
| type === 'success' ? 'status-success' : 'status-info' | |
| ); | |
| statusDiv.style.marginBottom = '1rem'; | |
| } | |
| async function getAvailableCameras() { | |
| try { | |
| const devices = await navigator.mediaDevices.enumerateDevices(); | |
| availableCameras = devices.filter(d => d.kind === 'videoinput'); | |
| if (availableCameras.length > 1) switchCameraBtn.style.display = 'inline-flex'; | |
| return availableCameras; | |
| } catch (err) { | |
| console.error('Error getting camera list:', err); | |
| return []; | |
| } | |
| } | |
| function checkBrowserSupport() { | |
| const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1'; | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| updateStatus('Camera access not supported in this browser. Please use a modern browser.', 'error'); | |
| startCameraBtn.disabled = true; | |
| showTroubleshootingInfo(); | |
| return false; | |
| } | |
| if (!isSecureContext) { | |
| updateStatus('Camera requires HTTPS or localhost.', 'error'); | |
| startCameraBtn.disabled = true; | |
| showTroubleshootingInfo(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| function showTroubleshootingInfo() { | |
| troubleshootingDiv.style.display = 'block'; | |
| document.getElementById('currentUrl').textContent = window.location.href; | |
| document.getElementById('deviceInfo').textContent = | |
| `${navigator.platform} | Mobile: ${isMobile() ? 'Yes' : 'No'} | Touch: ${'ontouchstart' in window ? 'Yes' : 'No'}`; | |
| } | |
| function getCameraConstraints() { | |
| const constraints = { | |
| video: { | |
| width: { ideal: isMobile() ? 720 : 1280 }, | |
| height: { ideal: isMobile() ? 960 : 720 }, | |
| } | |
| }; | |
| if (availableCameras.length > 0) { | |
| constraints.video.deviceId = { exact: availableCameras[currentCameraIndex].deviceId }; | |
| } else { | |
| constraints.video.facingMode = currentFacingMode; | |
| } | |
| return constraints; | |
| } | |
| async function startCamera() { | |
| try { | |
| updateStatus('Starting camera…', 'info'); | |
| await getAvailableCameras(); | |
| const constraints = getCameraConstraints(); | |
| if (cameraStream) cameraStream.getTracks().forEach(t => t.stop()); | |
| cameraStream = await navigator.mediaDevices.getUserMedia(constraints); | |
| video.srcObject = cameraStream; | |
| video.onloadedmetadata = () => { | |
| video.play().then(() => { | |
| streaming = true; | |
| video.style.display = 'block'; | |
| placeholder.style.display = 'none'; | |
| fullscreenToggle.style.display = 'block'; | |
| startCameraBtn.disabled = true; | |
| startDetectionBtn.disabled = false; | |
| switchCameraBtn.disabled = false; | |
| const cameraName = availableCameras.length > 0 ? | |
| (availableCameras[currentCameraIndex].label || `Camera ${currentCameraIndex + 1}`) : | |
| (currentFacingMode === 'environment' ? 'Back camera' : 'Front camera'); | |
| updateStatus(`${cameraName} active. Point at the sky and press Start Detection.`, 'success'); | |
| }).catch(err => { | |
| updateStatus(`Error playing video: ${err.message}`, 'error'); | |
| }); | |
| }; | |
| } catch (err) { | |
| console.error("Error accessing webcam:", err); | |
| let errorMessage = 'Camera access failed: '; | |
| if (err.name === 'NotAllowedError') { | |
| errorMessage += 'permission denied. Allow camera access and try again.'; | |
| } else if (err.name === 'NotFoundError') { | |
| errorMessage += 'no camera found on this device.'; | |
| } else if (err.name === 'NotReadableError') { | |
| errorMessage += 'camera is used by another app. Close other camera apps.'; | |
| } else if (err.name === 'OverconstrainedError') { | |
| errorMessage += 'trying fallback settings…'; | |
| setTimeout(() => startCameraFallback(), 1000); | |
| } else { | |
| errorMessage += err.message; | |
| } | |
| updateStatus(errorMessage, 'error'); | |
| } | |
| } | |
| async function startCameraFallback() { | |
| try { | |
| updateStatus('Trying basic camera access…', 'info'); | |
| if (cameraStream) cameraStream.getTracks().forEach(t => t.stop()); | |
| cameraStream = await navigator.mediaDevices.getUserMedia({ video: true }); | |
| video.srcObject = cameraStream; | |
| video.onloadedmetadata = () => { | |
| video.play().then(() => { | |
| streaming = true; | |
| video.style.display = 'block'; | |
| placeholder.style.display = 'none'; | |
| fullscreenToggle.style.display = 'block'; | |
| startCameraBtn.disabled = true; | |
| startDetectionBtn.disabled = false; | |
| updateStatus('Camera started with basic settings. Ready for detection.', 'success'); | |
| }); | |
| }; | |
| } catch (err) { | |
| updateStatus('Unable to access any camera. Check permissions and try again.', 'error'); | |
| showTroubleshootingInfo(); | |
| } | |
| } | |
| async function switchCamera() { | |
| if (availableCameras.length <= 1) { | |
| currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment'; | |
| } else { | |
| currentCameraIndex = (currentCameraIndex + 1) % availableCameras.length; | |
| } | |
| updateStatus('Switching camera…', 'info'); | |
| await startCamera(); | |
| } | |
| function detectLoop() { | |
| if (!detectionActive || !streaming) return; | |
| try { | |
| const videoWidth = video.videoWidth || 640; | |
| const videoHeight = video.videoHeight || 480; | |
| canvas.width = videoWidth; | |
| canvas.height = videoHeight; | |
| ctx.drawImage(video, 0, 0, videoWidth, videoHeight); | |
| canvas.toBlob(blob => { | |
| if (!blob || !detectionActive) return; | |
| const formData = new FormData(); | |
| formData.append('frame', blob, 'frame.jpg'); | |
| fetch('/detect/process/', { method: 'POST', body: formData }) | |
| .then(response => { | |
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |
| return response.blob(); | |
| }) | |
| .then(imageBlob => { | |
| if (detectionActive) { | |
| resultImg.src = URL.createObjectURL(imageBlob); | |
| resultImg.style.display = 'block'; | |
| video.style.display = 'none'; | |
| setTimeout(() => { | |
| if (detectionActive) requestAnimationFrame(detectLoop); | |
| }, 500); | |
| } | |
| }) | |
| .catch(err => { | |
| console.error('Detection error:', err); | |
| updateStatus(`Detection error: ${err.message}`, 'error'); | |
| stopDetection(); | |
| }); | |
| }, 'image/jpeg', 0.7); | |
| } catch (err) { | |
| console.error('Canvas error:', err); | |
| updateStatus(`Canvas error: ${err.message}`, 'error'); | |
| stopDetection(); | |
| } | |
| } | |
| function startDetection() { | |
| if (!streaming) { | |
| updateStatus('Start the camera first.', 'error'); | |
| return; | |
| } | |
| detectionActive = true; | |
| startDetectionBtn.disabled = true; | |
| stopDetectionBtn.disabled = false; | |
| document.getElementById('fullscreenStartDetection').disabled = true; | |
| document.getElementById('fullscreenStopDetection').disabled = false; | |
| updateStatus('Scanning the sky for constellations…', 'info'); | |
| detectLoop(); | |
| } | |
| function stopDetection() { | |
| detectionActive = false; | |
| startDetectionBtn.disabled = false; | |
| stopDetectionBtn.disabled = true; | |
| document.getElementById('fullscreenStartDetection').disabled = false; | |
| document.getElementById('fullscreenStopDetection').disabled = true; | |
| resultImg.style.display = 'none'; | |
| video.style.display = 'block'; | |
| updateStatus('Detection paused. Camera is still active.', 'info'); | |
| } | |
| function stopCamera() { | |
| if (cameraStream) { | |
| cameraStream.getTracks().forEach(t => t.stop()); | |
| cameraStream = null; | |
| } | |
| streaming = false; | |
| detectionActive = false; | |
| video.style.display = 'none'; | |
| resultImg.style.display = 'none'; | |
| placeholder.style.display = 'flex'; | |
| fullscreenToggle.style.display = 'none'; | |
| startCameraBtn.disabled = false; | |
| startDetectionBtn.disabled = true; | |
| stopDetectionBtn.disabled = true; | |
| switchCameraBtn.disabled = true; | |
| updateStatus('Camera stopped.', 'info'); | |
| } | |
| function toggleFullscreen() { | |
| isFullscreen ? exitFullscreen() : enterFullscreen(); | |
| } | |
| function enterFullscreen() { | |
| videoContainer.classList.add('fullscreen-active'); | |
| fullscreenControls.style.display = 'flex'; | |
| fullscreenToggle.innerHTML = ` | |
| <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> | |
| </svg>`; | |
| isFullscreen = true; | |
| if (screen.orientation && screen.orientation.lock) { | |
| screen.orientation.lock('landscape').catch(() => {}); | |
| } | |
| } | |
| function exitFullscreen() { | |
| videoContainer.classList.remove('fullscreen-active'); | |
| fullscreenControls.style.display = 'none'; | |
| fullscreenToggle.innerHTML = ` | |
| <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/> | |
| </svg>`; | |
| isFullscreen = false; | |
| if (screen.orientation && screen.orientation.unlock) { | |
| screen.orientation.unlock(); | |
| } | |
| } | |
| startCameraBtn.addEventListener('click', startCamera); | |
| switchCameraBtn.addEventListener('click', switchCamera); | |
| startDetectionBtn.addEventListener('click', startDetection); | |
| stopDetectionBtn.addEventListener('click', stopDetection); | |
| fullscreenToggle.addEventListener('click', toggleFullscreen); | |
| document.getElementById('fullscreenStartDetection').addEventListener('click', startDetection); | |
| document.getElementById('fullscreenStopDetection').addEventListener('click', stopDetection); | |
| document.getElementById('fullscreenSwitchCamera').addEventListener('click', switchCamera); | |
| document.getElementById('exitFullscreen').addEventListener('click', exitFullscreen); | |
| showTroubleshootingBtn.addEventListener('click', () => { | |
| const isVisible = troubleshootingDiv.style.display !== 'none'; | |
| troubleshootingDiv.style.display = isVisible ? 'none' : 'block'; | |
| if (!isVisible) showTroubleshootingInfo(); | |
| }); | |
| if (isMobile()) { | |
| let lastTouchEnd = 0; | |
| document.addEventListener('touchend', function (event) { | |
| const now = Date.now(); | |
| if (now - lastTouchEnd <= 300) event.preventDefault(); | |
| lastTouchEnd = now; | |
| }, false); | |
| window.addEventListener('orientationchange', () => { | |
| setTimeout(() => { | |
| if (streaming && video.videoWidth && video.videoHeight) { | |
| video.style.width = '100%'; | |
| video.style.height = '100%'; | |
| } | |
| }, 500); | |
| }); | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.hidden && detectionActive) { | |
| stopDetection(); | |
| updateStatus('Detection paused — app in background.', 'info'); | |
| } | |
| }); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'f' || e.key === 'F') { | |
| if (streaming) toggleFullscreen(); | |
| } else if (e.key === ' ') { | |
| e.preventDefault(); | |
| if (streaming && !detectionActive) startDetection(); | |
| else if (detectionActive) stopDetection(); | |
| } else if (e.key === 'c' || e.key === 'C') { | |
| if (streaming && availableCameras.length > 1) switchCamera(); | |
| } | |
| }); | |
| window.addEventListener('beforeunload', stopCamera); | |
| if (checkBrowserSupport()) { | |
| if (isMobile()) { | |
| updateStatus('Mobile device detected. Tap "Start Camera" to begin.', 'info'); | |
| } | |
| getAvailableCameras(); | |
| } | |
| </script> | |
| {% endblock %} | |