cduss Claude Opus 4.5 commited on
Commit
e304677
·
1 Parent(s): bdbd6d6

Add Pollen Robotics UI redesign with bidirectional audio

Browse files

- New coral/orange color scheme with Pollen Robotics branding
- Responsive layout for desktop and mobile
- Virtual joystick for relative head movement (pitch/yaw/roll)
- Absolute sliders for head orientation and body yaw
- Antenna sliders with live sync to robot state
- Voice chat (telephone mode): mic capture and robot audio playback
- Audio transceiver negotiation for WebRTC audio sending
- State polling at 500ms for smoother UI updates
- Slider values sync to robot position when not dragging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (1) hide show
  1. index.html +1141 -430
index.html CHANGED
@@ -1,584 +1,1031 @@
1
  <!doctype html>
2
- <html>
3
  <head>
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>Reachy Mini Telepresence</title>
 
 
 
7
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
 
9
  body {
10
- font-family: system-ui, -apple-system, sans-serif;
11
- background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);
12
- color: #eee;
13
  min-height: 100vh;
 
14
  }
15
 
16
  /* Header */
17
  .header {
18
- background: rgba(0,0,0,0.3);
19
- padding: 12px 24px;
 
20
  display: flex;
21
  align-items: center;
22
  justify-content: space-between;
23
- border-bottom: 1px solid #333;
 
 
 
24
  }
25
- .header h1 {
26
- font-size: 1.4em;
27
- color: #00d4ff;
28
  display: flex;
29
  align-items: center;
30
- gap: 10px;
31
  }
32
- .header h1::before { content: "🤖"; }
33
- .user-badge {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  display: flex;
35
  align-items: center;
36
  gap: 12px;
37
- background: #16213e;
38
- padding: 8px 16px;
39
- border-radius: 24px;
40
  }
41
- .user-badge .name { font-weight: 500; }
42
- .user-badge button {
 
 
 
 
 
 
 
 
 
 
43
  background: transparent;
44
- border: 1px solid #555;
45
- color: #aaa;
46
- padding: 4px 12px;
47
- border-radius: 12px;
48
  cursor: pointer;
49
  font-size: 0.85em;
 
 
 
 
 
 
50
  }
51
- .user-badge button:hover { border-color: #888; color: #fff; }
52
 
53
  /* Main Layout */
54
- .main-container {
55
  display: grid;
56
- grid-template-columns: 1fr 380px;
57
- gap: 20px;
58
- padding: 20px;
59
  max-width: 1600px;
60
  margin: 0 auto;
 
61
  }
62
- @media (max-width: 1100px) {
63
- .main-container { grid-template-columns: 1fr; }
 
 
 
 
 
 
64
  }
65
 
66
  /* Video Section */
67
- .video-section {
 
68
  background: #000;
69
  border-radius: 16px;
70
  overflow: hidden;
71
- position: relative;
72
  }
 
 
 
 
 
 
 
73
  video {
74
  width: 100%;
75
- aspect-ratio: 16/9;
76
- display: block;
77
- background: #111;
78
  }
79
- .video-overlay {
 
80
  position: absolute;
81
  top: 0;
82
  left: 0;
83
  right: 0;
84
  padding: 16px;
85
- background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
86
  display: flex;
87
  justify-content: space-between;
88
  align-items: flex-start;
89
  }
90
- .connection-status {
 
91
  display: flex;
92
  align-items: center;
93
  gap: 8px;
94
- font-size: 0.9em;
 
 
 
95
  }
96
- .status-dot {
 
97
  width: 10px;
98
  height: 10px;
99
  border-radius: 50%;
100
- background: #ff5252;
101
  }
102
- .status-dot.connected { background: #00c853; animation: pulse 2s infinite; }
103
- .status-dot.connecting { background: #ffc107; animation: pulse 0.5s infinite; }
104
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
105
 
106
- .video-controls {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  position: absolute;
108
  bottom: 0;
109
  left: 0;
110
  right: 0;
111
  padding: 16px;
112
- background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
 
 
 
113
  display: flex;
114
  justify-content: center;
115
  gap: 12px;
 
116
  }
117
- .video-controls button {
118
- background: rgba(255,255,255,0.15);
 
119
  border: none;
120
- color: #fff;
121
- padding: 12px 24px;
122
  border-radius: 8px;
 
 
123
  cursor: pointer;
124
- font-size: 1em;
125
- font-weight: 500;
126
  transition: all 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  }
128
- .video-controls button:hover { background: rgba(255,255,255,0.25); }
129
- .video-controls button.primary { background: #00d4ff; color: #000; }
130
- .video-controls button.primary:hover { background: #00a8cc; }
131
- .video-controls button.danger { background: #ff5252; }
132
- .video-controls button:disabled { opacity: 0.4; cursor: not-allowed; }
133
 
134
- /* State Display */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  .state-bar {
136
  display: flex;
137
- gap: 20px;
138
- padding: 16px 20px;
139
- background: #16213e;
140
  border-radius: 0 0 16px 16px;
141
  flex-wrap: wrap;
 
142
  }
 
143
  .state-item {
144
  display: flex;
145
  flex-direction: column;
146
- gap: 4px;
147
  }
 
148
  .state-item label {
149
- font-size: 0.75em;
150
- color: #888;
151
  text-transform: uppercase;
152
  letter-spacing: 0.5px;
153
  }
 
154
  .state-item .value {
155
- font-family: 'SF Mono', monospace;
156
- font-size: 0.95em;
157
- color: #00d4ff;
158
  }
159
 
160
- /* Control Panel */
161
- .control-panel {
162
  display: flex;
163
  flex-direction: column;
164
- gap: 16px;
 
 
 
 
 
 
165
  }
166
 
 
 
 
 
 
 
 
 
 
 
167
  .panel {
168
- background: #16213e;
169
  border-radius: 12px;
170
  overflow: hidden;
171
  }
 
172
  .panel-header {
173
  padding: 12px 16px;
174
  background: rgba(0,0,0,0.2);
175
  font-weight: 600;
176
- font-size: 0.9em;
177
  display: flex;
178
  align-items: center;
179
  gap: 8px;
 
180
  }
 
181
  .panel-content {
182
  padding: 16px;
183
  }
184
 
185
- /* Motor Control */
186
- .motor-buttons {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  display: grid;
188
  grid-template-columns: repeat(3, 1fr);
189
  gap: 8px;
190
  }
 
191
  .motor-btn {
192
  padding: 10px;
193
  border: 2px solid transparent;
194
  border-radius: 8px;
195
  font-weight: 600;
 
196
  cursor: pointer;
197
  transition: all 0.2s;
198
- font-size: 0.85em;
199
- }
200
- .motor-btn.enable { background: #1b5e20; color: #fff; }
201
- .motor-btn.enable:hover { background: #2e7d32; }
202
- .motor-btn.enable.active { border-color: #00c853; box-shadow: 0 0 12px rgba(0,200,83,0.4); }
203
- .motor-btn.disable { background: #b71c1c; color: #fff; }
204
- .motor-btn.disable:hover { background: #c62828; }
205
- .motor-btn.disable.active { border-color: #ff5252; box-shadow: 0 0 12px rgba(255,82,82,0.4); }
206
- .motor-btn.gravity { background: #f57c00; color: #fff; }
207
- .motor-btn.gravity:hover { background: #ff9800; }
208
- .motor-btn.gravity.active { border-color: #ffc107; box-shadow: 0 0 12px rgba(255,193,7,0.4); }
209
- .motor-btn:disabled { opacity: 0.4; cursor: not-allowed; }
210
-
211
- /* Joystick / Head Control */
212
- .head-control {
213
- display: grid;
214
- grid-template-columns: 1fr 1fr;
215
- gap: 16px;
216
  }
217
- .control-group label {
218
- display: block;
219
- font-size: 0.8em;
220
- color: #888;
221
- margin-bottom: 6px;
222
  }
223
- .control-group input[type="range"] {
224
- width: 100%;
225
- -webkit-appearance: none;
226
- height: 6px;
227
- background: #0f3460;
228
- border-radius: 3px;
229
- margin-bottom: 4px;
230
  }
231
- .control-group input[type="range"]::-webkit-slider-thumb {
232
- -webkit-appearance: none;
233
- width: 16px;
234
- height: 16px;
235
- background: #00d4ff;
236
- border-radius: 50%;
237
- cursor: pointer;
238
  }
239
- .control-group .value-display {
240
- font-family: 'SF Mono', monospace;
241
- font-size: 0.85em;
242
- color: #00d4ff;
243
- text-align: center;
 
 
244
  }
245
 
246
- /* Quick Actions */
247
- .quick-actions {
248
  display: grid;
249
  grid-template-columns: repeat(2, 1fr);
250
  gap: 8px;
251
  }
 
252
  .action-btn {
253
  padding: 12px;
254
- border: 1px solid #0f3460;
255
- background: #0f3460;
256
- color: #fff;
257
  border-radius: 8px;
258
  cursor: pointer;
259
- font-size: 0.9em;
260
  transition: all 0.2s;
261
  display: flex;
262
  align-items: center;
263
  justify-content: center;
264
- gap: 8px;
265
  }
266
- .action-btn:hover { background: #1a4a80; border-color: #00d4ff; }
267
- .action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
268
- .action-btn.recording { background: #c62828; animation: pulse 1s infinite; }
269
 
270
- /* Sound Section */
271
- .sound-input {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  display: flex;
273
  gap: 8px;
 
274
  }
275
- .sound-input input {
 
276
  flex: 1;
277
  padding: 10px 12px;
278
- background: #0f3460;
279
- border: 1px solid #0f3460;
280
  border-radius: 8px;
281
- color: #fff;
282
  font-size: 0.9em;
283
  }
284
- .sound-input input:focus {
 
285
  outline: none;
286
- border-color: #00d4ff;
287
  }
 
288
  .sound-presets {
289
  display: flex;
290
  flex-wrap: wrap;
291
  gap: 6px;
292
- margin-top: 10px;
293
  }
294
- .sound-preset {
 
295
  padding: 6px 12px;
296
- background: #0a0a1a;
297
- border: 1px solid #333;
298
  border-radius: 16px;
299
- color: #aaa;
300
- font-size: 0.8em;
301
  cursor: pointer;
302
  transition: all 0.2s;
303
  }
304
- .sound-preset:hover { border-color: #00d4ff; color: #fff; }
305
 
306
- /* Log */
307
- .log-panel {
308
- max-height: 150px;
309
- overflow-y: auto;
310
- background: #0a0a1a;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  border-radius: 8px;
312
- padding: 12px;
313
- font-family: 'SF Mono', monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  font-size: 0.8em;
 
 
315
  }
316
- .log-entry { margin-bottom: 4px; color: #888; }
317
- .log-entry.error { color: #ff5252; }
318
- .log-entry.success { color: #00c853; }
319
- .log-entry.info { color: #00d4ff; }
320
 
321
  /* Login View */
322
  .login-view {
 
323
  display: flex;
324
  align-items: center;
325
  justify-content: center;
326
- min-height: 100vh;
327
  padding: 20px;
 
328
  }
 
329
  .login-card {
330
- background: #16213e;
331
- padding: 40px;
332
- border-radius: 16px;
333
  text-align: center;
334
- max-width: 400px;
 
 
 
 
 
 
 
335
  }
 
336
  .login-card h2 {
337
- margin-bottom: 16px;
338
- color: #00d4ff;
 
339
  }
 
340
  .login-card p {
341
- color: #888;
342
- margin-bottom: 24px;
 
343
  }
 
344
  .btn-hf {
345
- background: #ff9d00;
346
  color: #000;
347
  border: none;
348
  padding: 14px 32px;
349
- border-radius: 8px;
350
- font-size: 1.1em;
351
- font-weight: 600;
352
  cursor: pointer;
353
- transition: background 0.2s;
354
- }
355
- .btn-hf:hover { background: #ffb340; }
356
-
357
- /* Robot selector */
358
- .robot-list {
359
- display: flex;
360
- flex-direction: column;
361
  gap: 8px;
362
  }
363
- .robot-item {
364
- padding: 12px 16px;
365
- background: #0f3460;
366
- border-radius: 8px;
367
- cursor: pointer;
368
- transition: all 0.2s;
369
- border: 2px solid transparent;
370
  }
371
- .robot-item:hover { background: #1a4a80; }
372
- .robot-item.selected { border-color: #00d4ff; background: #1a4a80; }
373
- .robot-item .name { font-weight: 500; }
374
- .robot-item .id { font-size: 0.8em; color: #888; margin-top: 4px; }
375
 
 
376
  .hidden { display: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  </style>
378
  </head>
379
  <body>
380
  <!-- Login View -->
381
  <div id="loginView" class="login-view">
382
  <div class="login-card">
383
- <h2>🤖 Reachy Mini Telepresence</h2>
384
- <p>Sign in with your HuggingFace account to connect to your robot and start controlling it remotely.</p>
385
- <button class="btn-hf" onclick="loginToHuggingFace()">Sign in with Hugging Face</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  </div>
387
  </div>
388
 
389
  <!-- Main App -->
390
  <div id="mainApp" class="hidden">
391
  <header class="header">
392
- <h1>Reachy Mini Telepresence</h1>
393
- <div class="user-badge">
394
- <span class="name" id="username">@user</span>
395
- <button onclick="logout()">Sign out</button>
 
 
 
 
 
 
 
 
 
 
 
 
396
  </div>
397
  </header>
398
 
399
- <div class="main-container">
400
  <!-- Video Section -->
401
- <div>
402
- <div class="video-section">
403
- <video id="remoteVideo" autoplay playsinline muted></video>
404
-
405
- <div class="video-overlay">
406
- <div class="connection-status">
407
- <div class="status-dot" id="statusDot"></div>
 
408
  <span id="statusText">Disconnected</span>
409
  </div>
410
- <div id="robotName" style="font-weight: 500;"></div>
411
  </div>
412
 
413
- <div class="video-controls">
414
- <button id="connectBtn" onclick="connectSignaling()">Connect Server</button>
415
- <button id="startBtn" class="primary" onclick="startStream()" disabled>Start Stream</button>
416
- <button id="stopBtn" class="danger" onclick="stopStream()" disabled>Stop</button>
 
 
417
  </div>
418
  </div>
419
 
420
- <!-- State Bar -->
421
  <div class="state-bar" id="stateBar">
422
  <div class="state-item">
423
  <label>Motors</label>
424
  <span class="value" id="stateMotors">--</span>
425
  </div>
426
  <div class="state-item">
427
- <label>Head Yaw</label>
428
- <span class="value" id="stateYaw">--°</span>
429
  </div>
430
  <div class="state-item">
431
- <label>Head Pitch</label>
432
- <span class="value" id="statePitch">--°</span>
433
  </div>
434
  <div class="state-item">
435
- <label>Body Yaw</label>
436
- <span class="value" id="stateBodyYaw">--°</span>
437
  </div>
438
  <div class="state-item">
439
- <label>Right Antenna</label>
440
- <span class="value" id="stateRightAnt">--°</span>
441
  </div>
442
  <div class="state-item">
443
- <label>Left Antenna</label>
444
- <span class="value" id="stateLeftAnt">--°</span>
445
  </div>
446
  <div class="state-item">
447
- <label>Recording</label>
448
- <span class="value" id="stateRecording">--</span>
449
  </div>
450
  </div>
451
 
452
- <!-- Robot Selector (shown when connected but no stream) -->
453
  <div id="robotSelector" class="panel hidden" style="margin-top: 16px;">
454
- <div class="panel-header">📡 Available Robots</div>
455
  <div class="panel-content">
456
  <div id="robotList" class="robot-list">
457
- <em style="color: #666;">Searching for robots...</em>
458
  </div>
459
  </div>
460
  </div>
 
461
 
462
- <!-- Log -->
463
- <div class="panel" style="margin-top: 16px;">
464
- <div class="panel-header" style="justify-content: space-between;">
465
- 📋 Activity Log
466
- <button onclick="clearLog()" style="background: transparent; border: none; color: #888; cursor: pointer; font-size: 0.8em;">Clear</button>
467
- </div>
468
- <div class="panel-content" style="padding: 0;">
469
- <div id="logArea" class="log-panel"></div>
 
 
 
470
  </div>
471
  </div>
472
- </div>
473
 
474
- <!-- Control Panel -->
475
- <div class="control-panel">
476
- <!-- Motor Control -->
477
  <div class="panel">
478
- <div class="panel-header"> Motor Control</div>
479
  <div class="panel-content">
480
- <div class="motor-buttons">
481
- <button class="motor-btn enable" id="btnEnable" onclick="setMotorMode('enabled')" disabled>Enable</button>
482
- <button class="motor-btn disable" id="btnDisable" onclick="setMotorMode('disabled')" disabled>Disable</button>
483
- <button class="motor-btn gravity" id="btnGravity" onclick="setMotorMode('gravity_compensation')" disabled>Gravity</button>
 
 
 
 
 
 
 
 
 
 
 
 
484
  </div>
485
  </div>
486
  </div>
487
 
488
- <!-- Head Control -->
489
  <div class="panel">
490
- <div class="panel-header">🎯 Head Control</div>
491
  <div class="panel-content">
492
- <div class="head-control">
493
- <div class="control-group">
494
- <label>Yaw (left/right)</label>
495
- <input type="range" id="yawSlider" min="-45" max="45" value="0" oninput="updateSliderValue('yaw')">
496
- <div class="value-display"><span id="yawValue">0</span>°</div>
497
- </div>
498
- <div class="control-group">
499
- <label>Pitch (up/down)</label>
500
- <input type="range" id="pitchSlider" min="-30" max="30" value="0" oninput="updateSliderValue('pitch')">
501
- <div class="value-display"><span id="pitchValue">0</span>°</div>
502
  </div>
 
503
  </div>
504
- <div style="margin-top: 12px;">
505
- <div class="control-group">
506
- <label>Body Yaw</label>
507
- <input type="range" id="bodyYawSlider" min="-45" max="45" value="0" oninput="updateSliderValue('bodyYaw')">
508
- <div class="value-display"><span id="bodyYawValue">0</span>°</div>
509
  </div>
 
510
  </div>
511
- <div class="quick-actions" style="margin-top: 12px;">
512
- <button class="action-btn" id="btnSendPose" onclick="sendHeadPose()" disabled>📤 Send Pose</button>
513
- <button class="action-btn" id="btnCenter" onclick="centerHead()" disabled>🎯 Center</button>
 
 
 
514
  </div>
515
- <div style="margin-top: 10px; display: flex; align-items: center; gap: 12px;">
516
- <label style="font-size: 0.85em; color: #888;">
517
- <input type="checkbox" id="smoothCheck" style="margin-right: 6px;"> Smooth movement
518
- </label>
519
- <label style="font-size: 0.85em; color: #888;">
520
- Duration: <input type="number" id="durationInput" value="0.5" min="0.1" max="3" step="0.1" style="width: 60px; padding: 4px; background: #0f3460; border: 1px solid #333; border-radius: 4px; color: #fff;">s
521
- </label>
522
  </div>
523
  </div>
524
  </div>
525
 
526
  <!-- Antennas -->
527
  <div class="panel">
528
- <div class="panel-header">📡 Antennas</div>
529
  <div class="panel-content">
530
- <div class="head-control">
531
- <div class="control-group">
532
- <label>Right Antenna</label>
533
- <input type="range" id="rightAntSlider" min="-175" max="175" value="0" oninput="updateSliderValue('rightAnt')">
534
- <div class="value-display"><span id="rightAntValue">0</span>°</div>
535
- </div>
536
- <div class="control-group">
537
- <label>Left Antenna</label>
538
- <input type="range" id="leftAntSlider" min="-175" max="175" value="0" oninput="updateSliderValue('leftAnt')">
539
- <div class="value-display"><span id="leftAntValue">0</span>°</div>
540
  </div>
 
541
  </div>
542
- <div class="quick-actions" style="margin-top: 12px;">
543
- <button class="action-btn" id="btnSendAnt" onclick="sendAntennas()" disabled>📤 Send</button>
544
- <button class="action-btn" id="btnResetAnt" onclick="resetAntennas()" disabled>↩️ Reset</button>
 
 
 
545
  </div>
546
  </div>
547
  </div>
548
 
549
  <!-- Animations -->
550
  <div class="panel">
551
- <div class="panel-header">Animations</div>
552
  <div class="panel-content">
553
- <div class="quick-actions">
554
- <button class="action-btn" id="btnWakeUp" onclick="wakeUp()" disabled>☀️ Wake Up</button>
555
- <button class="action-btn" id="btnSleep" onclick="goToSleep()" disabled>🌙 Sleep</button>
556
  </div>
557
  </div>
558
  </div>
559
 
560
- <!-- Sound -->
561
  <div class="panel">
562
- <div class="panel-header">🔊 Sound</div>
563
  <div class="panel-content">
564
- <div class="sound-input">
565
- <input type="text" id="soundInput" placeholder="Sound file name...">
566
- <button class="action-btn" id="btnPlaySound" onclick="playSound()" disabled style="flex: 0;">▶️</button>
567
  </div>
568
  <div class="sound-presets">
569
- <span class="sound-preset" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
570
- <span class="sound-preset" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
 
 
 
 
 
 
 
 
 
 
 
571
  </div>
572
  </div>
573
  </div>
574
 
575
  <!-- Recording -->
576
  <div class="panel">
577
- <div class="panel-header">⏺️ Recording</div>
578
  <div class="panel-content">
579
- <div class="quick-actions">
580
- <button class="action-btn" id="btnStartRec" onclick="startRecording()" disabled>⏺️ Start</button>
581
- <button class="action-btn" id="btnStopRec" onclick="stopRecording()" disabled>⏹️ Stop</button>
582
  </div>
583
  </div>
584
  </div>
@@ -600,9 +1047,41 @@
600
  let userToken = null;
601
  let currentUser = null;
602
  let sseAbortController = null;
603
- let currentMotorMode = null;
604
  let stateRefreshInterval = null;
605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  // Export functions
607
  window.loginToHuggingFace = loginToHuggingFace;
608
  window.logout = logout;
@@ -610,22 +1089,23 @@
610
  window.startStream = startStream;
611
  window.stopStream = stopStream;
612
  window.setMotorMode = setMotorMode;
613
- window.sendHeadPose = sendHeadPose;
614
- window.centerHead = centerHead;
615
- window.sendAntennas = sendAntennas;
616
- window.resetAntennas = resetAntennas;
617
  window.wakeUp = wakeUp;
618
  window.goToSleep = goToSleep;
619
  window.playSound = playSound;
620
  window.playSoundPreset = playSoundPreset;
 
 
621
  window.startRecording = startRecording;
622
  window.stopRecording = stopRecording;
623
- window.updateSliderValue = updateSliderValue;
624
- window.clearLog = clearLog;
625
 
626
  // Init
627
- document.addEventListener('DOMContentLoaded', initAuth);
 
 
 
 
628
 
 
629
  async function initAuth() {
630
  try {
631
  const oauthResult = await oauthHandleRedirectIfPresent();
@@ -676,32 +1156,16 @@
676
  document.getElementById('loginView').classList.add('hidden');
677
  document.getElementById('mainApp').classList.remove('hidden');
678
  document.getElementById('username').textContent = '@' + currentUser;
679
- log('Ready to connect', 'info');
680
- }
681
-
682
- // Logging
683
- function log(msg, type = '') {
684
- const area = document.getElementById('logArea');
685
- const entry = document.createElement('div');
686
- entry.className = 'log-entry ' + type;
687
- entry.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
688
- area.appendChild(entry);
689
- area.scrollTop = area.scrollHeight;
690
  }
691
 
692
- function clearLog() {
693
- document.getElementById('logArea').innerHTML = '';
694
- }
695
-
696
- // Connection status
697
  function updateStatus(status, text) {
698
- const dot = document.getElementById('statusDot');
699
- const txt = document.getElementById('statusText');
700
- dot.className = 'status-dot ' + status;
701
- txt.textContent = text;
702
  }
703
 
704
- // Send to server
705
  async function sendToServer(message) {
706
  try {
707
  const res = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, {
@@ -711,27 +1175,21 @@
711
  });
712
  return await res.json();
713
  } catch (e) {
714
- log(`Send error: ${e.message}`, 'error');
715
  return null;
716
  }
717
  }
718
 
719
- // Send command via data channel
720
  function sendCommand(cmd) {
721
- if (!dataChannel || dataChannel.readyState !== 'open') {
722
- log('Not connected', 'error');
723
- return false;
724
- }
725
  dataChannel.send(JSON.stringify(cmd));
726
  return true;
727
  }
728
 
729
- // SSE Connection
730
  async function connectSignaling() {
731
  if (!userToken) return;
732
 
733
  updateStatus('connecting', 'Connecting...');
734
- log('Connecting to server...');
735
  document.getElementById('connectBtn').disabled = true;
736
 
737
  sseAbortController = new AbortController();
@@ -744,7 +1202,6 @@
744
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
745
 
746
  updateStatus('connected', 'Server connected');
747
- log('Connected to signaling server', 'success');
748
  document.getElementById('robotSelector').classList.remove('hidden');
749
 
750
  const reader = res.body.getReader();
@@ -772,7 +1229,7 @@
772
  }
773
  } catch (e) {
774
  if (e.name !== 'AbortError') {
775
- log(`Connection failed: ${e.message}`, 'error');
776
  }
777
  updateStatus('', 'Disconnected');
778
  document.getElementById('connectBtn').disabled = false;
@@ -805,9 +1262,6 @@
805
  case 'peer':
806
  handlePeerMessage(msg);
807
  break;
808
- case 'error':
809
- log(`Error: ${msg.details}`, 'error');
810
- break;
811
  }
812
  }
813
 
@@ -816,17 +1270,17 @@
816
  list.innerHTML = '';
817
 
818
  if (!robots?.length) {
819
- list.innerHTML = '<em style="color: #666;">No robots online. Make sure your robot is connected.</em>';
820
  document.getElementById('startBtn').disabled = true;
821
  return;
822
  }
823
 
824
  for (const robot of robots) {
825
  const div = document.createElement('div');
826
- div.className = 'robot-item' + (robot.id === selectedProducerId ? ' selected' : '');
827
  div.innerHTML = `
828
  <div class="name">${robot.meta?.name || 'Reachy Mini'}</div>
829
- <div class="id">${robot.id.slice(0, 8)}...</div>
830
  `;
831
  div.onclick = () => selectRobot(robot, div);
832
  list.appendChild(div);
@@ -834,19 +1288,17 @@
834
  }
835
 
836
  function selectRobot(robot, el) {
837
- document.querySelectorAll('.robot-item').forEach(e => e.classList.remove('selected'));
838
  el.classList.add('selected');
839
  selectedProducerId = robot.id;
840
  document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini';
841
  document.getElementById('startBtn').disabled = false;
842
- log(`Selected: ${robot.meta?.name || robot.id.slice(0, 8)}`);
843
  }
844
 
845
- // WebRTC
846
  async function startStream() {
847
  if (!selectedProducerId) return;
848
 
849
- log('Starting WebRTC...');
850
  updateStatus('connecting', 'Connecting to robot...');
851
 
852
  peerConnection = new RTCPeerConnection({
@@ -854,9 +1306,17 @@
854
  });
855
 
856
  peerConnection.ontrack = (e) => {
 
857
  if (e.track.kind === 'video') {
858
  document.getElementById('remoteVideo').srcObject = e.streams[0];
859
- log('Video stream received', 'success');
 
 
 
 
 
 
 
860
  }
861
  };
862
 
@@ -876,18 +1336,24 @@
876
  updateStatus('connected', 'Connected');
877
  enableControls(true);
878
  document.getElementById('robotSelector').classList.add('hidden');
879
- // Start state refresh
880
- stateRefreshInterval = setInterval(() => sendCommand({ get_state: true }), 2000);
 
 
 
 
 
 
 
 
881
  } else if (state === 'failed' || state === 'disconnected') {
882
  updateStatus('', 'Connection lost');
883
- log('Connection lost', 'error');
884
  }
885
  };
886
 
887
  peerConnection.ondatachannel = (e) => {
888
  dataChannel = e.channel;
889
  dataChannel.onopen = () => {
890
- log('Data channel open', 'success');
891
  sendCommand({ get_state: true });
892
  };
893
  dataChannel.onmessage = (e) => handleRobotMessage(JSON.parse(e.data));
@@ -906,6 +1372,14 @@
906
  if (msg.sdp) {
907
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
908
  if (msg.sdp.type === 'offer') {
 
 
 
 
 
 
 
 
909
  const answer = await peerConnection.createAnswer();
910
  await peerConnection.setLocalDescription(answer);
911
  await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
@@ -915,179 +1389,416 @@
915
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
916
  }
917
  } catch (e) {
918
- log(`WebRTC error: ${e.message}`, 'error');
919
  }
920
  }
921
 
922
  function stopStream() {
923
  if (stateRefreshInterval) clearInterval(stateRefreshInterval);
 
924
  if (peerConnection) peerConnection.close();
925
  if (dataChannel) dataChannel.close();
926
  peerConnection = null;
927
  dataChannel = null;
928
  currentSessionId = null;
 
929
  document.getElementById('remoteVideo').srcObject = null;
 
930
  document.getElementById('startBtn').disabled = !selectedProducerId;
931
  document.getElementById('stopBtn').disabled = true;
932
  document.getElementById('robotSelector').classList.remove('hidden');
933
  enableControls(false);
934
  updateStatus('connected', 'Server connected');
935
- resetStateDisplay();
936
  }
937
 
938
  function enableControls(enabled) {
939
- const btns = ['btnEnable', 'btnDisable', 'btnGravity', 'btnSendPose', 'btnCenter',
940
- 'btnSendAnt', 'btnResetAnt', 'btnWakeUp', 'btnSleep', 'btnPlaySound',
941
- 'btnStartRec', 'btnStopRec'];
942
  btns.forEach(id => document.getElementById(id).disabled = !enabled);
943
  }
944
 
945
- // Robot message handler
946
  function handleRobotMessage(data) {
947
  if (data.state) {
948
- updateStateDisplay(data.state);
949
  } else if (data.motor_mode) {
950
- setMotorButtonActive(data.motor_mode);
 
951
  document.getElementById('stateMotors').textContent = data.motor_mode;
952
  } else if (data.error) {
953
- log(`Robot error: ${data.error}`, 'error');
954
- } else if (data.status === 'ok' && data.completed) {
955
- log(`${data.command} completed`, 'success');
956
  }
957
  }
958
 
959
- function updateStateDisplay(state) {
960
  if (state.motor_mode) {
 
961
  document.getElementById('stateMotors').textContent = state.motor_mode;
962
- setMotorButtonActive(state.motor_mode);
963
  }
 
964
  if (state.head_pose) {
965
- // Extract angles from rotation matrix (simplified)
966
  const m = state.head_pose;
 
967
  const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
968
  const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
 
 
 
 
 
 
969
  document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
970
  document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971
  }
 
972
  if (state.body_yaw !== undefined) {
973
- document.getElementById('stateBodyYaw').textContent = (state.body_yaw * 180 / Math.PI).toFixed(1) + '°';
 
 
 
 
 
 
 
974
  }
 
975
  if (state.antennas) {
976
- document.getElementById('stateRightAnt').textContent = (state.antennas[0] * 180 / Math.PI).toFixed(0) + '°';
977
- document.getElementById('stateLeftAnt').textContent = (state.antennas[1] * 180 / Math.PI).toFixed(0) + '°';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  }
 
979
  if (state.is_recording !== undefined) {
980
- document.getElementById('stateRecording').textContent = state.is_recording ? '🔴 REC' : 'OFF';
981
  document.getElementById('btnStartRec').classList.toggle('recording', state.is_recording);
982
  }
983
  }
984
 
985
- function resetStateDisplay() {
986
- ['stateMotors', 'stateYaw', 'statePitch', 'stateBodyYaw', 'stateRightAnt', 'stateLeftAnt'].forEach(id => {
987
- document.getElementById(id).textContent = '--';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
988
  });
989
- document.getElementById('stateRecording').textContent = '--';
990
- setMotorButtonActive(null);
991
- }
992
 
993
- function setMotorButtonActive(mode) {
994
- document.getElementById('btnEnable').classList.toggle('active', mode === 'enabled');
995
- document.getElementById('btnDisable').classList.toggle('active', mode === 'disabled');
996
- document.getElementById('btnGravity').classList.toggle('active', mode === 'gravity_compensation');
997
- currentMotorMode = mode;
998
  }
999
 
1000
- // Slider updates
1001
- function updateSliderValue(name) {
1002
- const slider = document.getElementById(name + 'Slider');
1003
- const display = document.getElementById(name + 'Value');
1004
- display.textContent = slider.value;
1005
- }
1006
 
1007
- // Controls
1008
- function setMotorMode(mode) {
1009
- sendCommand({ set_motor_mode: mode });
1010
- log(`Motor: ${mode}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  }
1012
 
1013
- function buildMatrix(yaw, pitch) {
1014
- const yr = yaw * Math.PI / 180, pr = pitch * Math.PI / 180;
1015
- const cy = Math.cos(yr), sy = Math.sin(yr), cp = Math.cos(pr), sp = Math.sin(pr);
1016
- return [[cy*cp, -sy, cy*sp, 0], [sy*cp, cy, sy*sp, 0], [-sp, 0, cp, 0], [0, 0, 0, 1]];
 
1017
  }
1018
 
1019
- function sendHeadPose() {
1020
- const yaw = parseInt(document.getElementById('yawSlider').value);
1021
- const pitch = parseInt(document.getElementById('pitchSlider').value);
1022
- const smooth = document.getElementById('smoothCheck').checked;
1023
- const duration = parseFloat(document.getElementById('durationInput').value);
1024
- const bodyYaw = parseInt(document.getElementById('bodyYawSlider').value) * Math.PI / 180;
 
 
 
 
 
 
 
 
 
 
 
1025
 
1026
- if (smooth) {
1027
- sendCommand({ goto_target: { head: buildMatrix(yaw, pitch), body_yaw: bodyYaw, duration } });
1028
- log(`Goto: yaw=${yaw}°, pitch=${pitch}°, duration=${duration}s`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
  } else {
1030
- sendCommand({ set_target: buildMatrix(yaw, pitch) });
1031
- if (bodyYaw !== 0) sendCommand({ set_body_yaw: bodyYaw });
1032
- log(`Set: yaw=${yaw}°, pitch=${pitch}°`);
 
 
 
1033
  }
1034
  }
1035
 
1036
- function centerHead() {
1037
- ['yawSlider', 'pitchSlider', 'bodyYawSlider'].forEach(id => document.getElementById(id).value = 0);
1038
- ['yawValue', 'pitchValue', 'bodyYawValue'].forEach(id => document.getElementById(id).textContent = '0');
1039
- sendCommand({ goto_target: { head: buildMatrix(0, 0), body_yaw: 0, duration: 0.5 } });
1040
- log('Centering head...');
1041
- }
1042
 
1043
- function sendAntennas() {
1044
- const r = parseInt(document.getElementById('rightAntSlider').value) * Math.PI / 180;
1045
- const l = parseInt(document.getElementById('leftAntSlider').value) * Math.PI / 180;
1046
- sendCommand({ set_antennas: [r, l] });
1047
- log(`Antennas: R=${(r*180/Math.PI).toFixed(0)}°, L=${(l*180/Math.PI).toFixed(0)}°`);
 
 
 
 
 
 
1048
  }
1049
 
1050
- function resetAntennas() {
1051
- document.getElementById('rightAntSlider').value = 0;
1052
- document.getElementById('leftAntSlider').value = 0;
1053
- document.getElementById('rightAntValue').textContent = '0';
1054
- document.getElementById('leftAntValue').textContent = '0';
1055
- sendCommand({ set_antennas: [0, 0] });
1056
- log('Antennas reset');
1057
  }
1058
 
1059
  function wakeUp() {
1060
  sendCommand({ wake_up: true });
1061
- log('Wake up animation...', 'info');
1062
  }
1063
 
1064
  function goToSleep() {
1065
  sendCommand({ goto_sleep: true });
1066
- log('Sleep animation...', 'info');
1067
  }
1068
 
1069
  function playSound() {
1070
  const file = document.getElementById('soundInput').value.trim();
1071
- if (file) {
1072
- sendCommand({ play_sound: file });
1073
- log(`Playing: ${file}`);
1074
- }
1075
  }
1076
 
1077
  function playSoundPreset(file) {
1078
  document.getElementById('soundInput').value = file;
1079
  sendCommand({ play_sound: file });
1080
- log(`Playing: ${file}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
  }
1082
 
1083
  function startRecording() {
1084
  sendCommand({ start_recording: true });
1085
- log('Recording started', 'info');
1086
  }
1087
 
1088
  function stopRecording() {
1089
  sendCommand({ stop_recording: true });
1090
- log('Recording stopped', 'info');
1091
  }
1092
  </script>
1093
  </body>
 
1
  <!doctype html>
2
+ <html lang="en">
3
  <head>
4
  <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
6
+ <title>Reachy Mini - Pollen Robotics</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
  <style>
11
+ :root {
12
+ --pollen-coral: #FF6B35;
13
+ --pollen-coral-light: #FF8A5C;
14
+ --pollen-coral-dark: #E55A2B;
15
+ --pollen-dark: #1A1A2E;
16
+ --pollen-darker: #0F0F1A;
17
+ --pollen-card: #16213E;
18
+ --pollen-card-light: #1E2A4A;
19
+ --text-primary: #FFFFFF;
20
+ --text-secondary: #A0AEC0;
21
+ --text-muted: #718096;
22
+ --success: #48BB78;
23
+ --warning: #ECC94B;
24
+ --danger: #F56565;
25
+ }
26
+
27
  * { box-sizing: border-box; margin: 0; padding: 0; }
28
+
29
  body {
30
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
31
+ background: var(--pollen-darker);
32
+ color: var(--text-primary);
33
  min-height: 100vh;
34
+ overflow-x: hidden;
35
  }
36
 
37
  /* Header */
38
  .header {
39
+ background: rgba(0,0,0,0.4);
40
+ backdrop-filter: blur(10px);
41
+ padding: 12px 20px;
42
  display: flex;
43
  align-items: center;
44
  justify-content: space-between;
45
+ border-bottom: 1px solid rgba(255,107,53,0.2);
46
+ position: sticky;
47
+ top: 0;
48
+ z-index: 100;
49
  }
50
+
51
+ .logo {
 
52
  display: flex;
53
  align-items: center;
54
+ gap: 12px;
55
  }
56
+
57
+ .logo svg {
58
+ width: 36px;
59
+ height: 36px;
60
+ }
61
+
62
+ .logo-text {
63
+ font-weight: 700;
64
+ font-size: 1.2em;
65
+ color: var(--pollen-coral);
66
+ }
67
+
68
+ .logo-text span {
69
+ color: var(--text-secondary);
70
+ font-weight: 400;
71
+ }
72
+
73
+ .user-section {
74
  display: flex;
75
  align-items: center;
76
  gap: 12px;
 
 
 
77
  }
78
+
79
+ .user-badge {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ background: var(--pollen-card);
84
+ padding: 6px 14px;
85
+ border-radius: 20px;
86
+ font-size: 0.9em;
87
+ }
88
+
89
+ .btn-logout {
90
  background: transparent;
91
+ border: 1px solid var(--text-muted);
92
+ color: var(--text-secondary);
93
+ padding: 6px 14px;
94
+ border-radius: 16px;
95
  cursor: pointer;
96
  font-size: 0.85em;
97
+ transition: all 0.2s;
98
+ }
99
+
100
+ .btn-logout:hover {
101
+ border-color: var(--pollen-coral);
102
+ color: var(--pollen-coral);
103
  }
 
104
 
105
  /* Main Layout */
106
+ .app-container {
107
  display: grid;
108
+ grid-template-columns: 1fr 340px;
109
+ gap: 16px;
110
+ padding: 16px;
111
  max-width: 1600px;
112
  margin: 0 auto;
113
+ min-height: calc(100vh - 65px);
114
  }
115
+
116
+ @media (max-width: 1024px) {
117
+ .app-container {
118
+ grid-template-columns: 1fr;
119
+ }
120
+ .control-sidebar {
121
+ order: 2;
122
+ }
123
  }
124
 
125
  /* Video Section */
126
+ .video-container {
127
+ position: relative;
128
  background: #000;
129
  border-radius: 16px;
130
  overflow: hidden;
131
+ aspect-ratio: 16/9;
132
  }
133
+
134
+ @media (max-width: 1024px) {
135
+ .video-container {
136
+ aspect-ratio: 4/3;
137
+ }
138
+ }
139
+
140
  video {
141
  width: 100%;
142
+ height: 100%;
143
+ object-fit: cover;
144
+ background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
145
  }
146
+
147
+ .video-overlay-top {
148
  position: absolute;
149
  top: 0;
150
  left: 0;
151
  right: 0;
152
  padding: 16px;
153
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
154
  display: flex;
155
  justify-content: space-between;
156
  align-items: flex-start;
157
  }
158
+
159
+ .connection-badge {
160
  display: flex;
161
  align-items: center;
162
  gap: 8px;
163
+ background: rgba(0,0,0,0.5);
164
+ padding: 8px 14px;
165
+ border-radius: 20px;
166
+ font-size: 0.85em;
167
  }
168
+
169
+ .status-indicator {
170
  width: 10px;
171
  height: 10px;
172
  border-radius: 50%;
173
+ background: var(--danger);
174
  }
 
 
 
175
 
176
+ .status-indicator.connected {
177
+ background: var(--success);
178
+ box-shadow: 0 0 8px var(--success);
179
+ }
180
+
181
+ .status-indicator.connecting {
182
+ background: var(--warning);
183
+ animation: blink 0.8s infinite;
184
+ }
185
+
186
+ @keyframes blink {
187
+ 0%, 100% { opacity: 1; }
188
+ 50% { opacity: 0.4; }
189
+ }
190
+
191
+ .robot-name {
192
+ background: rgba(0,0,0,0.5);
193
+ padding: 8px 14px;
194
+ border-radius: 20px;
195
+ font-size: 0.85em;
196
+ font-weight: 500;
197
+ }
198
+
199
+ .video-overlay-bottom {
200
  position: absolute;
201
  bottom: 0;
202
  left: 0;
203
  right: 0;
204
  padding: 16px;
205
+ background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
206
+ }
207
+
208
+ .video-controls {
209
  display: flex;
210
  justify-content: center;
211
  gap: 12px;
212
+ flex-wrap: wrap;
213
  }
214
+
215
+ .btn {
216
+ padding: 10px 20px;
217
  border: none;
 
 
218
  border-radius: 8px;
219
+ font-weight: 600;
220
+ font-size: 0.9em;
221
  cursor: pointer;
 
 
222
  transition: all 0.2s;
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 6px;
226
+ }
227
+
228
+ .btn-primary {
229
+ background: var(--pollen-coral);
230
+ color: white;
231
+ }
232
+
233
+ .btn-primary:hover {
234
+ background: var(--pollen-coral-light);
235
+ transform: translateY(-1px);
236
  }
 
 
 
 
 
237
 
238
+ .btn-secondary {
239
+ background: rgba(255,255,255,0.15);
240
+ color: white;
241
+ }
242
+
243
+ .btn-secondary:hover {
244
+ background: rgba(255,255,255,0.25);
245
+ }
246
+
247
+ .btn-danger {
248
+ background: var(--danger);
249
+ color: white;
250
+ }
251
+
252
+ .btn:disabled {
253
+ opacity: 0.4;
254
+ cursor: not-allowed;
255
+ transform: none;
256
+ }
257
+
258
+ /* State Bar */
259
  .state-bar {
260
  display: flex;
261
+ gap: 16px;
262
+ padding: 12px 16px;
263
+ background: var(--pollen-card);
264
  border-radius: 0 0 16px 16px;
265
  flex-wrap: wrap;
266
+ margin-top: -16px;
267
  }
268
+
269
  .state-item {
270
  display: flex;
271
  flex-direction: column;
272
+ gap: 2px;
273
  }
274
+
275
  .state-item label {
276
+ font-size: 0.7em;
277
+ color: var(--text-muted);
278
  text-transform: uppercase;
279
  letter-spacing: 0.5px;
280
  }
281
+
282
  .state-item .value {
283
+ font-family: 'SF Mono', 'Fira Code', monospace;
284
+ font-size: 0.9em;
285
+ color: var(--pollen-coral);
286
  }
287
 
288
+ /* Control Sidebar */
289
+ .control-sidebar {
290
  display: flex;
291
  flex-direction: column;
292
+ gap: 12px;
293
+ max-height: calc(100vh - 90px);
294
+ overflow-y: auto;
295
+ }
296
+
297
+ .control-sidebar::-webkit-scrollbar {
298
+ width: 6px;
299
  }
300
 
301
+ .control-sidebar::-webkit-scrollbar-track {
302
+ background: transparent;
303
+ }
304
+
305
+ .control-sidebar::-webkit-scrollbar-thumb {
306
+ background: var(--pollen-card-light);
307
+ border-radius: 3px;
308
+ }
309
+
310
+ /* Panels */
311
  .panel {
312
+ background: var(--pollen-card);
313
  border-radius: 12px;
314
  overflow: hidden;
315
  }
316
+
317
  .panel-header {
318
  padding: 12px 16px;
319
  background: rgba(0,0,0,0.2);
320
  font-weight: 600;
321
+ font-size: 0.85em;
322
  display: flex;
323
  align-items: center;
324
  gap: 8px;
325
+ color: var(--pollen-coral);
326
  }
327
+
328
  .panel-content {
329
  padding: 16px;
330
  }
331
 
332
+ /* Joystick */
333
+ .joystick-container {
334
+ display: flex;
335
+ gap: 16px;
336
+ align-items: center;
337
+ justify-content: center;
338
+ }
339
+
340
+ .joystick-area {
341
+ width: 160px;
342
+ height: 160px;
343
+ background: radial-gradient(circle at center, var(--pollen-card-light) 0%, var(--pollen-darker) 100%);
344
+ border-radius: 50%;
345
+ position: relative;
346
+ border: 2px solid var(--pollen-coral);
347
+ touch-action: none;
348
+ cursor: grab;
349
+ }
350
+
351
+ .joystick-area:active {
352
+ cursor: grabbing;
353
+ }
354
+
355
+ .joystick-knob {
356
+ width: 50px;
357
+ height: 50px;
358
+ background: var(--pollen-coral);
359
+ border-radius: 50%;
360
+ position: absolute;
361
+ top: 50%;
362
+ left: 50%;
363
+ transform: translate(-50%, -50%);
364
+ box-shadow: 0 4px 12px rgba(255,107,53,0.4);
365
+ pointer-events: none;
366
+ transition: box-shadow 0.2s;
367
+ }
368
+
369
+ .joystick-area:active .joystick-knob {
370
+ box-shadow: 0 6px 20px rgba(255,107,53,0.6);
371
+ }
372
+
373
+ .joystick-labels {
374
+ position: absolute;
375
+ font-size: 0.7em;
376
+ color: var(--text-muted);
377
+ }
378
+
379
+ .joystick-labels.top { top: 8px; left: 50%; transform: translateX(-50%); }
380
+ .joystick-labels.bottom { bottom: 8px; left: 50%; transform: translateX(-50%); }
381
+ .joystick-labels.left { left: 8px; top: 50%; transform: translateY(-50%); }
382
+ .joystick-labels.right { right: 8px; top: 50%; transform: translateY(-50%); }
383
+
384
+ .z-slider-container {
385
+ display: flex;
386
+ flex-direction: column;
387
+ align-items: center;
388
+ gap: 8px;
389
+ }
390
+
391
+ .z-slider {
392
+ writing-mode: vertical-lr;
393
+ direction: rtl;
394
+ height: 140px;
395
+ width: 8px;
396
+ -webkit-appearance: none;
397
+ background: var(--pollen-darker);
398
+ border-radius: 4px;
399
+ }
400
+
401
+ .z-slider::-webkit-slider-thumb {
402
+ -webkit-appearance: none;
403
+ width: 24px;
404
+ height: 24px;
405
+ background: var(--pollen-coral);
406
+ border-radius: 50%;
407
+ cursor: pointer;
408
+ box-shadow: 0 2px 8px rgba(255,107,53,0.4);
409
+ }
410
+
411
+ .z-label {
412
+ font-size: 0.75em;
413
+ color: var(--text-muted);
414
+ }
415
+
416
+ /* Sliders */
417
+ .slider-group {
418
+ margin-bottom: 16px;
419
+ }
420
+
421
+ .slider-group:last-child {
422
+ margin-bottom: 0;
423
+ }
424
+
425
+ .slider-header {
426
+ display: flex;
427
+ justify-content: space-between;
428
+ align-items: center;
429
+ margin-bottom: 8px;
430
+ }
431
+
432
+ .slider-label {
433
+ font-size: 0.85em;
434
+ color: var(--text-secondary);
435
+ }
436
+
437
+ .slider-value {
438
+ font-family: 'SF Mono', monospace;
439
+ font-size: 0.85em;
440
+ color: var(--pollen-coral);
441
+ min-width: 50px;
442
+ text-align: right;
443
+ }
444
+
445
+ .slider {
446
+ width: 100%;
447
+ height: 6px;
448
+ -webkit-appearance: none;
449
+ background: var(--pollen-darker);
450
+ border-radius: 3px;
451
+ outline: none;
452
+ }
453
+
454
+ .slider::-webkit-slider-thumb {
455
+ -webkit-appearance: none;
456
+ width: 18px;
457
+ height: 18px;
458
+ background: var(--pollen-coral);
459
+ border-radius: 50%;
460
+ cursor: pointer;
461
+ transition: transform 0.1s, box-shadow 0.1s;
462
+ }
463
+
464
+ .slider::-webkit-slider-thumb:hover {
465
+ transform: scale(1.1);
466
+ box-shadow: 0 0 10px rgba(255,107,53,0.5);
467
+ }
468
+
469
+ .slider::-webkit-slider-thumb:active {
470
+ transform: scale(1.2);
471
+ }
472
+
473
+ /* Motor Buttons */
474
+ .motor-grid {
475
  display: grid;
476
  grid-template-columns: repeat(3, 1fr);
477
  gap: 8px;
478
  }
479
+
480
  .motor-btn {
481
  padding: 10px;
482
  border: 2px solid transparent;
483
  border-radius: 8px;
484
  font-weight: 600;
485
+ font-size: 0.8em;
486
  cursor: pointer;
487
  transition: all 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  }
489
+
490
+ .motor-btn.on {
491
+ background: #1B5E20;
492
+ color: white;
 
493
  }
494
+
495
+ .motor-btn.on:hover { background: #2E7D32; }
496
+ .motor-btn.on.active { border-color: var(--success); box-shadow: 0 0 12px rgba(72,187,120,0.4); }
497
+
498
+ .motor-btn.off {
499
+ background: #B71C1C;
500
+ color: white;
501
  }
502
+
503
+ .motor-btn.off:hover { background: #C62828; }
504
+ .motor-btn.off.active { border-color: var(--danger); box-shadow: 0 0 12px rgba(245,101,101,0.4); }
505
+
506
+ .motor-btn.gravity {
507
+ background: var(--pollen-coral-dark);
508
+ color: white;
509
  }
510
+
511
+ .motor-btn.gravity:hover { background: var(--pollen-coral); }
512
+ .motor-btn.gravity.active { border-color: var(--pollen-coral-light); box-shadow: 0 0 12px rgba(255,107,53,0.4); }
513
+
514
+ .motor-btn:disabled {
515
+ opacity: 0.4;
516
+ cursor: not-allowed;
517
  }
518
 
519
+ /* Animation Buttons */
520
+ .action-grid {
521
  display: grid;
522
  grid-template-columns: repeat(2, 1fr);
523
  gap: 8px;
524
  }
525
+
526
  .action-btn {
527
  padding: 12px;
528
+ background: var(--pollen-darker);
529
+ border: 1px solid var(--pollen-card-light);
530
+ color: var(--text-primary);
531
  border-radius: 8px;
532
  cursor: pointer;
533
+ font-size: 0.85em;
534
  transition: all 0.2s;
535
  display: flex;
536
  align-items: center;
537
  justify-content: center;
538
+ gap: 6px;
539
  }
 
 
 
540
 
541
+ .action-btn:hover {
542
+ background: var(--pollen-card-light);
543
+ border-color: var(--pollen-coral);
544
+ }
545
+
546
+ .action-btn:disabled {
547
+ opacity: 0.4;
548
+ cursor: not-allowed;
549
+ }
550
+
551
+ .action-btn.recording {
552
+ background: var(--danger);
553
+ border-color: var(--danger);
554
+ animation: blink 1s infinite;
555
+ }
556
+
557
+ /* Sound & Speak */
558
+ .sound-row {
559
  display: flex;
560
  gap: 8px;
561
+ margin-bottom: 12px;
562
  }
563
+
564
+ .sound-input {
565
  flex: 1;
566
  padding: 10px 12px;
567
+ background: var(--pollen-darker);
568
+ border: 1px solid var(--pollen-card-light);
569
  border-radius: 8px;
570
+ color: var(--text-primary);
571
  font-size: 0.9em;
572
  }
573
+
574
+ .sound-input:focus {
575
  outline: none;
576
+ border-color: var(--pollen-coral);
577
  }
578
+
579
  .sound-presets {
580
  display: flex;
581
  flex-wrap: wrap;
582
  gap: 6px;
 
583
  }
584
+
585
+ .preset-chip {
586
  padding: 6px 12px;
587
+ background: var(--pollen-darker);
588
+ border: 1px solid var(--pollen-card-light);
589
  border-radius: 16px;
590
+ color: var(--text-secondary);
591
+ font-size: 0.75em;
592
  cursor: pointer;
593
  transition: all 0.2s;
594
  }
 
595
 
596
+ .preset-chip:hover {
597
+ border-color: var(--pollen-coral);
598
+ color: var(--pollen-coral);
599
+ }
600
+
601
+ .speak-section {
602
+ margin-top: 16px;
603
+ padding-top: 16px;
604
+ border-top: 1px solid var(--pollen-card-light);
605
+ }
606
+
607
+ .speak-label {
608
+ font-size: 0.8em;
609
+ color: var(--text-muted);
610
+ margin-bottom: 8px;
611
+ display: block;
612
+ }
613
+
614
+ .speak-row {
615
+ display: flex;
616
+ gap: 8px;
617
+ }
618
+
619
+ .speak-input {
620
+ flex: 1;
621
+ padding: 10px 12px;
622
+ background: var(--pollen-darker);
623
+ border: 1px solid var(--pollen-card-light);
624
  border-radius: 8px;
625
+ color: var(--text-primary);
626
+ font-size: 0.9em;
627
+ resize: none;
628
+ }
629
+
630
+ .speak-input:focus {
631
+ outline: none;
632
+ border-color: var(--pollen-coral);
633
+ }
634
+
635
+ /* Robot Selector */
636
+ .robot-list {
637
+ display: flex;
638
+ flex-direction: column;
639
+ gap: 8px;
640
+ }
641
+
642
+ .robot-card {
643
+ padding: 12px 16px;
644
+ background: var(--pollen-darker);
645
+ border: 2px solid transparent;
646
+ border-radius: 10px;
647
+ cursor: pointer;
648
+ transition: all 0.2s;
649
+ }
650
+
651
+ .robot-card:hover {
652
+ background: var(--pollen-card-light);
653
+ }
654
+
655
+ .robot-card.selected {
656
+ border-color: var(--pollen-coral);
657
+ background: var(--pollen-card-light);
658
+ }
659
+
660
+ .robot-card .name {
661
+ font-weight: 600;
662
+ margin-bottom: 4px;
663
+ }
664
+
665
+ .robot-card .id {
666
  font-size: 0.8em;
667
+ color: var(--text-muted);
668
+ font-family: monospace;
669
  }
 
 
 
 
670
 
671
  /* Login View */
672
  .login-view {
673
+ min-height: 100vh;
674
  display: flex;
675
  align-items: center;
676
  justify-content: center;
 
677
  padding: 20px;
678
+ background: linear-gradient(135deg, var(--pollen-darker) 0%, var(--pollen-dark) 100%);
679
  }
680
+
681
  .login-card {
682
+ background: var(--pollen-card);
683
+ padding: 48px;
684
+ border-radius: 20px;
685
  text-align: center;
686
+ max-width: 420px;
687
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
688
+ }
689
+
690
+ .login-logo {
691
+ width: 80px;
692
+ height: 80px;
693
+ margin-bottom: 24px;
694
  }
695
+
696
  .login-card h2 {
697
+ color: var(--pollen-coral);
698
+ margin-bottom: 12px;
699
+ font-size: 1.8em;
700
  }
701
+
702
  .login-card p {
703
+ color: var(--text-secondary);
704
+ margin-bottom: 32px;
705
+ line-height: 1.6;
706
  }
707
+
708
  .btn-hf {
709
+ background: #FFD21E;
710
  color: #000;
711
  border: none;
712
  padding: 14px 32px;
713
+ border-radius: 10px;
714
+ font-size: 1em;
715
+ font-weight: 700;
716
  cursor: pointer;
717
+ transition: all 0.2s;
718
+ display: inline-flex;
719
+ align-items: center;
 
 
 
 
 
720
  gap: 8px;
721
  }
722
+
723
+ .btn-hf:hover {
724
+ background: #FFE55C;
725
+ transform: translateY(-2px);
726
+ box-shadow: 0 8px 20px rgba(255,210,30,0.3);
 
 
727
  }
 
 
 
 
728
 
729
+ /* Utilities */
730
  .hidden { display: none !important; }
731
+
732
+ /* Mobile adjustments */
733
+ @media (max-width: 600px) {
734
+ .header {
735
+ padding: 10px 16px;
736
+ }
737
+
738
+ .logo-text {
739
+ font-size: 1em;
740
+ }
741
+
742
+ .app-container {
743
+ padding: 12px;
744
+ gap: 12px;
745
+ }
746
+
747
+ .video-controls {
748
+ gap: 8px;
749
+ }
750
+
751
+ .btn {
752
+ padding: 8px 14px;
753
+ font-size: 0.85em;
754
+ }
755
+
756
+ .panel-content {
757
+ padding: 12px;
758
+ }
759
+
760
+ .joystick-area {
761
+ width: 140px;
762
+ height: 140px;
763
+ }
764
+
765
+ .state-bar {
766
+ gap: 12px;
767
+ padding: 10px 12px;
768
+ }
769
+
770
+ .state-item label {
771
+ font-size: 0.65em;
772
+ }
773
+
774
+ .state-item .value {
775
+ font-size: 0.8em;
776
+ }
777
+ }
778
  </style>
779
  </head>
780
  <body>
781
  <!-- Login View -->
782
  <div id="loginView" class="login-view">
783
  <div class="login-card">
784
+ <svg class="login-logo" viewBox="0 0 100 100" fill="none">
785
+ <circle cx="50" cy="50" r="45" stroke="#FF6B35" stroke-width="4"/>
786
+ <circle cx="35" cy="40" r="8" fill="#FF6B35"/>
787
+ <circle cx="65" cy="40" r="8" fill="#FF6B35"/>
788
+ <path d="M30 60 Q50 80 70 60" stroke="#FF6B35" stroke-width="4" fill="none" stroke-linecap="round"/>
789
+ <path d="M25 20 L35 35" stroke="#FF6B35" stroke-width="3" stroke-linecap="round"/>
790
+ <path d="M75 20 L65 35" stroke="#FF6B35" stroke-width="3" stroke-linecap="round"/>
791
+ </svg>
792
+ <h2>Reachy Mini</h2>
793
+ <p>Sign in with your HuggingFace account to connect and control your robot remotely.</p>
794
+ <button class="btn-hf" onclick="loginToHuggingFace()">
795
+ <svg width="20" height="20" viewBox="0 0 95 88" fill="currentColor">
796
+ <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"/>
797
+ </svg>
798
+ Sign in with Hugging Face
799
+ </button>
800
  </div>
801
  </div>
802
 
803
  <!-- Main App -->
804
  <div id="mainApp" class="hidden">
805
  <header class="header">
806
+ <div class="logo">
807
+ <svg viewBox="0 0 100 100" fill="none">
808
+ <circle cx="50" cy="50" r="45" stroke="#FF6B35" stroke-width="4"/>
809
+ <circle cx="35" cy="40" r="8" fill="#FF6B35"/>
810
+ <circle cx="65" cy="40" r="8" fill="#FF6B35"/>
811
+ <path d="M30 60 Q50 80 70 60" stroke="#FF6B35" stroke-width="4" fill="none" stroke-linecap="round"/>
812
+ <path d="M25 20 L35 35" stroke="#FF6B35" stroke-width="3" stroke-linecap="round"/>
813
+ <path d="M75 20 L65 35" stroke="#FF6B35" stroke-width="3" stroke-linecap="round"/>
814
+ </svg>
815
+ <div class="logo-text">Reachy Mini <span>by Pollen Robotics</span></div>
816
+ </div>
817
+ <div class="user-section">
818
+ <div class="user-badge">
819
+ <span id="username">@user</span>
820
+ </div>
821
+ <button class="btn-logout" onclick="logout()">Sign out</button>
822
  </div>
823
  </header>
824
 
825
+ <div class="app-container">
826
  <!-- Video Section -->
827
+ <div class="video-section">
828
+ <div class="video-container">
829
+ <video id="remoteVideo" autoplay playsinline></video>
830
+ <audio id="remoteAudio" autoplay></audio>
831
+
832
+ <div class="video-overlay-top">
833
+ <div class="connection-badge">
834
+ <div class="status-indicator" id="statusIndicator"></div>
835
  <span id="statusText">Disconnected</span>
836
  </div>
837
+ <div class="robot-name" id="robotName"></div>
838
  </div>
839
 
840
+ <div class="video-overlay-bottom">
841
+ <div class="video-controls">
842
+ <button class="btn btn-secondary" id="connectBtn" onclick="connectSignaling()">Connect Server</button>
843
+ <button class="btn btn-primary" id="startBtn" onclick="startStream()" disabled>Start Stream</button>
844
+ <button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Disconnect</button>
845
+ </div>
846
  </div>
847
  </div>
848
 
 
849
  <div class="state-bar" id="stateBar">
850
  <div class="state-item">
851
  <label>Motors</label>
852
  <span class="value" id="stateMotors">--</span>
853
  </div>
854
  <div class="state-item">
855
+ <label>Yaw</label>
856
+ <span class="value" id="stateYaw">--</span>
857
  </div>
858
  <div class="state-item">
859
+ <label>Pitch</label>
860
+ <span class="value" id="statePitch">--</span>
861
  </div>
862
  <div class="state-item">
863
+ <label>Roll</label>
864
+ <span class="value" id="stateRoll">--</span>
865
  </div>
866
  <div class="state-item">
867
+ <label>Body</label>
868
+ <span class="value" id="stateBody">--</span>
869
  </div>
870
  <div class="state-item">
871
+ <label>R.Ant</label>
872
+ <span class="value" id="stateRAnt">--</span>
873
  </div>
874
  <div class="state-item">
875
+ <label>L.Ant</label>
876
+ <span class="value" id="stateLAnt">--</span>
877
  </div>
878
  </div>
879
 
880
+ <!-- Robot Selector -->
881
  <div id="robotSelector" class="panel hidden" style="margin-top: 16px;">
882
+ <div class="panel-header">Available Robots</div>
883
  <div class="panel-content">
884
  <div id="robotList" class="robot-list">
885
+ <div style="color: var(--text-muted); font-size: 0.9em;">Searching for robots...</div>
886
  </div>
887
  </div>
888
  </div>
889
+ </div>
890
 
891
+ <!-- Control Sidebar -->
892
+ <div class="control-sidebar">
893
+ <!-- Motors -->
894
+ <div class="panel">
895
+ <div class="panel-header">Motors</div>
896
+ <div class="panel-content">
897
+ <div class="motor-grid">
898
+ <button class="motor-btn on" id="btnMotorOn" onclick="setMotorMode('enabled')" disabled>ON</button>
899
+ <button class="motor-btn off" id="btnMotorOff" onclick="setMotorMode('disabled')" disabled>OFF</button>
900
+ <button class="motor-btn gravity" id="btnMotorGrav" onclick="setMotorMode('gravity_compensation')" disabled>Gravity</button>
901
+ </div>
902
  </div>
903
  </div>
 
904
 
905
+ <!-- Joystick Control -->
 
 
906
  <div class="panel">
907
+ <div class="panel-header">Position Control (Relative)</div>
908
  <div class="panel-content">
909
+ <div class="joystick-container">
910
+ <div class="joystick-area" id="joystick">
911
+ <div class="joystick-knob" id="joystickKnob"></div>
912
+ <span class="joystick-labels top">Pitch +</span>
913
+ <span class="joystick-labels bottom">Pitch -</span>
914
+ <span class="joystick-labels left">Yaw +</span>
915
+ <span class="joystick-labels right">Yaw -</span>
916
+ </div>
917
+ <div class="z-slider-container">
918
+ <span class="z-label">Roll +</span>
919
+ <input type="range" class="z-slider" id="rollJoystick" min="-100" max="100" value="0">
920
+ <span class="z-label">Roll -</span>
921
+ </div>
922
+ </div>
923
+ <div style="text-align: center; margin-top: 12px; font-size: 0.8em; color: var(--text-muted);">
924
+ Drag joystick to move. Release to stop.
925
  </div>
926
  </div>
927
  </div>
928
 
929
+ <!-- Orientation Sliders (Absolute) -->
930
  <div class="panel">
931
+ <div class="panel-header">Head Orientation (Absolute)</div>
932
  <div class="panel-content">
933
+ <div class="slider-group">
934
+ <div class="slider-header">
935
+ <span class="slider-label">Yaw (left/right)</span>
936
+ <span class="slider-value" id="yawValue">0°</span>
 
 
 
 
 
 
937
  </div>
938
+ <input type="range" class="slider" id="yawSlider" min="-45" max="45" value="0">
939
  </div>
940
+ <div class="slider-group">
941
+ <div class="slider-header">
942
+ <span class="slider-label">Pitch (up/down)</span>
943
+ <span class="slider-value" id="pitchValue">0°</span>
 
944
  </div>
945
+ <input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0">
946
  </div>
947
+ <div class="slider-group">
948
+ <div class="slider-header">
949
+ <span class="slider-label">Roll (tilt)</span>
950
+ <span class="slider-value" id="rollValue">0°</span>
951
+ </div>
952
+ <input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0">
953
  </div>
954
+ <div class="slider-group">
955
+ <div class="slider-header">
956
+ <span class="slider-label">Body Yaw</span>
957
+ <span class="slider-value" id="bodyValue">0°</span>
958
+ </div>
959
+ <input type="range" class="slider" id="bodySlider" min="-45" max="45" value="0">
 
960
  </div>
961
  </div>
962
  </div>
963
 
964
  <!-- Antennas -->
965
  <div class="panel">
966
+ <div class="panel-header">Antennas</div>
967
  <div class="panel-content">
968
+ <div class="slider-group">
969
+ <div class="slider-header">
970
+ <span class="slider-label">Right Antenna</span>
971
+ <span class="slider-value" id="rightAntValue">0°</span>
 
 
 
 
 
 
972
  </div>
973
+ <input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0">
974
  </div>
975
+ <div class="slider-group">
976
+ <div class="slider-header">
977
+ <span class="slider-label">Left Antenna</span>
978
+ <span class="slider-value" id="leftAntValue">0°</span>
979
+ </div>
980
+ <input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0">
981
  </div>
982
  </div>
983
  </div>
984
 
985
  <!-- Animations -->
986
  <div class="panel">
987
+ <div class="panel-header">Animations</div>
988
  <div class="panel-content">
989
+ <div class="action-grid">
990
+ <button class="action-btn" id="btnWakeUp" onclick="wakeUp()" disabled>Wake Up</button>
991
+ <button class="action-btn" id="btnSleep" onclick="goToSleep()" disabled>Sleep</button>
992
  </div>
993
  </div>
994
  </div>
995
 
996
+ <!-- Sound & Speak -->
997
  <div class="panel">
998
+ <div class="panel-header">Sound & Speak</div>
999
  <div class="panel-content">
1000
+ <div class="sound-row">
1001
+ <input type="text" class="sound-input" id="soundInput" placeholder="Sound file...">
1002
+ <button class="btn btn-primary" id="btnPlaySound" onclick="playSound()" disabled style="padding: 10px 14px;">Play</button>
1003
  </div>
1004
  <div class="sound-presets">
1005
+ <span class="preset-chip" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
1006
+ <span class="preset-chip" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
1007
+ <span class="preset-chip" onclick="playSoundPreset('yes.wav')">yes</span>
1008
+ <span class="preset-chip" onclick="playSoundPreset('no.wav')">no</span>
1009
+ </div>
1010
+
1011
+ <div class="speak-section">
1012
+ <label class="speak-label">Voice Chat (Telephone Mode)</label>
1013
+ <div class="speak-row">
1014
+ <button class="btn btn-primary" id="btnMic" onclick="toggleMicrophone()" style="flex: 1;">Enable Mic</button>
1015
+ <button class="btn btn-secondary" id="btnMute" onclick="toggleMute()" style="flex: 1;">Unmute Robot</button>
1016
+ </div>
1017
+ <div id="micStatus" style="margin-top: 8px; font-size: 0.8em; color: var(--text-muted); text-align: center;"></div>
1018
  </div>
1019
  </div>
1020
  </div>
1021
 
1022
  <!-- Recording -->
1023
  <div class="panel">
1024
+ <div class="panel-header">Recording</div>
1025
  <div class="panel-content">
1026
+ <div class="action-grid">
1027
+ <button class="action-btn" id="btnStartRec" onclick="startRecording()" disabled>Start Rec</button>
1028
+ <button class="action-btn" id="btnStopRec" onclick="stopRecording()" disabled>Stop Rec</button>
1029
  </div>
1030
  </div>
1031
  </div>
 
1047
  let userToken = null;
1048
  let currentUser = null;
1049
  let sseAbortController = null;
 
1050
  let stateRefreshInterval = null;
1051
 
1052
+ // Robot state (from get_state)
1053
+ let robotState = {
1054
+ motorMode: null,
1055
+ yaw: 0,
1056
+ pitch: 0,
1057
+ roll: 0,
1058
+ bodyYaw: 0,
1059
+ rightAntenna: 0,
1060
+ leftAntenna: 0,
1061
+ isRecording: false
1062
+ };
1063
+
1064
+ // Slider update flags
1065
+ let userDragging = {
1066
+ yaw: false,
1067
+ pitch: false,
1068
+ roll: false,
1069
+ body: false,
1070
+ rightAnt: false,
1071
+ leftAnt: false
1072
+ };
1073
+
1074
+ // Joystick state
1075
+ let joystickActive = false;
1076
+ let joystickCenter = { x: 0, y: 0 };
1077
+ let joystickInterval = null;
1078
+
1079
+ // Audio state
1080
+ let localStream = null;
1081
+ let micEnabled = false;
1082
+ let robotMuted = true;
1083
+ let audioSender = null;
1084
+
1085
  // Export functions
1086
  window.loginToHuggingFace = loginToHuggingFace;
1087
  window.logout = logout;
 
1089
  window.startStream = startStream;
1090
  window.stopStream = stopStream;
1091
  window.setMotorMode = setMotorMode;
 
 
 
 
1092
  window.wakeUp = wakeUp;
1093
  window.goToSleep = goToSleep;
1094
  window.playSound = playSound;
1095
  window.playSoundPreset = playSoundPreset;
1096
+ window.toggleMicrophone = toggleMicrophone;
1097
+ window.toggleMute = toggleMute;
1098
  window.startRecording = startRecording;
1099
  window.stopRecording = stopRecording;
 
 
1100
 
1101
  // Init
1102
+ document.addEventListener('DOMContentLoaded', () => {
1103
+ initAuth();
1104
+ initJoystick();
1105
+ initSliders();
1106
+ });
1107
 
1108
+ // ===================== Auth =====================
1109
  async function initAuth() {
1110
  try {
1111
  const oauthResult = await oauthHandleRedirectIfPresent();
 
1156
  document.getElementById('loginView').classList.add('hidden');
1157
  document.getElementById('mainApp').classList.remove('hidden');
1158
  document.getElementById('username').textContent = '@' + currentUser;
 
 
 
 
 
 
 
 
 
 
 
1159
  }
1160
 
1161
+ // ===================== Connection =====================
 
 
 
 
1162
  function updateStatus(status, text) {
1163
+ const indicator = document.getElementById('statusIndicator');
1164
+ const textEl = document.getElementById('statusText');
1165
+ indicator.className = 'status-indicator ' + status;
1166
+ textEl.textContent = text;
1167
  }
1168
 
 
1169
  async function sendToServer(message) {
1170
  try {
1171
  const res = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, {
 
1175
  });
1176
  return await res.json();
1177
  } catch (e) {
1178
+ console.error('Send error:', e);
1179
  return null;
1180
  }
1181
  }
1182
 
 
1183
  function sendCommand(cmd) {
1184
+ if (!dataChannel || dataChannel.readyState !== 'open') return false;
 
 
 
1185
  dataChannel.send(JSON.stringify(cmd));
1186
  return true;
1187
  }
1188
 
 
1189
  async function connectSignaling() {
1190
  if (!userToken) return;
1191
 
1192
  updateStatus('connecting', 'Connecting...');
 
1193
  document.getElementById('connectBtn').disabled = true;
1194
 
1195
  sseAbortController = new AbortController();
 
1202
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1203
 
1204
  updateStatus('connected', 'Server connected');
 
1205
  document.getElementById('robotSelector').classList.remove('hidden');
1206
 
1207
  const reader = res.body.getReader();
 
1229
  }
1230
  } catch (e) {
1231
  if (e.name !== 'AbortError') {
1232
+ console.error('Connection failed:', e);
1233
  }
1234
  updateStatus('', 'Disconnected');
1235
  document.getElementById('connectBtn').disabled = false;
 
1262
  case 'peer':
1263
  handlePeerMessage(msg);
1264
  break;
 
 
 
1265
  }
1266
  }
1267
 
 
1270
  list.innerHTML = '';
1271
 
1272
  if (!robots?.length) {
1273
+ list.innerHTML = '<div style="color: var(--text-muted); font-size: 0.9em;">No robots online.</div>';
1274
  document.getElementById('startBtn').disabled = true;
1275
  return;
1276
  }
1277
 
1278
  for (const robot of robots) {
1279
  const div = document.createElement('div');
1280
+ div.className = 'robot-card' + (robot.id === selectedProducerId ? ' selected' : '');
1281
  div.innerHTML = `
1282
  <div class="name">${robot.meta?.name || 'Reachy Mini'}</div>
1283
+ <div class="id">${robot.id.slice(0, 12)}...</div>
1284
  `;
1285
  div.onclick = () => selectRobot(robot, div);
1286
  list.appendChild(div);
 
1288
  }
1289
 
1290
  function selectRobot(robot, el) {
1291
+ document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected'));
1292
  el.classList.add('selected');
1293
  selectedProducerId = robot.id;
1294
  document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini';
1295
  document.getElementById('startBtn').disabled = false;
 
1296
  }
1297
 
1298
+ // ===================== WebRTC =====================
1299
  async function startStream() {
1300
  if (!selectedProducerId) return;
1301
 
 
1302
  updateStatus('connecting', 'Connecting to robot...');
1303
 
1304
  peerConnection = new RTCPeerConnection({
 
1306
  });
1307
 
1308
  peerConnection.ontrack = (e) => {
1309
+ console.log('Received track:', e.track.kind);
1310
  if (e.track.kind === 'video') {
1311
  document.getElementById('remoteVideo').srcObject = e.streams[0];
1312
+ }
1313
+ if (e.track.kind === 'audio') {
1314
+ // Robot audio - connect to audio element
1315
+ const audioEl = document.getElementById('remoteAudio');
1316
+ audioEl.srcObject = new MediaStream([e.track]);
1317
+ audioEl.muted = robotMuted;
1318
+ updateMuteButton();
1319
+ console.log('Robot audio track connected');
1320
  }
1321
  };
1322
 
 
1336
  updateStatus('connected', 'Connected');
1337
  enableControls(true);
1338
  document.getElementById('robotSelector').classList.add('hidden');
1339
+ stateRefreshInterval = setInterval(() => sendCommand({ get_state: true }), 500);
1340
+
1341
+ // If mic was already enabled, attach it to the sender
1342
+ if (micEnabled && localStream && audioSender) {
1343
+ const audioTrack = localStream.getAudioTracks()[0];
1344
+ if (audioTrack) {
1345
+ audioSender.replaceTrack(audioTrack);
1346
+ console.log('Attached existing mic to audio sender');
1347
+ }
1348
+ }
1349
  } else if (state === 'failed' || state === 'disconnected') {
1350
  updateStatus('', 'Connection lost');
 
1351
  }
1352
  };
1353
 
1354
  peerConnection.ondatachannel = (e) => {
1355
  dataChannel = e.channel;
1356
  dataChannel.onopen = () => {
 
1357
  sendCommand({ get_state: true });
1358
  };
1359
  dataChannel.onmessage = (e) => handleRobotMessage(JSON.parse(e.data));
 
1372
  if (msg.sdp) {
1373
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
1374
  if (msg.sdp.type === 'offer') {
1375
+ // Add a transceiver for sending audio (telephone mode)
1376
+ // This ensures audio is negotiated in the SDP
1377
+ const transceiver = peerConnection.addTransceiver('audio', {
1378
+ direction: 'sendonly'
1379
+ });
1380
+ audioSender = transceiver.sender;
1381
+ console.log('Added audio transceiver for sending');
1382
+
1383
  const answer = await peerConnection.createAnswer();
1384
  await peerConnection.setLocalDescription(answer);
1385
  await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
 
1389
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
1390
  }
1391
  } catch (e) {
1392
+ console.error('WebRTC error:', e);
1393
  }
1394
  }
1395
 
1396
  function stopStream() {
1397
  if (stateRefreshInterval) clearInterval(stateRefreshInterval);
1398
+ if (joystickInterval) clearInterval(joystickInterval);
1399
  if (peerConnection) peerConnection.close();
1400
  if (dataChannel) dataChannel.close();
1401
  peerConnection = null;
1402
  dataChannel = null;
1403
  currentSessionId = null;
1404
+ audioSender = null;
1405
  document.getElementById('remoteVideo').srcObject = null;
1406
+ document.getElementById('remoteAudio').srcObject = null;
1407
  document.getElementById('startBtn').disabled = !selectedProducerId;
1408
  document.getElementById('stopBtn').disabled = true;
1409
  document.getElementById('robotSelector').classList.remove('hidden');
1410
  enableControls(false);
1411
  updateStatus('connected', 'Server connected');
1412
+ document.getElementById('micStatus').textContent = '';
1413
  }
1414
 
1415
  function enableControls(enabled) {
1416
+ const btns = ['btnMotorOn', 'btnMotorOff', 'btnMotorGrav', 'btnWakeUp', 'btnSleep', 'btnPlaySound', 'btnStartRec', 'btnStopRec'];
 
 
1417
  btns.forEach(id => document.getElementById(id).disabled = !enabled);
1418
  }
1419
 
1420
+ // ===================== Robot Messages =====================
1421
  function handleRobotMessage(data) {
1422
  if (data.state) {
1423
+ updateRobotState(data.state);
1424
  } else if (data.motor_mode) {
1425
+ robotState.motorMode = data.motor_mode;
1426
+ updateMotorButtons(data.motor_mode);
1427
  document.getElementById('stateMotors').textContent = data.motor_mode;
1428
  } else if (data.error) {
1429
+ console.error('Robot error:', data.error);
 
 
1430
  }
1431
  }
1432
 
1433
+ function updateRobotState(state) {
1434
  if (state.motor_mode) {
1435
+ robotState.motorMode = state.motor_mode;
1436
  document.getElementById('stateMotors').textContent = state.motor_mode;
1437
+ updateMotorButtons(state.motor_mode);
1438
  }
1439
+
1440
  if (state.head_pose) {
 
1441
  const m = state.head_pose;
1442
+ // Extract yaw, pitch, roll from rotation matrix
1443
  const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
1444
  const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
1445
+ const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI;
1446
+
1447
+ robotState.yaw = yaw;
1448
+ robotState.pitch = pitch;
1449
+ robotState.roll = roll;
1450
+
1451
  document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
1452
  document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
1453
+ document.getElementById('stateRoll').textContent = roll.toFixed(1) + '°';
1454
+
1455
+ // Update sliders if user isn't dragging
1456
+ if (!userDragging.yaw) {
1457
+ document.getElementById('yawSlider').value = yaw;
1458
+ document.getElementById('yawValue').textContent = yaw.toFixed(0) + '��';
1459
+ }
1460
+ if (!userDragging.pitch) {
1461
+ document.getElementById('pitchSlider').value = pitch;
1462
+ document.getElementById('pitchValue').textContent = pitch.toFixed(0) + '°';
1463
+ }
1464
+ if (!userDragging.roll) {
1465
+ document.getElementById('rollSlider').value = roll;
1466
+ document.getElementById('rollValue').textContent = roll.toFixed(0) + '°';
1467
+ }
1468
  }
1469
+
1470
  if (state.body_yaw !== undefined) {
1471
+ const bodyDeg = state.body_yaw * 180 / Math.PI;
1472
+ robotState.bodyYaw = bodyDeg;
1473
+ document.getElementById('stateBody').textContent = bodyDeg.toFixed(1) + '°';
1474
+
1475
+ if (!userDragging.body) {
1476
+ document.getElementById('bodySlider').value = bodyDeg;
1477
+ document.getElementById('bodyValue').textContent = bodyDeg.toFixed(0) + '°';
1478
+ }
1479
  }
1480
+
1481
  if (state.antennas) {
1482
+ const rightDeg = state.antennas[0] * 180 / Math.PI;
1483
+ const leftDeg = state.antennas[1] * 180 / Math.PI;
1484
+ robotState.rightAntenna = rightDeg;
1485
+ robotState.leftAntenna = leftDeg;
1486
+
1487
+ document.getElementById('stateRAnt').textContent = rightDeg.toFixed(0) + '°';
1488
+ document.getElementById('stateLAnt').textContent = leftDeg.toFixed(0) + '°';
1489
+
1490
+ if (!userDragging.rightAnt) {
1491
+ document.getElementById('rightAntSlider').value = rightDeg;
1492
+ document.getElementById('rightAntValue').textContent = rightDeg.toFixed(0) + '°';
1493
+ }
1494
+ if (!userDragging.leftAnt) {
1495
+ document.getElementById('leftAntSlider').value = leftDeg;
1496
+ document.getElementById('leftAntValue').textContent = leftDeg.toFixed(0) + '°';
1497
+ }
1498
  }
1499
+
1500
  if (state.is_recording !== undefined) {
1501
+ robotState.isRecording = state.is_recording;
1502
  document.getElementById('btnStartRec').classList.toggle('recording', state.is_recording);
1503
  }
1504
  }
1505
 
1506
+ function updateMotorButtons(mode) {
1507
+ document.getElementById('btnMotorOn').classList.toggle('active', mode === 'enabled');
1508
+ document.getElementById('btnMotorOff').classList.toggle('active', mode === 'disabled');
1509
+ document.getElementById('btnMotorGrav').classList.toggle('active', mode === 'gravity_compensation');
1510
+ }
1511
+
1512
+ // ===================== Joystick =====================
1513
+ function initJoystick() {
1514
+ const joystick = document.getElementById('joystick');
1515
+ const knob = document.getElementById('joystickKnob');
1516
+ const rollSlider = document.getElementById('rollJoystick');
1517
+
1518
+ const getPos = (e) => {
1519
+ const rect = joystick.getBoundingClientRect();
1520
+ const centerX = rect.width / 2;
1521
+ const centerY = rect.height / 2;
1522
+ const touch = e.touches ? e.touches[0] : e;
1523
+ let x = touch.clientX - rect.left - centerX;
1524
+ let y = touch.clientY - rect.top - centerY;
1525
+ const maxRadius = centerX - 25;
1526
+ const dist = Math.sqrt(x * x + y * y);
1527
+ if (dist > maxRadius) {
1528
+ x = (x / dist) * maxRadius;
1529
+ y = (y / dist) * maxRadius;
1530
+ }
1531
+ return { x, y, normX: x / maxRadius, normY: y / maxRadius };
1532
+ };
1533
+
1534
+ const startJoystick = (e) => {
1535
+ e.preventDefault();
1536
+ joystickActive = true;
1537
+ const pos = getPos(e);
1538
+ updateKnob(pos);
1539
+ startJoystickMovement();
1540
+ };
1541
+
1542
+ const moveJoystick = (e) => {
1543
+ if (!joystickActive) return;
1544
+ e.preventDefault();
1545
+ const pos = getPos(e);
1546
+ updateKnob(pos);
1547
+ joystickCenter = { x: pos.normX, y: pos.normY };
1548
+ };
1549
+
1550
+ const endJoystick = () => {
1551
+ joystickActive = false;
1552
+ knob.style.left = '50%';
1553
+ knob.style.top = '50%';
1554
+ joystickCenter = { x: 0, y: 0 };
1555
+ stopJoystickMovement();
1556
+ };
1557
+
1558
+ const updateKnob = (pos) => {
1559
+ const rect = joystick.getBoundingClientRect();
1560
+ knob.style.left = (rect.width / 2 + pos.x) + 'px';
1561
+ knob.style.top = (rect.height / 2 + pos.y) + 'px';
1562
+ joystickCenter = { x: pos.normX, y: pos.normY };
1563
+ };
1564
+
1565
+ joystick.addEventListener('mousedown', startJoystick);
1566
+ joystick.addEventListener('touchstart', startJoystick, { passive: false });
1567
+ document.addEventListener('mousemove', moveJoystick);
1568
+ document.addEventListener('touchmove', moveJoystick, { passive: false });
1569
+ document.addEventListener('mouseup', endJoystick);
1570
+ document.addEventListener('touchend', endJoystick);
1571
+
1572
+ // Roll slider
1573
+ rollSlider.addEventListener('input', () => {
1574
+ if (joystickActive || rollSlider.value != 0) {
1575
+ // Apply roll delta while dragging
1576
+ }
1577
  });
 
 
 
1578
 
1579
+ rollSlider.addEventListener('change', () => {
1580
+ rollSlider.value = 0;
1581
+ });
 
 
1582
  }
1583
 
1584
+ function startJoystickMovement() {
1585
+ if (joystickInterval) return;
 
 
 
 
1586
 
1587
+ joystickInterval = setInterval(() => {
1588
+ if (!joystickActive && joystickCenter.x === 0 && joystickCenter.y === 0) return;
1589
+
1590
+ const speed = 2; // degrees per tick
1591
+ const rollSlider = document.getElementById('rollJoystick');
1592
+ const rollDelta = (parseFloat(rollSlider.value) / 100) * speed;
1593
+
1594
+ // X controls Yaw (inverted: left = positive yaw)
1595
+ // Y controls Pitch (inverted: up = positive pitch)
1596
+ const yawDelta = -joystickCenter.x * speed;
1597
+ const pitchDelta = -joystickCenter.y * speed;
1598
+
1599
+ // Calculate new absolute positions
1600
+ let newYaw = robotState.yaw + yawDelta;
1601
+ let newPitch = robotState.pitch + pitchDelta;
1602
+ let newRoll = robotState.roll + rollDelta;
1603
+
1604
+ // Clamp values
1605
+ newYaw = Math.max(-45, Math.min(45, newYaw));
1606
+ newPitch = Math.max(-30, Math.min(30, newPitch));
1607
+ newRoll = Math.max(-20, Math.min(20, newRoll));
1608
+
1609
+ // Send command
1610
+ const matrix = buildMatrix(newYaw, newPitch, newRoll);
1611
+ sendCommand({ set_target: matrix });
1612
+
1613
+ }, 50); // 20 updates per second
1614
  }
1615
 
1616
+ function stopJoystickMovement() {
1617
+ if (joystickInterval) {
1618
+ clearInterval(joystickInterval);
1619
+ joystickInterval = null;
1620
+ }
1621
  }
1622
 
1623
+ // ===================== Sliders =====================
1624
+ function initSliders() {
1625
+ const sliders = [
1626
+ { id: 'yawSlider', value: 'yawValue', key: 'yaw' },
1627
+ { id: 'pitchSlider', value: 'pitchValue', key: 'pitch' },
1628
+ { id: 'rollSlider', value: 'rollValue', key: 'roll' },
1629
+ { id: 'bodySlider', value: 'bodyValue', key: 'body' },
1630
+ { id: 'rightAntSlider', value: 'rightAntValue', key: 'rightAnt' },
1631
+ { id: 'leftAntSlider', value: 'leftAntValue', key: 'leftAnt' }
1632
+ ];
1633
+
1634
+ sliders.forEach(({ id, value, key }) => {
1635
+ const slider = document.getElementById(id);
1636
+ const valueEl = document.getElementById(value);
1637
+
1638
+ slider.addEventListener('mousedown', () => userDragging[key] = true);
1639
+ slider.addEventListener('touchstart', () => userDragging[key] = true);
1640
 
1641
+ slider.addEventListener('input', () => {
1642
+ valueEl.textContent = slider.value + '°';
1643
+ sendSliderUpdate(key, parseFloat(slider.value));
1644
+ });
1645
+
1646
+ slider.addEventListener('change', () => {
1647
+ userDragging[key] = false;
1648
+ });
1649
+
1650
+ document.addEventListener('mouseup', () => {
1651
+ if (userDragging[key]) userDragging[key] = false;
1652
+ });
1653
+ });
1654
+ }
1655
+
1656
+ function sendSliderUpdate(key, value) {
1657
+ if (key === 'rightAnt' || key === 'leftAnt') {
1658
+ const right = parseFloat(document.getElementById('rightAntSlider').value) * Math.PI / 180;
1659
+ const left = parseFloat(document.getElementById('leftAntSlider').value) * Math.PI / 180;
1660
+ sendCommand({ set_antennas: [right, left] });
1661
+ } else if (key === 'body') {
1662
+ sendCommand({ set_body_yaw: value * Math.PI / 180 });
1663
  } else {
1664
+ // Head orientation
1665
+ const yaw = parseFloat(document.getElementById('yawSlider').value);
1666
+ const pitch = parseFloat(document.getElementById('pitchSlider').value);
1667
+ const roll = parseFloat(document.getElementById('rollSlider').value);
1668
+ const matrix = buildMatrix(yaw, pitch, roll);
1669
+ sendCommand({ set_target: matrix });
1670
  }
1671
  }
1672
 
1673
+ // ===================== Matrix Builder =====================
1674
+ function buildMatrix(yawDeg, pitchDeg, rollDeg = 0) {
1675
+ const y = yawDeg * Math.PI / 180;
1676
+ const p = pitchDeg * Math.PI / 180;
1677
+ const r = rollDeg * Math.PI / 180;
 
1678
 
1679
+ const cy = Math.cos(y), sy = Math.sin(y);
1680
+ const cp = Math.cos(p), sp = Math.sin(p);
1681
+ const cr = Math.cos(r), sr = Math.sin(r);
1682
+
1683
+ // Rotation matrix: Rz(yaw) * Ry(pitch) * Rx(roll)
1684
+ return [
1685
+ [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, 0],
1686
+ [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, 0],
1687
+ [-sp, cp * sr, cp * cr, 0],
1688
+ [0, 0, 0, 1]
1689
+ ];
1690
  }
1691
 
1692
+ // ===================== Controls =====================
1693
+ function setMotorMode(mode) {
1694
+ sendCommand({ set_motor_mode: mode });
 
 
 
 
1695
  }
1696
 
1697
  function wakeUp() {
1698
  sendCommand({ wake_up: true });
 
1699
  }
1700
 
1701
  function goToSleep() {
1702
  sendCommand({ goto_sleep: true });
 
1703
  }
1704
 
1705
  function playSound() {
1706
  const file = document.getElementById('soundInput').value.trim();
1707
+ if (file) sendCommand({ play_sound: file });
 
 
 
1708
  }
1709
 
1710
  function playSoundPreset(file) {
1711
  document.getElementById('soundInput').value = file;
1712
  sendCommand({ play_sound: file });
1713
+ }
1714
+
1715
+ async function toggleMicrophone() {
1716
+ const btn = document.getElementById('btnMic');
1717
+ const status = document.getElementById('micStatus');
1718
+
1719
+ if (micEnabled) {
1720
+ // Disable mic - replace track with null
1721
+ if (localStream) {
1722
+ localStream.getTracks().forEach(track => track.stop());
1723
+ localStream = null;
1724
+ }
1725
+ if (audioSender) {
1726
+ await audioSender.replaceTrack(null);
1727
+ console.log('Removed audio track from sender');
1728
+ }
1729
+ micEnabled = false;
1730
+ btn.textContent = 'Enable Mic';
1731
+ btn.classList.remove('btn-danger');
1732
+ btn.classList.add('btn-primary');
1733
+ status.textContent = 'Microphone disabled';
1734
+ status.style.color = 'var(--text-muted)';
1735
+ } else {
1736
+ // Enable mic
1737
+ try {
1738
+ localStream = await navigator.mediaDevices.getUserMedia({
1739
+ audio: {
1740
+ echoCancellation: true,
1741
+ noiseSuppression: true,
1742
+ autoGainControl: true
1743
+ }
1744
+ });
1745
+
1746
+ const audioTrack = localStream.getAudioTracks()[0];
1747
+
1748
+ // Replace track on the pre-negotiated sender
1749
+ if (audioSender) {
1750
+ await audioSender.replaceTrack(audioTrack);
1751
+ console.log('Replaced audio track on sender - speaking to robot');
1752
+ } else {
1753
+ console.warn('No audio sender available - connection may not support sending audio');
1754
+ }
1755
+
1756
+ micEnabled = true;
1757
+ btn.textContent = 'Disable Mic';
1758
+ btn.classList.remove('btn-primary');
1759
+ btn.classList.add('btn-danger');
1760
+ status.textContent = 'Microphone active - speaking to robot';
1761
+ status.style.color = 'var(--success)';
1762
+ } catch (err) {
1763
+ console.error('Microphone access denied:', err);
1764
+ status.textContent = 'Microphone access denied';
1765
+ status.style.color = 'var(--danger)';
1766
+ }
1767
+ }
1768
+ }
1769
+
1770
+ function toggleMute() {
1771
+ robotMuted = !robotMuted;
1772
+ const audioEl = document.getElementById('remoteAudio');
1773
+ audioEl.muted = robotMuted;
1774
+ updateMuteButton();
1775
+ }
1776
+
1777
+ function updateMuteButton() {
1778
+ const btn = document.getElementById('btnMute');
1779
+ const status = document.getElementById('micStatus');
1780
+ if (robotMuted) {
1781
+ btn.textContent = 'Unmute Robot';
1782
+ btn.classList.remove('btn-danger');
1783
+ btn.classList.add('btn-secondary');
1784
+ } else {
1785
+ btn.textContent = 'Mute Robot';
1786
+ btn.classList.remove('btn-secondary');
1787
+ btn.classList.add('btn-danger');
1788
+ // Show listening status
1789
+ if (!micEnabled) {
1790
+ status.textContent = 'Listening to robot audio';
1791
+ status.style.color = 'var(--pollen-coral)';
1792
+ }
1793
+ }
1794
  }
1795
 
1796
  function startRecording() {
1797
  sendCommand({ start_recording: true });
 
1798
  }
1799
 
1800
  function stopRecording() {
1801
  sendCommand({ stop_recording: true });
 
1802
  }
1803
  </script>
1804
  </body>