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