| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> |
| <title>Reachy Mini - Pollen Robotics</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --pollen-coral: #FF6B35; |
| --pollen-coral-light: #FF8A5C; |
| --pollen-coral-dark: #E55A2B; |
| --pollen-dark: #1A1A2E; |
| --pollen-darker: #0F0F1A; |
| --pollen-card: #16213E; |
| --pollen-card-light: #1E2A4A; |
| --text-primary: #FFFFFF; |
| --text-secondary: #A0AEC0; |
| --text-muted: #718096; |
| --success: #48BB78; |
| --warning: #ECC94B; |
| --danger: #F56565; |
| } |
| |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| body { |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; |
| background: var(--pollen-darker); |
| color: var(--text-primary); |
| min-height: 100vh; |
| min-height: 100dvh; |
| overflow-x: hidden; |
| } |
| |
| |
| .header { |
| background: rgba(0,0,0,0.4); |
| backdrop-filter: blur(10px); |
| padding: 8px 16px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| border-bottom: 1px solid rgba(255,107,53,0.2); |
| } |
| |
| .logo { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .logo img { |
| width: 32px; |
| height: 32px; |
| border-radius: 6px; |
| } |
| |
| .logo-text { |
| font-weight: 700; |
| font-size: 1em; |
| color: var(--pollen-coral); |
| } |
| |
| .logo-text span { |
| color: var(--text-secondary); |
| font-weight: 400; |
| font-size: 0.85em; |
| } |
| |
| .user-section { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .user-badge { |
| background: var(--pollen-card); |
| padding: 4px 12px; |
| border-radius: 16px; |
| font-size: 0.8em; |
| } |
| |
| .btn-logout { |
| background: transparent; |
| border: 1px solid var(--text-muted); |
| color: var(--text-secondary); |
| padding: 4px 12px; |
| border-radius: 12px; |
| cursor: pointer; |
| font-size: 0.75em; |
| } |
| |
| |
| .app-container { |
| display: flex; |
| flex-direction: column; |
| padding: 8px; |
| gap: 8px; |
| max-width: 800px; |
| margin: 0 auto; |
| } |
| |
| |
| .video-container { |
| position: relative; |
| background: #000; |
| border-radius: 12px; |
| overflow: hidden; |
| aspect-ratio: 16/9; |
| width: 100%; |
| } |
| |
| video { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%); |
| } |
| |
| .video-overlay-top { |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| padding: 12px; |
| background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%); |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-start; |
| } |
| |
| .connection-badge { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| background: rgba(0,0,0,0.5); |
| padding: 6px 12px; |
| border-radius: 16px; |
| font-size: 0.8em; |
| } |
| |
| .status-indicator { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: var(--danger); |
| } |
| |
| .status-indicator.connected { |
| background: var(--success); |
| box-shadow: 0 0 8px var(--success); |
| } |
| |
| .status-indicator.connecting { |
| background: var(--warning); |
| animation: blink 0.8s infinite; |
| } |
| |
| @keyframes blink { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.4; } |
| } |
| |
| .robot-name { |
| background: rgba(0,0,0,0.5); |
| padding: 6px 12px; |
| border-radius: 16px; |
| font-size: 0.8em; |
| font-weight: 500; |
| } |
| |
| .video-overlay-bottom { |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| padding: 12px; |
| background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%); |
| } |
| |
| .video-controls { |
| display: flex; |
| justify-content: center; |
| gap: 8px; |
| flex-wrap: wrap; |
| } |
| |
| .btn { |
| padding: 8px 16px; |
| border: none; |
| border-radius: 8px; |
| font-weight: 600; |
| font-size: 0.85em; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| |
| .btn-primary { |
| background: var(--pollen-coral); |
| color: white; |
| } |
| |
| .btn-secondary { |
| background: rgba(255,255,255,0.15); |
| color: white; |
| } |
| |
| .btn-danger { |
| background: var(--danger); |
| color: white; |
| } |
| |
| .btn:disabled { |
| opacity: 0.4; |
| cursor: not-allowed; |
| } |
| |
| .btn-mute { |
| background: rgba(255,255,255,0.15); |
| color: white; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| |
| .btn-mute.muted { |
| background: var(--danger); |
| } |
| |
| .btn-mute svg { |
| width: 16px; |
| height: 16px; |
| } |
| |
| |
| .panel { |
| background: var(--pollen-card); |
| border-radius: 12px; |
| overflow: hidden; |
| } |
| |
| .panel-header { |
| padding: 10px 14px; |
| background: rgba(0,0,0,0.2); |
| font-weight: 600; |
| font-size: 0.85em; |
| color: var(--pollen-coral); |
| } |
| |
| .panel-content { |
| padding: 12px; |
| } |
| |
| |
| .slider-row { |
| display: flex; |
| gap: 12px; |
| align-items: center; |
| margin-bottom: 12px; |
| } |
| |
| .slider-row:last-child { |
| margin-bottom: 0; |
| } |
| |
| .slider-label { |
| font-size: 0.8em; |
| color: var(--text-secondary); |
| min-width: 80px; |
| } |
| |
| .slider { |
| flex: 1; |
| height: 8px; |
| -webkit-appearance: none; |
| background: var(--pollen-darker); |
| border-radius: 4px; |
| } |
| |
| .slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| width: 20px; |
| height: 20px; |
| background: var(--pollen-coral); |
| border-radius: 50%; |
| cursor: pointer; |
| } |
| |
| .slider-value { |
| font-family: monospace; |
| font-size: 0.8em; |
| color: var(--pollen-coral); |
| min-width: 45px; |
| text-align: right; |
| } |
| |
| |
| .sound-row { |
| display: flex; |
| gap: 8px; |
| margin-bottom: 10px; |
| } |
| |
| .sound-input { |
| flex: 1; |
| padding: 8px 10px; |
| background: var(--pollen-darker); |
| border: 1px solid var(--pollen-card-light); |
| border-radius: 6px; |
| color: var(--text-primary); |
| font-size: 0.85em; |
| } |
| |
| .sound-presets { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| margin-bottom: 12px; |
| } |
| |
| .preset-chip { |
| padding: 4px 10px; |
| background: var(--pollen-darker); |
| border: 1px solid var(--pollen-card-light); |
| border-radius: 12px; |
| color: var(--text-secondary); |
| font-size: 0.7em; |
| cursor: pointer; |
| } |
| |
| .preset-chip:hover { |
| border-color: var(--pollen-coral); |
| color: var(--pollen-coral); |
| } |
| |
| |
| .robot-list { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .robot-card { |
| padding: 10px 14px; |
| background: var(--pollen-darker); |
| border: 2px solid transparent; |
| border-radius: 8px; |
| cursor: pointer; |
| } |
| |
| .robot-card:hover { |
| background: var(--pollen-card-light); |
| } |
| |
| .robot-card.selected { |
| border-color: var(--pollen-coral); |
| } |
| |
| .robot-card .name { |
| font-weight: 600; |
| font-size: 0.9em; |
| } |
| |
| .robot-card .id { |
| font-size: 0.75em; |
| color: var(--text-muted); |
| font-family: monospace; |
| } |
| |
| |
| @media (min-width: 900px) { |
| .app-container { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| grid-template-rows: auto auto auto auto; |
| max-width: 1200px; |
| gap: 12px; |
| } |
| |
| .video-container { |
| grid-column: 1; |
| grid-row: 1 / 3; |
| } |
| |
| #robotSelector { |
| grid-column: 1; |
| grid-row: 3; |
| } |
| |
| .panel:nth-of-type(1) { |
| grid-column: 2; |
| grid-row: 1; |
| } |
| |
| .panel:nth-of-type(2) { |
| grid-column: 2; |
| grid-row: 2; |
| } |
| |
| .panel:nth-of-type(3) { |
| grid-column: 2; |
| grid-row: 3; |
| } |
| } |
| |
| |
| .login-view { |
| min-height: 100vh; |
| min-height: 100dvh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| } |
| |
| .login-card { |
| background: var(--pollen-card); |
| padding: 40px; |
| border-radius: 16px; |
| text-align: center; |
| max-width: 380px; |
| } |
| |
| .login-logo { |
| width: 72px; |
| height: 72px; |
| margin-bottom: 20px; |
| border-radius: 12px; |
| } |
| |
| .login-card h2 { |
| color: var(--pollen-coral); |
| margin-bottom: 10px; |
| font-size: 1.5em; |
| } |
| |
| .login-card p { |
| color: var(--text-secondary); |
| margin-bottom: 24px; |
| font-size: 0.9em; |
| line-height: 1.5; |
| } |
| |
| .btn-hf { |
| background: #FFD21E; |
| color: #000; |
| border: none; |
| padding: 12px 28px; |
| border-radius: 8px; |
| font-size: 0.95em; |
| font-weight: 700; |
| cursor: pointer; |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .hidden { display: none !important; } |
| </style> |
| </head> |
| <body> |
| |
| <div id="loginView" class="login-view"> |
| <div class="login-card"> |
| <img class="login-logo" src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini"> |
| <h2>Reachy Mini</h2> |
| <p>Sign in with your HuggingFace account to connect and control your robot remotely.</p> |
| <button class="btn-hf" onclick="loginToHuggingFace()"> |
| <svg width="18" height="18" viewBox="0 0 95 88" fill="currentColor"> |
| <path d="M47.5 0C26.3 0 9.1 17.2 9.1 38.4v2.9c0 4.5 1.1 9 3.2 13L0 88h95L82.7 54.3c2.1-4 3.2-8.5 3.2-13v-2.9C85.9 17.2 68.7 0 47.5 0z"/> |
| </svg> |
| Sign in with Hugging Face |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div id="mainApp" class="hidden"> |
| <header class="header"> |
| <div class="logo"> |
| <img src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini"> |
| <div class="logo-text">Reachy Mini <span>by Pollen Robotics</span></div> |
| </div> |
| <div class="user-section"> |
| <div class="user-badge"><span id="username">@user</span></div> |
| <button class="btn-logout" onclick="logout()">Sign out</button> |
| </div> |
| </header> |
|
|
| <div class="app-container"> |
| |
| <div class="video-container"> |
| <video id="remoteVideo" autoplay playsinline></video> |
|
|
| <div class="video-overlay-top"> |
| <div class="connection-badge"> |
| <div class="status-indicator" id="statusIndicator"></div> |
| <span id="statusText">Disconnected</span> |
| </div> |
| <div class="robot-name" id="robotName"></div> |
| </div> |
|
|
| <div class="video-overlay-bottom"> |
| <div class="video-controls"> |
| <button class="btn btn-secondary" id="connectBtn" onclick="connectSignaling()">Connect</button> |
| <button class="btn btn-primary" id="startBtn" onclick="startStream()" disabled>Start</button> |
| <button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Stop</button> |
| <button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled> |
| <svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> |
| <line x1="23" y1="9" x2="17" y2="15"></line> |
| <line x1="17" y1="9" x2="23" y2="15"></line> |
| </svg> |
| <svg id="speakerOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> |
| <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path> |
| </svg> |
| <span id="muteText">Unmute</span> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
|
|
| |
| <div id="robotSelector" class="panel hidden"> |
| <div class="panel-header">Available Robots</div> |
| <div class="panel-content"> |
| <div id="robotList" class="robot-list"> |
| <div style="color: var(--text-muted); font-size: 0.85em;">Searching...</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header">Head Orientation</div> |
| <div class="panel-content"> |
| <div class="slider-row"> |
| <span class="slider-label">Roll</span> |
| <input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0" step="0.5"> |
| <span class="slider-value" id="rollValue">0.0°</span> |
| </div> |
| <div class="slider-row"> |
| <span class="slider-label">Pitch</span> |
| <input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0" step="0.5"> |
| <span class="slider-value" id="pitchValue">0.0°</span> |
| </div> |
| <div class="slider-row"> |
| <span class="slider-label">Yaw</span> |
| <input type="range" class="slider" id="yawSlider" min="-45" max="45" value="0" step="0.5"> |
| <span class="slider-value" id="yawValue">0.0°</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header">Antennas</div> |
| <div class="panel-content"> |
| <div class="slider-row"> |
| <span class="slider-label">Right</span> |
| <input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0"> |
| <span class="slider-value" id="rightAntValue">0°</span> |
| </div> |
| <div class="slider-row"> |
| <span class="slider-label">Left</span> |
| <input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0"> |
| <span class="slider-value" id="leftAntValue">0°</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="panel"> |
| <div class="panel-header">Sound</div> |
| <div class="panel-content"> |
| <div class="sound-row"> |
| <input type="text" class="sound-input" id="soundInput" placeholder="Sound file..."> |
| <button class="btn btn-primary" id="btnPlaySound" onclick="playSound()" disabled>Play</button> |
| </div> |
| <div class="sound-presets"> |
| <span class="preset-chip" onclick="playSoundPreset('wake_up.wav')">wake_up</span> |
| <span class="preset-chip" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span> |
| <span class="preset-chip" onclick="playSoundPreset('yes.wav')">yes</span> |
| <span class="preset-chip" onclick="playSoundPreset('no.wav')">no</span> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| <script type="module"> |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.15.2/+esm"; |
| |
| const SIGNALING_SERVER = 'https://cduss-reachy-mini-central.hf.space'; |
| |
| |
| let peerConnection = null; |
| let dataChannel = null; |
| let selectedProducerId = null; |
| let myPeerId = null; |
| let currentSessionId = null; |
| let userToken = null; |
| let currentUser = null; |
| let sseAbortController = null; |
| let stateRefreshInterval = null; |
| |
| |
| let headSlidersActive = false; |
| |
| |
| let latencyMonitorId = null; |
| |
| |
| let isMuted = true; |
| |
| |
| window.loginToHuggingFace = loginToHuggingFace; |
| window.logout = logout; |
| window.connectSignaling = connectSignaling; |
| window.startStream = startStream; |
| window.stopStream = stopStream; |
| window.playSound = playSound; |
| window.playSoundPreset = playSoundPreset; |
| window.toggleMute = toggleMute; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| initAuth(); |
| initHeadSliders(); |
| initAntennaSliders(); |
| }); |
| |
| |
| async function initAuth() { |
| try { |
| const oauthResult = await oauthHandleRedirectIfPresent(); |
| if (oauthResult) { |
| currentUser = oauthResult.userInfo.name || oauthResult.userInfo.preferred_username; |
| userToken = oauthResult.accessToken; |
| sessionStorage.setItem('hf_token', userToken); |
| sessionStorage.setItem('hf_username', currentUser); |
| sessionStorage.setItem('hf_token_expires', oauthResult.accessTokenExpiresAt); |
| showMainApp(); |
| } else { |
| const storedToken = sessionStorage.getItem('hf_token'); |
| const storedUser = sessionStorage.getItem('hf_username'); |
| const expires = sessionStorage.getItem('hf_token_expires'); |
| if (storedToken && storedUser && expires && new Date(expires) > new Date()) { |
| userToken = storedToken; |
| currentUser = storedUser; |
| showMainApp(); |
| } else { |
| showLogin(); |
| } |
| } |
| } catch (e) { |
| console.error('Auth error:', e); |
| showLogin(); |
| } |
| } |
| |
| async function loginToHuggingFace() { |
| window.location.href = await oauthLoginUrl(); |
| } |
| |
| function logout() { |
| sessionStorage.clear(); |
| userToken = null; |
| currentUser = null; |
| disconnectAll(); |
| showLogin(); |
| } |
| |
| function showLogin() { |
| document.getElementById('loginView').classList.remove('hidden'); |
| document.getElementById('mainApp').classList.add('hidden'); |
| } |
| |
| function showMainApp() { |
| document.getElementById('loginView').classList.add('hidden'); |
| document.getElementById('mainApp').classList.remove('hidden'); |
| document.getElementById('username').textContent = '@' + currentUser; |
| } |
| |
| |
| function updateStatus(status, text) { |
| document.getElementById('statusIndicator').className = 'status-indicator ' + status; |
| document.getElementById('statusText').textContent = text; |
| } |
| |
| async function sendToServer(message) { |
| try { |
| const res = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(message) |
| }); |
| return await res.json(); |
| } catch (e) { |
| console.error('Send error:', e); |
| return null; |
| } |
| } |
| |
| function sendCommand(cmd) { |
| if (!dataChannel || dataChannel.readyState !== 'open') return false; |
| dataChannel.send(JSON.stringify(cmd)); |
| return true; |
| } |
| |
| async function connectSignaling() { |
| if (!userToken) return; |
| updateStatus('connecting', 'Connecting...'); |
| document.getElementById('connectBtn').disabled = true; |
| sseAbortController = new AbortController(); |
| |
| try { |
| const res = await fetch(`${SIGNALING_SERVER}/events?token=${encodeURIComponent(userToken)}`, { |
| signal: sseAbortController.signal |
| }); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| |
| updateStatus('connected', 'Connected'); |
| document.getElementById('robotSelector').classList.remove('hidden'); |
| |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
| for (const line of lines) { |
| if (line.startsWith('data:')) { |
| try { handleSignalingMessage(JSON.parse(line.slice(5).trim())); } catch (e) {} |
| } |
| } |
| } |
| } catch (e) { |
| if (e.name !== 'AbortError') console.error('Connection failed:', e); |
| updateStatus('', 'Disconnected'); |
| document.getElementById('connectBtn').disabled = false; |
| document.getElementById('robotSelector').classList.add('hidden'); |
| } |
| } |
| |
| function disconnectAll() { |
| if (sseAbortController) sseAbortController.abort(); |
| stopStream(); |
| document.getElementById('connectBtn').disabled = false; |
| } |
| |
| async function handleSignalingMessage(msg) { |
| switch (msg.type) { |
| case 'welcome': |
| myPeerId = msg.peerId; |
| await sendToServer({ type: 'setPeerStatus', roles: ['listener'], meta: { name: 'Telepresence' } }); |
| break; |
| case 'list': |
| displayRobots(msg.producers); |
| break; |
| case 'peerStatusChanged': |
| const list = await sendToServer({ type: 'list' }); |
| if (list?.producers) displayRobots(list.producers); |
| break; |
| case 'sessionStarted': |
| currentSessionId = msg.sessionId; |
| break; |
| case 'peer': |
| handlePeerMessage(msg); |
| break; |
| } |
| } |
| |
| function displayRobots(robots) { |
| const list = document.getElementById('robotList'); |
| list.innerHTML = ''; |
| if (!robots?.length) { |
| list.innerHTML = '<div style="color: var(--text-muted);">No robots online</div>'; |
| document.getElementById('startBtn').disabled = true; |
| return; |
| } |
| for (const robot of robots) { |
| const div = document.createElement('div'); |
| div.className = 'robot-card' + (robot.id === selectedProducerId ? ' selected' : ''); |
| div.innerHTML = `<div class="name">${robot.meta?.name || 'Reachy Mini'}</div><div class="id">${robot.id.slice(0, 12)}...</div>`; |
| div.onclick = () => { |
| document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected')); |
| div.classList.add('selected'); |
| selectedProducerId = robot.id; |
| document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini'; |
| document.getElementById('startBtn').disabled = false; |
| }; |
| list.appendChild(div); |
| } |
| } |
| |
| |
| async function startStream() { |
| if (!selectedProducerId) return; |
| updateStatus('connecting', 'Connecting...'); |
| |
| |
| const video = document.getElementById('remoteVideo'); |
| video.muted = true; |
| isMuted = true; |
| updateMuteButton(); |
| |
| peerConnection = new RTCPeerConnection({ |
| iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] |
| }); |
| |
| peerConnection.ontrack = (e) => { |
| if (e.track.kind === 'video') { |
| const video = document.getElementById('remoteVideo'); |
| video.srcObject = e.streams[0]; |
| video.playsInline = true; |
| if ('requestVideoFrameCallback' in video) { |
| startLatencyMonitor(video); |
| } |
| } |
| }; |
| |
| peerConnection.onicecandidate = async (e) => { |
| if (e.candidate && currentSessionId) { |
| await sendToServer({ |
| type: 'peer', |
| sessionId: currentSessionId, |
| ice: { candidate: e.candidate.candidate, sdpMLineIndex: e.candidate.sdpMLineIndex, sdpMid: e.candidate.sdpMid } |
| }); |
| } |
| }; |
| |
| peerConnection.oniceconnectionstatechange = () => { |
| const state = peerConnection.iceConnectionState; |
| if (state === 'connected' || state === 'completed') { |
| updateStatus('connected', 'Connected'); |
| enableControls(true); |
| document.getElementById('robotSelector').classList.add('hidden'); |
| stateRefreshInterval = setInterval(() => sendCommand({ get_state: true }), 500); |
| } else if (state === 'failed' || state === 'disconnected') { |
| updateStatus('', 'Connection lost'); |
| } |
| }; |
| |
| peerConnection.ondatachannel = (e) => { |
| dataChannel = e.channel; |
| dataChannel.onopen = () => sendCommand({ get_state: true }); |
| dataChannel.onmessage = (e) => handleRobotMessage(JSON.parse(e.data)); |
| }; |
| |
| document.getElementById('startBtn').disabled = true; |
| document.getElementById('stopBtn').disabled = false; |
| |
| const res = await sendToServer({ type: 'startSession', peerId: selectedProducerId }); |
| if (res?.sessionId) currentSessionId = res.sessionId; |
| } |
| |
| async function handlePeerMessage(msg) { |
| if (!peerConnection) return; |
| try { |
| if (msg.sdp) { |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp)); |
| if (msg.sdp.type === 'offer') { |
| const answer = await peerConnection.createAnswer(); |
| await peerConnection.setLocalDescription(answer); |
| await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } }); |
| } |
| } |
| if (msg.ice) { |
| await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice)); |
| } |
| } catch (e) { |
| console.error('WebRTC error:', e); |
| } |
| } |
| |
| |
| function startLatencyMonitor(video) { |
| if (latencyMonitorId) clearInterval(latencyMonitorId); |
| |
| latencyMonitorId = setInterval(() => { |
| if (!video.srcObject || video.paused) return; |
| |
| const buffered = video.buffered; |
| if (buffered.length > 0) { |
| const bufferedEnd = buffered.end(buffered.length - 1); |
| const lag = bufferedEnd - video.currentTime; |
| |
| |
| if (lag > 0.5) { |
| console.log(`Latency correction: was ${lag.toFixed(2)}s behind`); |
| video.currentTime = bufferedEnd - 0.1; |
| } |
| } |
| }, 2000); |
| } |
| |
| async function stopStream() { |
| if (latencyMonitorId) clearInterval(latencyMonitorId); |
| if (stateRefreshInterval) clearInterval(stateRefreshInterval); |
| |
| |
| if (currentSessionId) { |
| await sendToServer({ type: 'endSession', sessionId: currentSessionId }); |
| } |
| |
| if (peerConnection) peerConnection.close(); |
| if (dataChannel) dataChannel.close(); |
| |
| peerConnection = null; |
| dataChannel = null; |
| currentSessionId = null; |
| document.getElementById('remoteVideo').srcObject = null; |
| document.getElementById('startBtn').disabled = !selectedProducerId; |
| document.getElementById('stopBtn').disabled = true; |
| document.getElementById('robotSelector').classList.remove('hidden'); |
| enableControls(false); |
| updateStatus('connected', 'Connected'); |
| } |
| |
| function enableControls(enabled) { |
| document.getElementById('btnPlaySound').disabled = !enabled; |
| document.getElementById('muteBtn').disabled = !enabled; |
| } |
| |
| function toggleMute() { |
| isMuted = !isMuted; |
| document.getElementById('remoteVideo').muted = isMuted; |
| updateMuteButton(); |
| } |
| |
| function updateMuteButton() { |
| const btn = document.getElementById('muteBtn'); |
| const speakerOffIcon = document.getElementById('speakerOffIcon'); |
| const speakerOnIcon = document.getElementById('speakerOnIcon'); |
| const muteText = document.getElementById('muteText'); |
| |
| if (isMuted) { |
| btn.classList.add('muted'); |
| speakerOffIcon.classList.remove('hidden'); |
| speakerOnIcon.classList.add('hidden'); |
| muteText.textContent = 'Unmute'; |
| } else { |
| btn.classList.remove('muted'); |
| speakerOffIcon.classList.add('hidden'); |
| speakerOnIcon.classList.remove('hidden'); |
| muteText.textContent = 'Mute'; |
| } |
| } |
| |
| |
| function handleRobotMessage(data) { |
| if (data.state) updateStateDisplay(data.state); |
| if (data.error) console.error('Robot error:', data.error); |
| } |
| |
| function updateStateDisplay(state) { |
| if (state.head_pose) { |
| const m = state.head_pose; |
| const pitch = Math.asin(-m[2][0]) * 180 / Math.PI; |
| const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI; |
| const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI; |
| |
| |
| if (!headSlidersActive) { |
| document.getElementById('rollSlider').value = roll; |
| document.getElementById('rollValue').textContent = roll.toFixed(1) + '°'; |
| document.getElementById('pitchSlider').value = pitch; |
| document.getElementById('pitchValue').textContent = pitch.toFixed(1) + '°'; |
| document.getElementById('yawSlider').value = yaw; |
| document.getElementById('yawValue').textContent = yaw.toFixed(1) + '°'; |
| } |
| } |
| if (state.antennas) { |
| const r = (state.antennas[0] * 180 / Math.PI).toFixed(0); |
| const l = (state.antennas[1] * 180 / Math.PI).toFixed(0); |
| document.getElementById('rightAntSlider').value = r; |
| document.getElementById('rightAntValue').textContent = r + '°'; |
| document.getElementById('leftAntSlider').value = l; |
| document.getElementById('leftAntValue').textContent = l + '°'; |
| } |
| } |
| |
| |
| function initHeadSliders() { |
| const rollSlider = document.getElementById('rollSlider'); |
| const pitchSlider = document.getElementById('pitchSlider'); |
| const yawSlider = document.getElementById('yawSlider'); |
| const rollValue = document.getElementById('rollValue'); |
| const pitchValue = document.getElementById('pitchValue'); |
| const yawValue = document.getElementById('yawValue'); |
| |
| function sendHeadPose() { |
| const roll = parseFloat(rollSlider.value); |
| const pitch = parseFloat(pitchSlider.value); |
| const yaw = parseFloat(yawSlider.value); |
| sendCommand({ set_target: buildMatrix(yaw, pitch, roll) }); |
| } |
| |
| function onSliderStart() { |
| headSlidersActive = true; |
| } |
| |
| function onSliderEnd() { |
| headSlidersActive = false; |
| } |
| |
| |
| rollSlider.addEventListener('mousedown', onSliderStart); |
| rollSlider.addEventListener('touchstart', onSliderStart); |
| rollSlider.addEventListener('mouseup', onSliderEnd); |
| rollSlider.addEventListener('touchend', onSliderEnd); |
| rollSlider.addEventListener('input', () => { |
| rollValue.textContent = parseFloat(rollSlider.value).toFixed(1) + '°'; |
| sendHeadPose(); |
| }); |
| |
| |
| pitchSlider.addEventListener('mousedown', onSliderStart); |
| pitchSlider.addEventListener('touchstart', onSliderStart); |
| pitchSlider.addEventListener('mouseup', onSliderEnd); |
| pitchSlider.addEventListener('touchend', onSliderEnd); |
| pitchSlider.addEventListener('input', () => { |
| pitchValue.textContent = parseFloat(pitchSlider.value).toFixed(1) + '°'; |
| sendHeadPose(); |
| }); |
| |
| |
| yawSlider.addEventListener('mousedown', onSliderStart); |
| yawSlider.addEventListener('touchstart', onSliderStart); |
| yawSlider.addEventListener('mouseup', onSliderEnd); |
| yawSlider.addEventListener('touchend', onSliderEnd); |
| yawSlider.addEventListener('input', () => { |
| yawValue.textContent = parseFloat(yawSlider.value).toFixed(1) + '°'; |
| sendHeadPose(); |
| }); |
| } |
| |
| function buildMatrix(yawDeg, pitchDeg, rollDeg) { |
| const y = yawDeg * Math.PI / 180; |
| const p = pitchDeg * Math.PI / 180; |
| const r = rollDeg * Math.PI / 180; |
| const cy = Math.cos(y), sy = Math.sin(y); |
| const cp = Math.cos(p), sp = Math.sin(p); |
| const cr = Math.cos(r), sr = Math.sin(r); |
| return [ |
| [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, 0], |
| [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, 0], |
| [-sp, cp * sr, cp * cr, 0], |
| [0, 0, 0, 1] |
| ]; |
| } |
| |
| |
| function initAntennaSliders() { |
| const rightSlider = document.getElementById('rightAntSlider'); |
| const leftSlider = document.getElementById('leftAntSlider'); |
| const rightValue = document.getElementById('rightAntValue'); |
| const leftValue = document.getElementById('leftAntValue'); |
| |
| function sendAntennas() { |
| const r = parseFloat(rightSlider.value) * Math.PI / 180; |
| const l = parseFloat(leftSlider.value) * Math.PI / 180; |
| sendCommand({ set_antennas: [r, l] }); |
| } |
| |
| rightSlider.addEventListener('input', () => { |
| rightValue.textContent = rightSlider.value + '°'; |
| sendAntennas(); |
| }); |
| |
| leftSlider.addEventListener('input', () => { |
| leftValue.textContent = leftSlider.value + '°'; |
| sendAntennas(); |
| }); |
| } |
| |
| |
| function playSound() { |
| const file = document.getElementById('soundInput').value.trim(); |
| if (file) sendCommand({ play_sound: file }); |
| } |
| |
| function playSoundPreset(file) { |
| document.getElementById('soundInput').value = file; |
| sendCommand({ play_sound: file }); |
| } |
| </script> |
| </body> |
| </html> |
|
|