Spaces:
Running
Running
| <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 */ | |
| .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; | |
| } | |
| /* Main Layout - Single Column */ | |
| .app-container { | |
| display: flex; | |
| flex-direction: column; | |
| padding: 8px; | |
| gap: 8px; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| /* Video Section */ | |
| .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; | |
| } | |
| /* Panels */ | |
| .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; | |
| } | |
| /* Sliders */ | |
| .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 */ | |
| .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 Selector */ | |
| .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; | |
| } | |
| /* Desktop Layout - Side by Side */ | |
| @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) { /* Head Control */ | |
| grid-column: 2; | |
| grid-row: 1; | |
| } | |
| .panel:nth-of-type(2) { /* Antennas */ | |
| grid-column: 2; | |
| grid-row: 2; | |
| } | |
| .panel:nth-of-type(3) { /* Sound */ | |
| grid-column: 2; | |
| grid-row: 3; | |
| } | |
| } | |
| /* Login View */ | |
| .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 ; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Login View --> | |
| <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> | |
| <!-- Main App --> | |
| <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"> | |
| <!-- Video --> | |
| <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> | |
| <button class="btn btn-mute muted" id="micBtn" onclick="toggleMic()" disabled> | |
| <svg id="micOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <line x1="1" y1="1" x2="23" y2="23"></line> | |
| <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path> | |
| <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.13 1.49-.35 2.17"></path> | |
| <line x1="12" y1="19" x2="12" y2="23"></line> | |
| <line x1="8" y1="23" x2="16" y2="23"></line> | |
| </svg> | |
| <svg id="micOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path> | |
| <line x1="12" y1="19" x2="12" y2="23"></line> | |
| <line x1="8" y1="23" x2="16" y2="23"></line> | |
| </svg> | |
| <span id="micText">Mic Off</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Robot Selector --> | |
| <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> | |
| <!-- Head Control - RPY Sliders --> | |
| <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> | |
| <!-- Antennas --> | |
| <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> | |
| <!-- Sound --> | |
| <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'; | |
| // Connection state | |
| 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; | |
| // Head control state | |
| let headSlidersActive = false; // True while user is dragging a slider | |
| // Latency monitor | |
| let latencyMonitorId = null; | |
| // Audio mute state (for robot's audio playback) | |
| let isMuted = true; // Default to muted | |
| // Microphone state (for speaking through the robot) | |
| let localMicStream = null; | |
| let isMicMuted = true; // Default mic off | |
| let robotSupportsMic = false; // Whether robot's SDP offers sendrecv audio | |
| // Export functions | |
| window.loginToHuggingFace = loginToHuggingFace; | |
| window.logout = logout; | |
| window.connectSignaling = connectSignaling; | |
| window.startStream = startStream; | |
| window.stopStream = stopStream; | |
| window.playSound = playSound; | |
| window.playSoundPreset = playSoundPreset; | |
| window.toggleMute = toggleMute; | |
| window.toggleMic = toggleMic; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initAuth(); | |
| initHeadSliders(); | |
| initAntennaSliders(); | |
| }); | |
| // ===================== Auth ===================== | |
| 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; | |
| } | |
| // ===================== Connection ===================== | |
| 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); | |
| } | |
| } | |
| // ===================== WebRTC ===================== | |
| async function startStream() { | |
| if (!selectedProducerId) return; | |
| updateStatus('connecting', 'Connecting...'); | |
| // Ensure video is muted by default | |
| const video = document.getElementById('remoteVideo'); | |
| video.muted = true; | |
| isMuted = true; | |
| updateMuteButton(); | |
| // Capture microphone eagerly (for permission prompt), but do NOT add | |
| // the track to the PeerConnection yet. We only add it after seeing | |
| // the robot's SDP offer to confirm it supports bidirectional audio. | |
| try { | |
| localMicStream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| localMicStream.getAudioTracks().forEach(t => t.enabled = false); // Start muted | |
| isMicMuted = true; | |
| } catch (e) { | |
| console.warn('Microphone not available, speak-through-robot disabled:', e); | |
| localMicStream = null; | |
| } | |
| robotSupportsMic = false; | |
| 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) { | |
| const sdp = msg.sdp; | |
| if (sdp.type === 'offer') { | |
| // Check if robot supports bidirectional audio (sendrecv) | |
| robotSupportsMic = sdpHasAudioSendRecv(sdp.sdp); | |
| // Add mic track BEFORE setRemoteDescription so the | |
| // answer naturally includes sendrecv for audio. | |
| if (robotSupportsMic && localMicStream) { | |
| for (const track of localMicStream.getAudioTracks()) { | |
| peerConnection.addTrack(track, localMicStream); | |
| } | |
| } | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); | |
| const answer = await peerConnection.createAnswer(); | |
| await peerConnection.setLocalDescription(answer); | |
| await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } }); | |
| } else { | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); | |
| } | |
| } | |
| if (msg.ice) { | |
| await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice)); | |
| } | |
| } catch (e) { | |
| console.error('WebRTC error:', e); | |
| } | |
| } | |
| // Latency monitor - catch up to live if buffer grows too large | |
| 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 more than 0.5s behind live edge, catch up | |
| if (lag > 0.5) { | |
| console.log(`Latency correction: was ${lag.toFixed(2)}s behind`); | |
| video.currentTime = bufferedEnd - 0.1; | |
| } | |
| } | |
| }, 2000); // Check every 2 seconds | |
| } | |
| async function stopStream() { | |
| if (latencyMonitorId) clearInterval(latencyMonitorId); | |
| if (stateRefreshInterval) clearInterval(stateRefreshInterval); | |
| // Notify server that session is ending | |
| if (currentSessionId) { | |
| await sendToServer({ type: 'endSession', sessionId: currentSessionId }); | |
| } | |
| // Stop microphone | |
| if (localMicStream) { | |
| localMicStream.getTracks().forEach(t => t.stop()); | |
| localMicStream = null; | |
| } | |
| isMicMuted = true; | |
| robotSupportsMic = false; | |
| 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; | |
| document.getElementById('micBtn').disabled = !enabled || !localMicStream || !robotSupportsMic; | |
| } | |
| 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 toggleMic() { | |
| if (!localMicStream) return; | |
| isMicMuted = !isMicMuted; | |
| localMicStream.getAudioTracks().forEach(t => t.enabled = !isMicMuted); | |
| updateMicButton(); | |
| } | |
| function updateMicButton() { | |
| const btn = document.getElementById('micBtn'); | |
| const micOffIcon = document.getElementById('micOffIcon'); | |
| const micOnIcon = document.getElementById('micOnIcon'); | |
| const micText = document.getElementById('micText'); | |
| if (isMicMuted) { | |
| btn.classList.add('muted'); | |
| micOffIcon.classList.remove('hidden'); | |
| micOnIcon.classList.add('hidden'); | |
| micText.textContent = 'Mic Off'; | |
| } else { | |
| btn.classList.remove('muted'); | |
| micOffIcon.classList.add('hidden'); | |
| micOnIcon.classList.remove('hidden'); | |
| micText.textContent = 'Mic On'; | |
| } | |
| } | |
| function sdpHasAudioSendRecv(sdp) { | |
| // Check whether the robot's SDP offer has sendrecv for audio, | |
| // meaning it supports bidirectional audio (updated daemon). | |
| const lines = sdp.split('\r\n'); | |
| let inAudioSection = false; | |
| for (const line of lines) { | |
| if (line.startsWith('m=audio')) inAudioSection = true; | |
| else if (line.startsWith('m=')) inAudioSection = false; | |
| if (inAudioSection && line === 'a=sendrecv') return true; | |
| } | |
| return false; | |
| } | |
| // ===================== Robot State ===================== | |
| 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; | |
| // Update sliders with real position when not being controlled | |
| 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 + '°'; | |
| } | |
| } | |
| // ===================== Head Sliders ===================== | |
| 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; | |
| } | |
| // Roll slider | |
| 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(); | |
| }); | |
| // Pitch slider | |
| 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(); | |
| }); | |
| // Yaw slider | |
| 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] | |
| ]; | |
| } | |
| // ===================== Antennas ===================== | |
| 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(); | |
| }); | |
| } | |
| // ===================== Sound ===================== | |
| 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> | |