webrtc_example-dev / index.html
cduss's picture
UI improvements: official icon, bigger joystick, simplified controls
de75730
raw
history blame
63.6 kB
<!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;
overflow-x: hidden;
}
/* Header */
.header {
background: rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,107,53,0.2);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo img {
width: 36px;
height: 36px;
border-radius: 8px;
}
.logo-text {
font-weight: 700;
font-size: 1.2em;
color: var(--pollen-coral);
}
.logo-text span {
color: var(--text-secondary);
font-weight: 400;
}
.user-section {
display: flex;
align-items: center;
gap: 12px;
}
.user-badge {
display: flex;
align-items: center;
gap: 8px;
background: var(--pollen-card);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.9em;
}
.btn-logout {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-secondary);
padding: 6px 14px;
border-radius: 16px;
cursor: pointer;
font-size: 0.85em;
transition: all 0.2s;
}
.btn-logout:hover {
border-color: var(--pollen-coral);
color: var(--pollen-coral);
}
/* Main Layout */
.app-container {
display: grid;
grid-template-columns: 1fr 340px;
gap: 16px;
padding: 16px;
max-width: 1600px;
margin: 0 auto;
min-height: calc(100vh - 65px);
}
@media (max-width: 1024px) {
.app-container {
grid-template-columns: 1fr;
}
.control-sidebar {
order: 2;
}
}
/* Video Section */
.video-container {
position: relative;
background: #000;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16/9;
}
@media (max-width: 1024px) {
.video-container {
aspect-ratio: 4/3;
}
}
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: 16px;
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: 8px;
background: rgba(0,0,0,0.5);
padding: 8px 14px;
border-radius: 20px;
font-size: 0.85em;
}
.status-indicator {
width: 10px;
height: 10px;
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: 8px 14px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.video-overlay-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
}
.video-controls {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: var(--pollen-coral);
color: white;
}
.btn-primary:hover {
background: var(--pollen-coral-light);
transform: translateY(-1px);
}
.btn-secondary {
background: rgba(255,255,255,0.15);
color: white;
}
.btn-secondary:hover {
background: rgba(255,255,255,0.25);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
/* State Bar */
.state-bar {
display: flex;
gap: 16px;
padding: 12px 16px;
background: var(--pollen-card);
border-radius: 0 0 16px 16px;
flex-wrap: wrap;
margin-top: -16px;
}
.state-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.state-item label {
font-size: 0.7em;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.state-item .value {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.9em;
color: var(--pollen-coral);
}
/* Control Sidebar */
.control-sidebar {
display: flex;
flex-direction: column;
gap: 12px;
max-height: calc(100vh - 90px);
overflow-y: auto;
}
.control-sidebar::-webkit-scrollbar {
width: 6px;
}
.control-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.control-sidebar::-webkit-scrollbar-thumb {
background: var(--pollen-card-light);
border-radius: 3px;
}
/* Panels */
.panel {
background: var(--pollen-card);
border-radius: 12px;
overflow: hidden;
}
.panel-header {
padding: 12px 16px;
background: rgba(0,0,0,0.2);
font-weight: 600;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 8px;
color: var(--pollen-coral);
}
.panel-content {
padding: 16px;
}
/* Joystick */
.joystick-container {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
padding: 10px 0;
}
.joystick-area {
width: 200px;
height: 200px;
background: radial-gradient(circle at center, var(--pollen-card-light) 0%, var(--pollen-darker) 100%);
border-radius: 50%;
position: relative;
border: 3px solid var(--pollen-coral);
touch-action: none;
cursor: grab;
}
.joystick-area:active {
cursor: grabbing;
}
.joystick-knob {
width: 60px;
height: 60px;
background: var(--pollen-coral);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 4px 16px rgba(255,107,53,0.5);
pointer-events: none;
transition: box-shadow 0.2s;
}
.joystick-area:active .joystick-knob {
box-shadow: 0 6px 24px rgba(255,107,53,0.7);
}
.joystick-labels {
position: absolute;
font-size: 0.75em;
color: var(--text-muted);
font-weight: 500;
}
.joystick-labels.top { top: 12px; left: 50%; transform: translateX(-50%); }
.joystick-labels.bottom { bottom: 12px; left: 50%; transform: translateX(-50%); }
.joystick-labels.left { left: 10px; top: 50%; transform: translateY(-50%); }
.joystick-labels.right { right: 10px; top: 50%; transform: translateY(-50%); }
.z-slider-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.z-slider {
writing-mode: vertical-lr;
direction: rtl;
height: 180px;
width: 12px;
-webkit-appearance: none;
background: var(--pollen-darker);
border-radius: 6px;
border: 2px solid var(--pollen-card-light);
}
.z-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 32px;
height: 32px;
background: var(--pollen-coral);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 10px rgba(255,107,53,0.5);
}
.z-label {
font-size: 0.8em;
color: var(--text-muted);
font-weight: 500;
}
/* Sliders */
.slider-group {
margin-bottom: 16px;
}
.slider-group:last-child {
margin-bottom: 0;
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.slider-label {
font-size: 0.85em;
color: var(--text-secondary);
}
.slider-value {
font-family: 'SF Mono', monospace;
font-size: 0.85em;
color: var(--pollen-coral);
min-width: 50px;
text-align: right;
}
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
background: var(--pollen-darker);
border-radius: 3px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: var(--pollen-coral);
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 10px rgba(255,107,53,0.5);
}
.slider::-webkit-slider-thumb:active {
transform: scale(1.2);
}
/* Motor Buttons */
.motor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.motor-btn {
padding: 10px;
border: 2px solid transparent;
border-radius: 8px;
font-weight: 600;
font-size: 0.8em;
cursor: pointer;
transition: all 0.2s;
}
.motor-btn.on {
background: #1B5E20;
color: white;
}
.motor-btn.on:hover { background: #2E7D32; }
.motor-btn.on.active { border-color: var(--success); box-shadow: 0 0 12px rgba(72,187,120,0.4); }
.motor-btn.off {
background: #B71C1C;
color: white;
}
.motor-btn.off:hover { background: #C62828; }
.motor-btn.off.active { border-color: var(--danger); box-shadow: 0 0 12px rgba(245,101,101,0.4); }
.motor-btn.gravity {
background: var(--pollen-coral-dark);
color: white;
}
.motor-btn.gravity:hover { background: var(--pollen-coral); }
.motor-btn.gravity.active { border-color: var(--pollen-coral-light); box-shadow: 0 0 12px rgba(255,107,53,0.4); }
.motor-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Animation Buttons */
.action-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.action-btn {
padding: 12px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
font-size: 0.85em;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.action-btn:hover {
background: var(--pollen-card-light);
border-color: var(--pollen-coral);
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn.recording {
background: var(--danger);
border-color: var(--danger);
animation: blink 1s infinite;
}
/* Sound & Speak */
.sound-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.sound-input {
flex: 1;
padding: 10px 12px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9em;
}
.sound-input:focus {
outline: none;
border-color: var(--pollen-coral);
}
.sound-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-chip {
padding: 6px 12px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
border-radius: 16px;
color: var(--text-secondary);
font-size: 0.75em;
cursor: pointer;
transition: all 0.2s;
}
.preset-chip:hover {
border-color: var(--pollen-coral);
color: var(--pollen-coral);
}
.speak-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--pollen-card-light);
}
.speak-label {
font-size: 0.8em;
color: var(--text-muted);
margin-bottom: 8px;
display: block;
}
.speak-row {
display: flex;
gap: 8px;
}
.speak-input {
flex: 1;
padding: 10px 12px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9em;
resize: none;
}
.speak-input:focus {
outline: none;
border-color: var(--pollen-coral);
}
/* Robot Selector */
.robot-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.robot-card {
padding: 12px 16px;
background: var(--pollen-darker);
border: 2px solid transparent;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.robot-card:hover {
background: var(--pollen-card-light);
}
.robot-card.selected {
border-color: var(--pollen-coral);
background: var(--pollen-card-light);
}
.robot-card .name {
font-weight: 600;
margin-bottom: 4px;
}
.robot-card .id {
font-size: 0.8em;
color: var(--text-muted);
font-family: monospace;
}
/* Login View */
.login-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: linear-gradient(135deg, var(--pollen-darker) 0%, var(--pollen-dark) 100%);
}
.login-card {
background: var(--pollen-card);
padding: 48px;
border-radius: 20px;
text-align: center;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.login-logo {
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.login-card h2 {
color: var(--pollen-coral);
margin-bottom: 12px;
font-size: 1.8em;
}
.login-card p {
color: var(--text-secondary);
margin-bottom: 32px;
line-height: 1.6;
}
.btn-hf {
background: #FFD21E;
color: #000;
border: none;
padding: 14px 32px;
border-radius: 10px;
font-size: 1em;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-hf:hover {
background: #FFE55C;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(255,210,30,0.3);
}
/* Utilities */
.hidden { display: none !important; }
/* Mobile adjustments */
@media (max-width: 600px) {
.header {
padding: 10px 16px;
}
.logo-text {
font-size: 1em;
}
.app-container {
padding: 12px;
gap: 12px;
}
.video-controls {
gap: 8px;
}
.btn {
padding: 8px 14px;
font-size: 0.85em;
}
.panel-content {
padding: 12px;
}
.joystick-area {
width: 160px;
height: 160px;
}
.z-slider {
height: 140px;
}
.state-bar {
gap: 12px;
padding: 10px 12px;
}
.state-item label {
font-size: 0.65em;
}
.state-item .value {
font-size: 0.8em;
}
}
</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="20" height="20" 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 Section -->
<div class="video-section">
<div class="video-container">
<video id="remoteVideo" autoplay playsinline></video>
<audio id="remoteAudio" autoplay></audio>
<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 Server</button>
<button class="btn btn-primary" id="startBtn" onclick="startStream()" disabled>Start Stream</button>
<button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Disconnect</button>
</div>
</div>
</div>
<div class="state-bar" id="stateBar">
<div class="state-item">
<label>Motors</label>
<span class="value" id="stateMotors">--</span>
</div>
<div class="state-item">
<label>Yaw</label>
<span class="value" id="stateYaw">--</span>
</div>
<div class="state-item">
<label>Pitch</label>
<span class="value" id="statePitch">--</span>
</div>
<div class="state-item">
<label>Roll</label>
<span class="value" id="stateRoll">--</span>
</div>
<div class="state-item">
<label>Body</label>
<span class="value" id="stateBody">--</span>
</div>
<div class="state-item">
<label>R.Ant</label>
<span class="value" id="stateRAnt">--</span>
</div>
<div class="state-item">
<label>L.Ant</label>
<span class="value" id="stateLAnt">--</span>
</div>
</div>
<!-- Robot Selector -->
<div id="robotSelector" class="panel hidden" style="margin-top: 16px;">
<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.9em;">Searching for robots...</div>
</div>
</div>
</div>
</div>
<!-- Control Sidebar -->
<div class="control-sidebar">
<!-- Joystick Control -->
<div class="panel">
<div class="panel-header">Position Control (Relative)</div>
<div class="panel-content">
<div class="joystick-container">
<div class="joystick-area" id="joystick">
<div class="joystick-knob" id="joystickKnob"></div>
<span class="joystick-labels top">Pitch +</span>
<span class="joystick-labels bottom">Pitch -</span>
<span class="joystick-labels left">Yaw +</span>
<span class="joystick-labels right">Yaw -</span>
</div>
<div class="z-slider-container">
<span class="z-label">Roll +</span>
<input type="range" class="z-slider" id="rollJoystick" min="-100" max="100" value="0">
<span class="z-label">Roll -</span>
</div>
</div>
<div style="text-align: center; margin-top: 12px; font-size: 0.8em; color: var(--text-muted);">
Drag joystick to move. Release to stop.
</div>
</div>
</div>
<!-- Orientation Sliders (Absolute) -->
<div class="panel">
<div class="panel-header">Head Orientation (Absolute)</div>
<div class="panel-content">
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Yaw (left/right)</span>
<span class="slider-value" id="yawValue"></span>
</div>
<input type="range" class="slider" id="yawSlider" min="-45" max="45" value="0">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Pitch (up/down)</span>
<span class="slider-value" id="pitchValue"></span>
</div>
<input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Roll (tilt)</span>
<span class="slider-value" id="rollValue"></span>
</div>
<input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Body Yaw</span>
<span class="slider-value" id="bodyValue"></span>
</div>
<input type="range" class="slider" id="bodySlider" min="-45" max="45" value="0">
</div>
</div>
</div>
<!-- Antennas -->
<div class="panel">
<div class="panel-header">Antennas</div>
<div class="panel-content">
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Right Antenna</span>
<span class="slider-value" id="rightAntValue"></span>
</div>
<input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-label">Left Antenna</span>
<span class="slider-value" id="leftAntValue"></span>
</div>
<input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0">
</div>
</div>
</div>
<!-- Sound & Speak -->
<div class="panel">
<div class="panel-header">Sound & Speak</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 style="padding: 10px 14px;">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 class="speak-section">
<label class="speak-label">Voice Chat (Telephone Mode)</label>
<div class="speak-row">
<button class="btn btn-primary" id="btnMic" onclick="toggleMicrophone()" style="flex: 1;">Enable Mic</button>
<button class="btn btn-secondary" id="btnMute" onclick="toggleMute()" style="flex: 1;">Unmute Robot</button>
</div>
<div id="micStatus" style="margin-top: 8px; font-size: 0.8em; color: var(--text-muted); text-align: center;"></div>
</div>
</div>
</div>
<!-- Recording -->
<div class="panel">
<div class="panel-header">Recording</div>
<div class="panel-content">
<div class="action-grid">
<button class="action-btn" id="btnStartRec" onclick="startRecording()" disabled>Start Rec</button>
<button class="action-btn" id="btnStopRec" onclick="stopRecording()" disabled>Stop Rec</button>
</div>
</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';
// 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;
// Robot state (from get_state)
let robotState = {
motorMode: null,
yaw: 0,
pitch: 0,
roll: 0,
bodyYaw: 0,
rightAntenna: 0,
leftAntenna: 0,
isRecording: false
};
// Slider update flags
let userDragging = {
yaw: false,
pitch: false,
roll: false,
body: false,
rightAnt: false,
leftAnt: false
};
// Joystick state
let joystickActive = false;
let joystickCenter = { x: 0, y: 0 };
let joystickInterval = null;
// Audio state
let localStream = null;
let micEnabled = false;
let robotMuted = true;
let audioSender = null;
// Export functions
window.loginToHuggingFace = loginToHuggingFace;
window.logout = logout;
window.connectSignaling = connectSignaling;
window.startStream = startStream;
window.stopStream = stopStream;
window.setMotorMode = setMotorMode;
window.wakeUp = wakeUp;
window.goToSleep = goToSleep;
window.playSound = playSound;
window.playSoundPreset = playSoundPreset;
window.toggleMicrophone = toggleMicrophone;
window.toggleMute = toggleMute;
window.startRecording = startRecording;
window.stopRecording = stopRecording;
// Init
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initJoystick();
initSliders();
});
// ===================== 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() {
const url = await oauthLoginUrl();
window.location.href = url;
}
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) {
const indicator = document.getElementById('statusIndicator');
const textEl = document.getElementById('statusText');
indicator.className = 'status-indicator ' + status;
textEl.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', 'Server 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:')) {
const data = line.slice(5).trim();
if (data) {
try {
handleSignalingMessage(JSON.parse(data));
} 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 App' } });
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); font-size: 0.9em;">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 = () => selectRobot(robot, div);
list.appendChild(div);
}
}
function selectRobot(robot, el) {
document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
selectedProducerId = robot.id;
document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini';
document.getElementById('startBtn').disabled = false;
}
// ===================== WebRTC =====================
async function startStream() {
if (!selectedProducerId) return;
updateStatus('connecting', 'Connecting to robot...');
peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnection.ontrack = (e) => {
console.log('Received track:', e.track.kind);
if (e.track.kind === 'video') {
document.getElementById('remoteVideo').srcObject = e.streams[0];
}
if (e.track.kind === 'audio') {
// Robot audio - connect to audio element
const audioEl = document.getElementById('remoteAudio');
audioEl.srcObject = new MediaStream([e.track]);
audioEl.muted = robotMuted;
updateMuteButton();
console.log('Robot audio track connected');
}
};
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);
// If mic was already enabled, attach it to the sender
if (micEnabled && localStream && audioSender) {
const audioTrack = localStream.getAudioTracks()[0];
if (audioTrack) {
audioSender.replaceTrack(audioTrack);
console.log('Attached existing mic to audio sender');
}
}
} 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') {
// Add a transceiver for sending audio (telephone mode)
// This ensures audio is negotiated in the SDP
const transceiver = peerConnection.addTransceiver('audio', {
direction: 'sendonly'
});
audioSender = transceiver.sender;
console.log('Added audio transceiver for sending');
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 stopStream() {
if (stateRefreshInterval) clearInterval(stateRefreshInterval);
if (joystickInterval) clearInterval(joystickInterval);
if (peerConnection) peerConnection.close();
if (dataChannel) dataChannel.close();
peerConnection = null;
dataChannel = null;
currentSessionId = null;
audioSender = null;
document.getElementById('remoteVideo').srcObject = null;
document.getElementById('remoteAudio').srcObject = null;
document.getElementById('startBtn').disabled = !selectedProducerId;
document.getElementById('stopBtn').disabled = true;
document.getElementById('robotSelector').classList.remove('hidden');
enableControls(false);
updateStatus('connected', 'Server connected');
document.getElementById('micStatus').textContent = '';
}
function enableControls(enabled) {
const btns = ['btnPlaySound', 'btnStartRec', 'btnStopRec'];
btns.forEach(id => document.getElementById(id).disabled = !enabled);
}
// ===================== Robot Messages =====================
function handleRobotMessage(data) {
if (data.state) {
updateRobotState(data.state);
} else if (data.motor_mode) {
robotState.motorMode = data.motor_mode;
document.getElementById('stateMotors').textContent = data.motor_mode;
} else if (data.error) {
console.error('Robot error:', data.error);
}
}
function updateRobotState(state) {
if (state.motor_mode) {
robotState.motorMode = state.motor_mode;
document.getElementById('stateMotors').textContent = state.motor_mode;
}
if (state.head_pose) {
const m = state.head_pose;
// Extract yaw, pitch, roll from rotation matrix
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;
robotState.yaw = yaw;
robotState.pitch = pitch;
robotState.roll = roll;
document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
document.getElementById('stateRoll').textContent = roll.toFixed(1) + '°';
// Update sliders if user isn't dragging
if (!userDragging.yaw) {
document.getElementById('yawSlider').value = yaw;
document.getElementById('yawValue').textContent = yaw.toFixed(0) + '°';
}
if (!userDragging.pitch) {
document.getElementById('pitchSlider').value = pitch;
document.getElementById('pitchValue').textContent = pitch.toFixed(0) + '°';
}
if (!userDragging.roll) {
document.getElementById('rollSlider').value = roll;
document.getElementById('rollValue').textContent = roll.toFixed(0) + '°';
}
}
if (state.body_yaw !== undefined) {
const bodyDeg = state.body_yaw * 180 / Math.PI;
robotState.bodyYaw = bodyDeg;
document.getElementById('stateBody').textContent = bodyDeg.toFixed(1) + '°';
if (!userDragging.body) {
document.getElementById('bodySlider').value = bodyDeg;
document.getElementById('bodyValue').textContent = bodyDeg.toFixed(0) + '°';
}
}
if (state.antennas) {
const rightDeg = state.antennas[0] * 180 / Math.PI;
const leftDeg = state.antennas[1] * 180 / Math.PI;
robotState.rightAntenna = rightDeg;
robotState.leftAntenna = leftDeg;
document.getElementById('stateRAnt').textContent = rightDeg.toFixed(0) + '°';
document.getElementById('stateLAnt').textContent = leftDeg.toFixed(0) + '°';
if (!userDragging.rightAnt) {
document.getElementById('rightAntSlider').value = rightDeg;
document.getElementById('rightAntValue').textContent = rightDeg.toFixed(0) + '°';
}
if (!userDragging.leftAnt) {
document.getElementById('leftAntSlider').value = leftDeg;
document.getElementById('leftAntValue').textContent = leftDeg.toFixed(0) + '°';
}
}
if (state.is_recording !== undefined) {
robotState.isRecording = state.is_recording;
document.getElementById('btnStartRec').classList.toggle('recording', state.is_recording);
}
}
// ===================== Joystick =====================
function initJoystick() {
const joystick = document.getElementById('joystick');
const knob = document.getElementById('joystickKnob');
const rollSlider = document.getElementById('rollJoystick');
const getPos = (e) => {
const rect = joystick.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const touch = e.touches ? e.touches[0] : e;
let x = touch.clientX - rect.left - centerX;
let y = touch.clientY - rect.top - centerY;
const maxRadius = centerX - 25;
const dist = Math.sqrt(x * x + y * y);
if (dist > maxRadius) {
x = (x / dist) * maxRadius;
y = (y / dist) * maxRadius;
}
return { x, y, normX: x / maxRadius, normY: y / maxRadius };
};
const startJoystick = (e) => {
e.preventDefault();
joystickActive = true;
const pos = getPos(e);
updateKnob(pos);
startJoystickMovement();
};
const moveJoystick = (e) => {
if (!joystickActive) return;
e.preventDefault();
const pos = getPos(e);
updateKnob(pos);
joystickCenter = { x: pos.normX, y: pos.normY };
};
const endJoystick = () => {
joystickActive = false;
knob.style.left = '50%';
knob.style.top = '50%';
joystickCenter = { x: 0, y: 0 };
stopJoystickMovement();
};
const updateKnob = (pos) => {
const rect = joystick.getBoundingClientRect();
knob.style.left = (rect.width / 2 + pos.x) + 'px';
knob.style.top = (rect.height / 2 + pos.y) + 'px';
joystickCenter = { x: pos.normX, y: pos.normY };
};
joystick.addEventListener('mousedown', startJoystick);
joystick.addEventListener('touchstart', startJoystick, { passive: false });
document.addEventListener('mousemove', moveJoystick);
document.addEventListener('touchmove', moveJoystick, { passive: false });
document.addEventListener('mouseup', endJoystick);
document.addEventListener('touchend', endJoystick);
// Roll slider
rollSlider.addEventListener('input', () => {
if (joystickActive || rollSlider.value != 0) {
// Apply roll delta while dragging
}
});
rollSlider.addEventListener('change', () => {
rollSlider.value = 0;
});
}
function startJoystickMovement() {
if (joystickInterval) return;
joystickInterval = setInterval(() => {
if (!joystickActive && joystickCenter.x === 0 && joystickCenter.y === 0) return;
const speed = 2; // degrees per tick
const rollSlider = document.getElementById('rollJoystick');
const rollDelta = (parseFloat(rollSlider.value) / 100) * speed;
// X controls Yaw (inverted: left = positive yaw)
// Y controls Pitch (inverted: up = positive pitch)
const yawDelta = -joystickCenter.x * speed;
const pitchDelta = -joystickCenter.y * speed;
// Calculate new absolute positions
let newYaw = robotState.yaw + yawDelta;
let newPitch = robotState.pitch + pitchDelta;
let newRoll = robotState.roll + rollDelta;
// Clamp values
newYaw = Math.max(-45, Math.min(45, newYaw));
newPitch = Math.max(-30, Math.min(30, newPitch));
newRoll = Math.max(-20, Math.min(20, newRoll));
// Send command
const matrix = buildMatrix(newYaw, newPitch, newRoll);
sendCommand({ set_target: matrix });
}, 50); // 20 updates per second
}
function stopJoystickMovement() {
if (joystickInterval) {
clearInterval(joystickInterval);
joystickInterval = null;
}
}
// ===================== Sliders =====================
function initSliders() {
const sliders = [
{ id: 'yawSlider', value: 'yawValue', key: 'yaw' },
{ id: 'pitchSlider', value: 'pitchValue', key: 'pitch' },
{ id: 'rollSlider', value: 'rollValue', key: 'roll' },
{ id: 'bodySlider', value: 'bodyValue', key: 'body' },
{ id: 'rightAntSlider', value: 'rightAntValue', key: 'rightAnt' },
{ id: 'leftAntSlider', value: 'leftAntValue', key: 'leftAnt' }
];
sliders.forEach(({ id, value, key }) => {
const slider = document.getElementById(id);
const valueEl = document.getElementById(value);
slider.addEventListener('mousedown', () => userDragging[key] = true);
slider.addEventListener('touchstart', () => userDragging[key] = true);
slider.addEventListener('input', () => {
valueEl.textContent = slider.value + '°';
sendSliderUpdate(key, parseFloat(slider.value));
});
slider.addEventListener('change', () => {
userDragging[key] = false;
});
document.addEventListener('mouseup', () => {
if (userDragging[key]) userDragging[key] = false;
});
});
}
function sendSliderUpdate(key, value) {
if (key === 'rightAnt' || key === 'leftAnt') {
const right = parseFloat(document.getElementById('rightAntSlider').value) * Math.PI / 180;
const left = parseFloat(document.getElementById('leftAntSlider').value) * Math.PI / 180;
sendCommand({ set_antennas: [right, left] });
} else if (key === 'body') {
sendCommand({ set_body_yaw: value * Math.PI / 180 });
} else {
// Head orientation
const yaw = parseFloat(document.getElementById('yawSlider').value);
const pitch = parseFloat(document.getElementById('pitchSlider').value);
const roll = parseFloat(document.getElementById('rollSlider').value);
const matrix = buildMatrix(yaw, pitch, roll);
sendCommand({ set_target: matrix });
}
}
// ===================== Matrix Builder =====================
function buildMatrix(yawDeg, pitchDeg, rollDeg = 0) {
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);
// Rotation matrix: Rz(yaw) * Ry(pitch) * Rx(roll)
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]
];
}
// ===================== Controls =====================
function setMotorMode(mode) {
sendCommand({ set_motor_mode: mode });
}
function wakeUp() {
sendCommand({ wake_up: true });
}
function goToSleep() {
sendCommand({ goto_sleep: true });
}
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 });
}
async function toggleMicrophone() {
const btn = document.getElementById('btnMic');
const status = document.getElementById('micStatus');
if (micEnabled) {
// Disable mic - replace track with null
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
if (audioSender) {
await audioSender.replaceTrack(null);
console.log('Removed audio track from sender');
}
micEnabled = false;
btn.textContent = 'Enable Mic';
btn.classList.remove('btn-danger');
btn.classList.add('btn-primary');
status.textContent = 'Microphone disabled';
status.style.color = 'var(--text-muted)';
} else {
// Enable mic
try {
localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
const audioTrack = localStream.getAudioTracks()[0];
// Replace track on the pre-negotiated sender
if (audioSender) {
await audioSender.replaceTrack(audioTrack);
console.log('Replaced audio track on sender - speaking to robot');
} else {
console.warn('No audio sender available - connection may not support sending audio');
}
micEnabled = true;
btn.textContent = 'Disable Mic';
btn.classList.remove('btn-primary');
btn.classList.add('btn-danger');
status.textContent = 'Microphone active - speaking to robot';
status.style.color = 'var(--success)';
} catch (err) {
console.error('Microphone access denied:', err);
status.textContent = 'Microphone access denied';
status.style.color = 'var(--danger)';
}
}
}
function toggleMute() {
robotMuted = !robotMuted;
const audioEl = document.getElementById('remoteAudio');
audioEl.muted = robotMuted;
updateMuteButton();
}
function updateMuteButton() {
const btn = document.getElementById('btnMute');
const status = document.getElementById('micStatus');
if (robotMuted) {
btn.textContent = 'Unmute Robot';
btn.classList.remove('btn-danger');
btn.classList.add('btn-secondary');
} else {
btn.textContent = 'Mute Robot';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-danger');
// Show listening status
if (!micEnabled) {
status.textContent = 'Listening to robot audio';
status.style.color = 'var(--pollen-coral)';
}
}
}
function startRecording() {
sendCommand({ start_recording: true });
}
function stopRecording() {
sendCommand({ stop_recording: true });
}
</script>
</body>
</html>