cduss Claude Opus 4.5 commited on
Commit
2882d76
·
1 Parent(s): de75730

Responsive UI rewrite: fix joystick behavior and antenna panel

Browse files

- Rewrite to single-column responsive layout for mobile support
- Fix joystick to continue movement while held (interval-based)
- Fix antenna sliders to show both left and right
- Remove head orientation absolute panel
- Use separate target tracking to prevent robot state interference
- Make joystick responsive with min(250px, 40vw) sizing

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

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