cduss commited on
Commit
bcd5ee8
·
1 Parent(s): c8ef110
Files changed (3) hide show
  1. README.md +39 -10
  2. index.html +192 -392
  3. style.css +2 -90
README.md CHANGED
@@ -7,6 +7,7 @@ sdk: static
7
  pinned: false
8
  hf_oauth: true
9
  hf_oauth_expiration_minutes: 480
 
10
  tags:
11
  - reachy_mini
12
  - reachy_mini_js_app
@@ -14,22 +15,50 @@ tags:
14
 
15
  # Reachy Mini WebRTC Demo
16
 
17
- WebRTC dashboard to connect to your Reachy Mini robot via the central signaling server.
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  ## Features
20
 
21
- - Video streaming from robot camera
22
- - Head control via data channel
23
- - HuggingFace OAuth authentication
 
 
 
 
 
24
 
25
  ## How it works
26
 
27
- 1. Sign in with your HuggingFace account
28
- 2. Connect to the central signaling server
29
- 3. Select your robot from the list
30
- 4. Start the video stream
 
 
31
 
32
  ## Requirements
33
 
34
- - Robot must be running with WebRTC enabled and connected to the central signaling server
35
- - Robot must have a valid HuggingFace token configured
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  hf_oauth: true
9
  hf_oauth_expiration_minutes: 480
10
+ short_description: Single-file WebRTC demo — sign in with HF, drive the robot.
11
  tags:
12
  - reachy_mini
13
  - reachy_mini_js_app
 
15
 
16
  # Reachy Mini WebRTC Demo
17
 
18
+ Single-file (`index.html` + `style.css`) WebRTC demo for Reachy Mini.
19
+ No build step, no bundler — drop it on a Hugging Face Space with
20
+ `sdk: static` and you're done.
21
+
22
+ Boots on the modern **host-shell** pattern documented in
23
+ [`ts/APP_CREATION_GUIDE.md`](https://github.com/pollen-robotics/reachy_mini/blob/main/ts/APP_CREATION_GUIDE.md):
24
+
25
+ - The host shell (loaded from jsDelivr on a standalone visit) owns
26
+ HF OAuth + robot picker + the connect / leave button. No more
27
+ hand-rolled login screen here.
28
+ - The post-connect UI (sliders, audio, sound, animations, torque,
29
+ volume, daemon logs) lives in this page's markup and runs inside
30
+ the host's iframe via `connectToHost()`. Same features as before.
31
 
32
  ## Features
33
 
34
+ - Video streaming from the robot's camera
35
+ - Head RPY sliders, body yaw (tank-coupled), antennas
36
+ - Wake up / go to sleep / torque toggle
37
+ - Sound playback (free-text + presets)
38
+ - Speaker volume sync
39
+ - Mic + speaker mute toggles
40
+ - Live latency badge (RTT / buffer / jitter, colour-coded)
41
+ - Daemon `journalctl` stream over the WebRTC data channel
42
 
43
  ## How it works
44
 
45
+ 1. Visit the Space the host shell renders sign-in.
46
+ 2. Sign in with Hugging Face (OAuth).
47
+ 3. The shell lists your reachable robots; pick one.
48
+ 4. The shell wakes the robot and iframes this page at
49
+ `?embedded=1`.
50
+ 5. All the controls below light up.
51
 
52
  ## Requirements
53
 
54
+ - Robot online on the central signaling server.
55
+ - Robot daemon recent enough to expose `subscribeLogs`, `getVolume`,
56
+ `wakeUp`, `gotoSleep`, `setMotorMode` over the data channel
57
+ (anything ≥ 1.8 should be fine).
58
+
59
+ ## SDK pin
60
+
61
+ `@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c`, loaded
62
+ from `cdn.jsdelivr.net`. Same exact pin as the three Pollen
63
+ reference apps — mixing versions across the SDK, host shell, and
64
+ daemon causes silent protocol drift (see APP_CREATION_GUIDE §10).
index.html CHANGED
@@ -9,66 +9,83 @@
9
  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
  <link rel="stylesheet" href="style.css">
12
- <!-- Kick off both JS modules during HTML parse so they're in flight
13
- before the bottom-of-body <script> executes. The embed entry
14
- pulls two hashed chunks of its own which we can't preload
15
- (the hashes drift across SDK releases), but front-loading the
16
- two top-level modules still shaves ~hundreds of ms off a cold
17
- CDN edge. -->
18
- <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/+esm" crossorigin>
19
- <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/host/dist/entry/embed.js" crossorigin>
20
- <!-- Inline pre-paint script: if we're loaded as an iframe embed,
21
- flip the DOM directly to the connected-shell view + a
22
- "Connecting…" status pill BEFORE the JS module starts
23
- running. Skips the visual flash of the Sign-in button while
24
- connectToHost is busy doing its OAuth round-trip + WebRTC
25
- handshake (which can be 3-8 s on a cold network). The script
26
- runs after </head> only because the elements it touches are
27
- in <body>; the cost of moving this script to a `defer="false"`
28
- block at the start of <body> is one DOM-ready paint that we
29
- already pay for. -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  <script>
31
  (function () {
32
  try {
33
- const params = new URLSearchParams(window.location.search);
34
- if (params.get('embedded') !== '1') return;
35
- // Defer the DOM mutations until after the elements
36
- // exist (we're parsed before <body>). DOMContentLoaded
37
- // fires once the body is done parsing — cheap, no UX
38
- // cost vs. the seconds-long connectToHost.
39
  document.addEventListener('DOMContentLoaded', function () {
40
- const login = document.getElementById('loginView');
41
- const main = document.getElementById('mainApp');
42
- const statusText = document.getElementById('statusText');
43
- const statusIndicator = document.getElementById('statusIndicator');
44
- if (login) login.classList.add('hidden');
45
- if (main) main.classList.remove('hidden');
46
- if (statusIndicator) statusIndicator.className = 'status-indicator connecting';
47
- if (statusText) statusText.textContent = 'Connecting…';
 
48
  });
49
- } catch (e) {
50
- // Best-effort UX polish — never block boot.
51
- }
52
  })();
53
  </script>
54
  </head>
55
  <body>
56
- <!-- Login View -->
57
- <div id="loginView" class="login-view">
58
- <div class="login-card">
59
- <img class="login-logo" src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
60
- <h2>Reachy Mini</h2>
61
- <p>Sign in with your HuggingFace account to connect and control your robot remotely.</p>
62
- <button class="btn-hf" onclick="loginToHuggingFace()">
63
- <svg width="18" height="18" viewBox="0 0 95 88" fill="currentColor">
64
- <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"/>
65
- </svg>
66
- Sign in with Hugging Face
67
- </button>
68
- </div>
69
- </div>
70
-
71
- <!-- Main App -->
72
  <div id="mainApp" class="hidden">
73
  <header class="header">
74
  <div class="logo">
@@ -77,11 +94,11 @@
77
  </div>
78
  <div class="user-section">
79
  <!-- Daemon version is filled in once the WebRTC data channel
80
- is open (via robot.getVersion()). Hidden until then so
81
- we don't show a stale/empty badge during connect. -->
 
 
82
  <div id="daemonVersion" class="user-badge hidden" title="Daemon version"></div>
83
- <div class="user-badge"><span id="username">@user</span></div>
84
- <button class="btn-logout" onclick="logout()">Sign out</button>
85
  </div>
86
  </header>
87
 
@@ -104,16 +121,13 @@
104
  <div class="video-overlay-bottom">
105
  <div class="video-controls">
106
  <!--
107
- Connect button drives the all-in-one autoConnect()
108
- flow: SSE robot selection (auto-pick if one free,
109
- else pickRobot callback opens the panel) →
110
- startSession ensureAwake. The legacy two-step
111
- Connect+Start split is gone; Start is kept hidden
112
- for any consumer still wiring against #startBtn.
113
  -->
114
- <button class="btn btn-primary" id="connectBtn" onclick="connectAndStream()">Connect</button>
115
- <button class="btn btn-primary hidden" id="startBtn" onclick="startStream()" disabled>Start</button>
116
- <button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Stop</button>
117
  <button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled>
118
  <svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
119
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
@@ -147,15 +161,9 @@
147
  </div>
148
 
149
 
150
- <!-- Robot Selector -->
151
- <div id="robotSelector" class="panel hidden">
152
- <div class="panel-header">Available Robots</div>
153
- <div class="panel-content">
154
- <div id="robotList" class="robot-list">
155
- <div style="color: var(--text-muted); font-size: 0.85em;">Searching...</div>
156
- </div>
157
- </div>
158
- </div>
159
 
160
  <!-- Head Control - RPY Sliders -->
161
  <div class="panel">
@@ -275,97 +283,51 @@
275
  </div>
276
 
277
  <script type="module">
278
- // Both imports below pull from the same npm package version:
279
- // @pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe.
280
- //
281
- // The package exports:
282
- // - `.` (root) → reachy-mini-sdk.js (the SDK runtime:
283
- // ReachyMini class,
284
- // matrix/RPY helpers,
285
- // autoConnect, etc.)
286
- // - `./host/embed` → host/dist/entry/embed.js (the embed-
287
- // side helper that
288
- // decodes `#creds=`
289
- // and runs auth +
290
- // startSession +
291
- // ensureAwake)
292
  //
293
- // The 1.7.3 stable tag publishes ONLY the root (no `host/` subpath
294
- // yet), so we use the `1.7.3-main.<sha>` prerelease auto-published
295
- // by CI on every push to main. Pin to a specific `<sha>` (the
296
- // mobile app pins the same in lockstep). Bump when a stable that
297
- // ships `host/` lands on npm.
298
  //
299
- // SDK URL ends with `/+esm` (a jsdelivr-specific endpoint) rather
300
- // than the literal `dist/reachy-mini-sdk.js`. `/+esm` runs a Rollup
301
- // + Terser pass on jsdelivr's edge that inlines the SDK's bare
302
- // npm dependencies (currently `@huggingface/hub` for OAuth) into a
303
- // single self-contained ESM bundle. Without it, the browser fails
304
- // on `import "@huggingface/hub"` (bare specifiers don't resolve in
305
- // browser-direct ESM without a bundler or import map). The host
306
- // bundle is already Vite-built and self-contained, so it points
307
- // at the raw `host/dist/entry/embed.js` path directly.
308
  //
309
- // jsdelivr serves npm versions directly no manual purge needed
310
- // when bumping the pin (npm tarballs are immutable, the CDN edge
311
- // refreshes naturally for each new version string).
312
- import { ReachyMini, matrixToRpy, radToDeg, rpyToMatrix, degToRad } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/+esm";
313
- // Host-embed entry. Loading this module side-effect-installs
314
- // `window.ReachyMini` (if unset) and exposes `connectToHost`,
315
- // which decodes the `#creds=<base64>` bundle the mobile shell
316
- // writes into the URL hash, instantiates the SDK against those
317
- // creds, and runs auth + startSession + ensureAwake before
318
- // resolving. Standalone visits never touch it the dispatcher
319
- // below branches on `?embedded=1`.
320
- import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.7.3-main.7654ffe/host/dist/entry/embed.js";
321
-
322
- // `let` (not `const`) because the embed path below replaces this
323
- // module-level SDK with the live instance returned by
324
- // connectToHost(). The pre-constructed object is harmless: its
325
- // ctor doesn't open sockets, so dropping it on the floor in
326
- // embed mode is fine.
327
- let robot = new ReachyMini({
328
- appName: "Reachy Mini WebRTC Demo",
329
- // Pin the org-owned canonical central. The SDK default
330
- // still tracks an older instance, so we keep this
331
- // explicit for self-documentation.
332
- signalingUrl: 'https://pollen-robotics-reachy-mini-central.hf.space',
333
- // NB: `autoStartFromUrl: true` is intentionally NOT set.
334
- // We use `autoConnect()` for embed bring-up; both running
335
- // at once raced two `startSession()` calls against the
336
- // same preselected robot, with central rejecting the
337
- // second one as "Robot is busy: <our own appName>". The
338
- // SDK now defensively suppresses `autoStartFromUrl` while
339
- // `autoConnect()` is in-flight, so this is just docs and
340
- // belt-and-suspenders.
341
- });
342
-
343
- let selectedRobotId = null;
344
  let headSlidersActive = false;
345
  let bodyYawSliderActive = false;
346
- // Last body yaw the user committed via the slider, in degrees.
347
- // Used to compute the delta we need to subtract from the head yaw
348
- // command so the head visually fixates a point in world frame
349
- // while the base rotates.
 
350
  let lastBodyYawDeg = 0;
351
  let detachVideo = null;
352
  let latencyIntervalId = null;
353
- // Returned by robot.subscribeLogs(); call to stop the daemon-side
354
- // journalctl stream. Reset on every sessionStopped.
355
  let logsUnsub = null;
356
  // Cap the logs panel so a long session doesn't grow without bound.
357
  const LOGS_MAX_LINES = 500;
358
 
359
- // Export functions for inline onclick handlers
360
- window.loginToHuggingFace = () => robot.login();
361
- window.logout = logout;
362
- window.connectAndStream = connectAndStream;
363
- // Kept exported for any deep-link / debugger script that still
364
- // pokes them by name — the in-app buttons now route through
365
- // connectAndStream() instead.
366
- window.connectSignaling = connectAndStream;
367
- window.startStream = connectAndStream;
368
- window.stopStream = stopStream;
369
  window.playSound = playSound;
370
  window.playSoundPreset = playSoundPreset;
371
  window.playWakeUp = playWakeUp;
@@ -375,126 +337,94 @@
375
  window.toggleMic = toggleMic;
376
  window.clearLogs = clearLogs;
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  document.addEventListener('DOMContentLoaded', async () => {
379
- // Dispatcher: `?embedded=1` means we're loaded inside the
380
- // mobile-app shell's iframe. The shell passes credentials
381
- // in the URL hash as a base64 `creds=` bundle (token +
382
- // username + robotPeerId + signalingUrl + theme + config).
383
- // We hand control to `connectToHost`, which decodes that
384
- // bundle and brings the WebRTC session up before resolving.
385
- // Standalone visits fall through to the existing
386
- // authenticate() → user-driven Connect flow.
387
- const isEmbed =
388
- new URLSearchParams(window.location.search).get('embedded') === '1';
389
-
390
- if (isEmbed) {
391
- try {
392
- const handle = await connectToHost();
393
- // Swap the module-level SDK for the live instance
394
- // the host handed back. All closures below that
395
- // call `robot.*` resolve `robot` at call time, so
396
- // they pick up the new value automatically.
397
- robot = handle.reachy;
398
- // The host attached the inbound track on the
399
- // handle's media surface; replay it into our <video>.
400
- handle.media.attachVideo(document.getElementById('remoteVideo'));
401
- initSliders();
402
- initRobotEvents();
403
- showMainApp();
404
- // The session is already up. Flip the UI directly
405
- // to the "connected" visual state — same set of
406
- // toggles the `streaming` event handler runs in
407
- // the standalone flow.
408
- document.getElementById('robotSelector').classList.add('hidden');
409
- updateStatus('connected', 'Connected');
410
- enableControls(true);
411
- document.getElementById('connectBtn').disabled = true;
412
- document.getElementById('stopBtn').disabled = false;
413
- startLatencyDisplay();
414
- fetchDaemonVersionOnce();
415
- syncVolumeSlider();
416
- startLogsStream();
417
- } catch (err) {
418
- console.error('[embed] connectToHost failed:', err);
419
- updateStatus(
420
- '',
421
- 'Embed connect failed: ' + (err?.message || err),
422
- );
423
- }
424
  return;
425
  }
426
 
427
- // Standalone path — unchanged behaviour. Bind the <video>
428
- // sink eagerly (attachVideo is lazy: it remembers the
429
- // element and wires the inbound track when it arrives),
430
- // then run authenticate(). Falls through to the login
431
- // screen when no cached token is found.
432
- initSliders();
433
- initRobotEvents();
434
- detachVideo = robot.attachVideo(document.getElementById('remoteVideo'));
435
- if (await robot.authenticate()) {
 
 
 
 
 
436
  showMainApp();
437
- } else {
438
- showLogin();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  }
440
  });
441
 
442
- // ===================== Auth =====================
443
- function logout() {
444
- if (detachVideo) { detachVideo(); detachVideo = null; }
445
- robot.logout();
446
- showLogin();
447
- }
448
-
449
- function showLogin() {
450
- document.getElementById('loginView').classList.remove('hidden');
451
- document.getElementById('mainApp').classList.add('hidden');
452
- }
453
-
454
  function showMainApp() {
455
- document.getElementById('loginView').classList.add('hidden');
456
  document.getElementById('mainApp').classList.remove('hidden');
457
- document.getElementById('username').textContent = '@' + robot.username;
458
  }
459
 
460
  // ===================== Robot Events =====================
 
 
 
 
 
461
  function initRobotEvents() {
462
- // The SSE `list` push fires on every producer come-and-go.
463
- // We don't drive selection from it any more — autoConnect's
464
- // pickRobot callback owns the picker render — but we still
465
- // dim cards whose robot dropped off while the panel is open.
466
- robot.addEventListener('robotsChanged', (e) => refreshPickerOnList(e.detail.robots));
467
-
468
- robot.addEventListener('streaming', () => {
469
- updateStatus('connected', 'Connected');
470
- enableControls(true);
471
- document.getElementById('connectBtn').disabled = true;
472
- document.getElementById('stopBtn').disabled = false;
473
- document.getElementById('robotSelector').classList.add('hidden');
474
- startLatencyDisplay();
475
- // Data channel is open — fetch daemon version (one-shot) and
476
- // sync the volume slider to the robot's current level. Both
477
- // swallow failures so a missing command handler or an
478
- // unsupported audio platform doesn't break the UI.
479
- // Wake/torque is already handled inside autoConnect() —
480
- // no ensureAwakeOnce() call needed here.
481
- fetchDaemonVersionOnce();
482
- syncVolumeSlider();
483
- startLogsStream();
484
- });
485
-
486
  robot.addEventListener('sessionStopped', (e) => {
487
- document.getElementById('connectBtn').disabled = false;
488
- document.getElementById('stopBtn').disabled = true;
489
- document.getElementById('robotSelector').classList.remove('hidden');
490
  enableControls(false);
491
- // e.detail.message is set when the stop was server-initiated (e.g.
492
- // a local Python app took over the robot). null for user-initiated.
493
- updateStatus('connected', e.detail?.message || 'Connected');
 
 
494
  updateMicButton();
495
  stopLatencyDisplay();
496
- // Disable the volume slider and clear the version pill — they
497
- // only make sense while the data channel is open.
498
  document.getElementById('volumeSlider').disabled = true;
499
  document.getElementById('volumeValue').textContent = '--';
500
  document.getElementById('daemonVersion').classList.add('hidden');
@@ -512,12 +442,6 @@
512
  enableControls(robot.state === 'streaming');
513
  });
514
 
515
- robot.addEventListener('disconnected', () => {
516
- updateStatus('', 'Disconnected');
517
- document.getElementById('connectBtn').disabled = false;
518
- document.getElementById('robotSelector').classList.add('hidden');
519
- });
520
-
521
  robot.addEventListener('error', (e) => {
522
  console.error(`[${e.detail.source}]`, e.detail.error);
523
  if (e.detail.source === 'webrtc') {
@@ -692,129 +616,10 @@
692
  });
693
  })();
694
 
695
- // All-in-one: auth (cached) SSE robot selection → session →
696
- // wake. Replaces the previous "Connect pick robot Start"
697
- // three-step flow with a single button.
698
- //
699
- // In standalone mode autoConnect() invokes our `pickRobot`
700
- // callback when there is more than one free robot; we render
701
- // the panel and resolve the Promise on user click.
702
- //
703
- // In embed mode autoConnect detects `isEmbedded` and skips the
704
- // picker entirely — the preselected robot id is used directly.
705
- async function connectAndStream() {
706
- if (!robot.isAuthenticated) return;
707
- if (typeof robot.autoConnect !== 'function') {
708
- console.error(
709
- 'robot.autoConnect is not a function — the reachy-mini.js ' +
710
- 'lib loaded from CDN predates the autoConnect feature. ' +
711
- 'Purge jsdelivr and hard-refresh the page.'
712
- );
713
- updateStatus('', 'SDK too old — purge jsdelivr cache');
714
- return;
715
- }
716
-
717
- updateStatus('connecting', 'Connecting...');
718
- document.getElementById('connectBtn').disabled = true;
719
-
720
- // Re-bind the <video> sink if a previous logout() tore it
721
- // down. attachVideo() is lazy — the SDK remembers the
722
- // element and wires the inbound track when it arrives, so
723
- // calling it before autoConnect() works for both first
724
- // session and post-logout reconnects.
725
- if (!detachVideo) {
726
- detachVideo = robot.attachVideo(document.getElementById('remoteVideo'));
727
- }
728
-
729
- try {
730
- const { robotName } = await robot.autoConnect({
731
- pickRobot: presentRobotPicker,
732
- });
733
- if (robotName) {
734
- document.getElementById('robotName').textContent = robotName;
735
- }
736
- // The `streaming` event handler does the rest (status
737
- // pill, latency display, daemon version, volume sync,
738
- // logs subscription). autoConnect already called
739
- // ensureAwake() before resolving.
740
- } catch (e) {
741
- console.error('autoConnect failed:', e);
742
- // Same error taxonomy as the old startStream():
743
- // "robot_busy" → central refused: another remote JS app is connected.
744
- // "robot_busy_local_app" → relay refused: a local Python app holds the robot.
745
- // "local_app_started" → a local Python app evicted a streaming session.
746
- // "robot_busy_local" → relay safety net; another session slipped in.
747
- let msg;
748
- if (e.reason === 'robot_busy') {
749
- msg = `Robot busy — "${e.activeApp}" is already connected`;
750
- } else if ((e.reason && e.reason.startsWith('robot_busy')) || e.reason === 'local_app_started') {
751
- msg = e.message;
752
- } else {
753
- msg = e.message || String(e);
754
- }
755
- updateStatus('', msg);
756
- document.getElementById('connectBtn').disabled = false;
757
- document.getElementById('robotSelector').classList.add('hidden');
758
- }
759
- }
760
-
761
- // pickRobot callback for autoConnect(). Renders the existing
762
- // robot-selector panel and resolves with the id the user
763
- // clicks. Receives the SDK-shaped robot objects (id, name,
764
- // busy, activeApp, meta, lastSeenAgeSeconds) already filtered
765
- // to "free" and deduped by install_id.
766
- function presentRobotPicker(robots) {
767
- return new Promise((resolve) => {
768
- const panel = document.getElementById('robotSelector');
769
- const list = document.getElementById('robotList');
770
- panel.classList.remove('hidden');
771
- list.innerHTML = '';
772
- if (!robots?.length) {
773
- list.innerHTML = '<div style="color: var(--text-muted);">No robots online</div>';
774
- resolve(null);
775
- return;
776
- }
777
- for (const r of robots) {
778
- const div = document.createElement('div');
779
- div.className = 'robot-card';
780
- const subtitle = r.busy
781
- ? `busy — ${r.activeApp ?? 'unknown app'}`
782
- : `${r.id.slice(0, 12)}...`;
783
- div.innerHTML = `<div class="name">${r.name || 'Reachy Mini'}</div><div class="id">${subtitle}</div>`;
784
- div.onclick = () => {
785
- if (r.busy) return; // selecting busy is a no-op
786
- document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected'));
787
- div.classList.add('selected');
788
- selectedRobotId = r.id;
789
- document.getElementById('robotName').textContent = r.name || 'Reachy Mini';
790
- resolve(r.id);
791
- };
792
- list.appendChild(div);
793
- }
794
- });
795
- }
796
-
797
- // Live SSE list updates: we still refresh the in-panel list as
798
- // robots come and go, but only while the panel is visible (i.e.
799
- // we're inside an active pickRobot prompt).
800
- function refreshPickerOnList(robots) {
801
- const panel = document.getElementById('robotSelector');
802
- if (panel.classList.contains('hidden')) return;
803
- // The picker promise already resolved once — re-rendering
804
- // here would replace the click handlers with stale ones
805
- // that try to resolve an already-settled Promise. Just
806
- // visually mark the cards.
807
- const list = document.getElementById('robotList');
808
- const presentIds = new Set((robots || []).map((r) => r.id));
809
- for (const card of list.querySelectorAll('.robot-card')) {
810
- // The id snippet is in the second child — best-effort
811
- // dim cards whose robot dropped off the list.
812
- const stillPresent = Array.from(presentIds).some((id) =>
813
- card.textContent.includes(id.slice(0, 12)),
814
- );
815
- card.style.opacity = stillPresent ? '' : '0.4';
816
- }
817
- }
818
 
819
  // ===================== Latency =====================
820
  function startLatencyDisplay() {
@@ -878,15 +683,10 @@
878
  }
879
 
880
  // ===================== Session =====================
881
- // The old two-step "Connect Start" flow is collapsed into
882
- // connectAndStream() above. Anything that used to call
883
- // startStream() now routes through connectAndStream() via the
884
- // window.startStream alias.
885
-
886
- async function stopStream() {
887
- if (detachVideo) { detachVideo(); detachVideo = null; }
888
- await robot.stopSession();
889
- }
890
 
891
  // ===================== Audio =====================
892
  function toggleMute() {
 
9
  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
  <link rel="stylesheet" href="style.css">
12
+ <link rel="icon" href="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" />
13
+
14
+ <!-- Modern host-shell SDK entries. The standalone path loads
15
+ `host/auto` (exports `mountHost`); the embed path loads
16
+ `host/embed` (`connectToHost`). Same package, two entries,
17
+ picked at runtime by the dispatcher at the bottom of <body>.
18
+ Pinned to the exact tarball the three Pollen reference apps
19
+ ship against — see APP_CREATION_GUIDE §10 (mixing SDK / host /
20
+ daemon versions causes silent protocol drift). -->
21
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c/host/dist/entry/auto.js" crossorigin>
22
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c/host/dist/entry/embed.js" crossorigin>
23
+
24
+ <!-- HF Spaces helper variables. In production (sdk: static +
25
+ hf_oauth: true), HF substitutes these placeholders at file-
26
+ serve time and the SDK reads them off `window.huggingface.variables`.
27
+ Locally the placeholders stay raw (the leading `__` gives them
28
+ away) we drop the global so `mountHost()` falls back to a
29
+ devToken or a manually-passed clientId. -->
30
+ <script>
31
+ (function () {
32
+ var clientId = "__OAUTH_CLIENT_ID__";
33
+ var scopes = "__OAUTH_SCOPES__";
34
+ var spaceHost = "__SPACE_HOST__";
35
+ var spaceId = "__SPACE_ID__";
36
+ var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
37
+ if (looksSubstituted) {
38
+ window.huggingface = window.huggingface || {};
39
+ window.huggingface.variables = {
40
+ OAUTH_CLIENT_ID: clientId,
41
+ OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
42
+ SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
43
+ SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
44
+ };
45
+ }
46
+ })();
47
+ </script>
48
+
49
+ <!-- Pre-paint visibility gate. Both paths start with #mainApp hidden:
50
+ - Standalone: mountHost() owns the page; #mainApp stays hidden.
51
+ - Embed: #mainApp is revealed by the embed handler once
52
+ connectToHost() resolves. While the handshake is in flight
53
+ we seed a "Connecting…" status pill so the eventual paint
54
+ reads as "almost there" rather than "broken". -->
55
  <script>
56
  (function () {
57
  try {
 
 
 
 
 
 
58
  document.addEventListener('DOMContentLoaded', function () {
59
+ var main = document.getElementById('mainApp');
60
+ if (main) main.classList.add('hidden');
61
+ var params = new URLSearchParams(window.location.search);
62
+ if (params.get('embedded') === '1' || params.get('embed') === '1') {
63
+ var statusText = document.getElementById('statusText');
64
+ var statusIndicator = document.getElementById('statusIndicator');
65
+ if (statusIndicator) statusIndicator.className = 'status-indicator connecting';
66
+ if (statusText) statusText.textContent = 'Connecting…';
67
+ }
68
  });
69
+ } catch (e) { /* best-effort polish */ }
 
 
70
  })();
71
  </script>
72
  </head>
73
  <body>
74
+ <!--
75
+ Host shell mount point. On a standalone visit, `mountHost()` from
76
+ the SDK's `host/auto` entry renders the full host UI (sign-in,
77
+ robot picker, top bar, leave button) into this div, owns OAuth
78
+ + connection lifecycle, and iframes back into THIS page with
79
+ `?embedded=1` once a robot is awake. We no longer hand-roll the
80
+ sign-in screen, picker, or Stop button the host owns them.
81
+ -->
82
+ <div id="root"></div>
83
+
84
+ <!-- Main App the post-connect surface. Hidden until either the
85
+ host shell shows us (standalone path, in-iframe) or the embed
86
+ dispatcher resolves connectToHost() (embed path). Everything
87
+ below this point is unchanged feature-wise vs the original
88
+ single-file demo. -->
 
89
  <div id="mainApp" class="hidden">
90
  <header class="header">
91
  <div class="logo">
 
94
  </div>
95
  <div class="user-section">
96
  <!-- Daemon version is filled in once the WebRTC data channel
97
+ is open (via reachy.getVersion()). Hidden until then so
98
+ we don't show a stale/empty badge during connect. The
99
+ host shell's top bar shows the signed-in user + sign-out
100
+ menu, so we no longer duplicate them here. -->
101
  <div id="daemonVersion" class="user-badge hidden" title="Daemon version"></div>
 
 
102
  </div>
103
  </header>
104
 
 
121
  <div class="video-overlay-bottom">
122
  <div class="video-controls">
123
  <!--
124
+ Connect / Stop are gone — the host shell's top
125
+ bar owns "leave session", and the iframe boots
126
+ already-connected via connectToHost(). Only the
127
+ per-direction audio toggles remain on the video,
128
+ since they're local-mute settings, not session
129
+ lifecycle.
130
  -->
 
 
 
131
  <button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled>
132
  <svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
133
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
 
161
  </div>
162
 
163
 
164
+ <!-- Robot Selector removed — the host shell owns robot
165
+ picking. By the time the embed handler runs, the
166
+ session is already up against the chosen robot. -->
 
 
 
 
 
 
167
 
168
  <!-- Head Control - RPY Sliders -->
169
  <div class="panel">
 
283
  </div>
284
 
285
  <script type="module">
286
+ // ===== SDK imports (modern host-shell pattern) =====
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  //
288
+ // Three entry points from the same npm pin
289
+ // (@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c the
290
+ // exact tarball the three Pollen reference apps ship against;
291
+ // see APP_CREATION_GUIDE §10 on version drift):
 
292
  //
293
+ // - host/dist/entry/auto.js → `mountHost` (standalone path)
294
+ // - host/dist/entry/embed.js `connectToHost` (embed path)
295
+ // - root (/+esm bundled by jsdelivr) math helpers
 
 
 
 
 
 
296
  //
297
+ // jsdelivr's `/+esm` endpoint inlines the SDK's bare npm deps
298
+ // (notably `@huggingface/hub`) into a single self-contained
299
+ // ESM bundle so the browser doesn't choke on the bare specifier.
300
+ // The host bundles are already Vite-built and self-contained, so
301
+ // they load straight from `host/dist/entry/*.js`.
302
+ import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c/host/dist/entry/auto.js";
303
+ import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c/host/dist/entry/embed.js";
304
+ import { matrixToRpy, radToDeg, rpyToMatrix, degToRad } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c/+esm";
305
+
306
+ // Live SDK instance for the running session. Set on the embed
307
+ // path by `connectToHost().reachy`. All callbacks below read
308
+ // `robot` at call time, so the late assignment is invisible to
309
+ // them.
310
+ let robot = null;
311
+
312
+ // ===== Module-scope state (unchanged from the legacy demo) =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  let headSlidersActive = false;
314
  let bodyYawSliderActive = false;
315
+ // Last body yaw the user *committed* via the slider, in degrees.
316
+ // The baseline for the tank-coupled head counter-rotation must
317
+ // be the last COMMANDED value (this variable), never telemetry
318
+ // telemetry lags one WebRTC RTT, so cumulative deltas computed
319
+ // against it stall under rapid drags.
320
  let lastBodyYawDeg = 0;
321
  let detachVideo = null;
322
  let latencyIntervalId = null;
323
+ // Returned by reachy.subscribeLogs(); call to stop the daemon-side
324
+ // journalctl stream. Reset on every leave.
325
  let logsUnsub = null;
326
  // Cap the logs panel so a long session doesn't grow without bound.
327
  const LOGS_MAX_LINES = 500;
328
 
329
+ // Inline-onclick handlers. Sign-in / sign-out / connect / stop
330
+ // are gone the host shell owns them now.
 
 
 
 
 
 
 
 
331
  window.playSound = playSound;
332
  window.playSoundPreset = playSoundPreset;
333
  window.playWakeUp = playWakeUp;
 
337
  window.toggleMic = toggleMic;
338
  window.clearLogs = clearLogs;
339
 
340
+ // ===== Dispatcher =====
341
+ //
342
+ // Standalone visit (no `?embedded=1`): mountHost() renders the
343
+ // full host shell into #root. The shell handles HF OAuth + robot
344
+ // picker + the leave button, and once a robot is awake it iframes
345
+ // THIS page at `?embedded=1`. No further JS in this module runs
346
+ // in the outer document.
347
+ //
348
+ // Embed visit: connectToHost() decodes the hash creds, brings
349
+ // the WebRTC session up, wakes the robot, and resolves with a
350
+ // typed handle. We replay its video+audio into our <video>, then
351
+ // wire the legacy slider / sound / log / latency UI exactly as
352
+ // before.
353
  document.addEventListener('DOMContentLoaded', async () => {
354
+ const params = new URLSearchParams(window.location.search);
355
+ const isEmbed = params.get('embedded') === '1' || params.get('embed') === '1';
356
+
357
+ if (!isEmbed) {
358
+ mountHost({
359
+ appName: 'Reachy Mini WebRTC Demo',
360
+ appIconUrl: 'https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png',
361
+ appEmoji: '🤖',
362
+ // We use the robot's mic in the audio toggles; the host
363
+ // needs to know so it negotiates an inbound audio track.
364
+ enableMicrophone: true,
365
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  return;
367
  }
368
 
369
+ try {
370
+ const handle = await connectToHost();
371
+ robot = handle.reachy;
372
+ // The host bridge captured the inbound video+audio tracks
373
+ // during boot; replay them into our <video>. attachVideo
374
+ // returns a cleanup we hold for the onLeave teardown.
375
+ detachVideo = handle.media.attachVideo(document.getElementById('remoteVideo'));
376
+
377
+ // Wire all the post-connect UI. Order matters: bind the
378
+ // event listeners FIRST so we don't miss the `state` /
379
+ // `micSupported` ticks that fire right after the session
380
+ // is already up.
381
+ initRobotEvents();
382
+ initSliders();
383
  showMainApp();
384
+ updateStatus('connected', 'Connected');
385
+ enableControls(true);
386
+ startLatencyDisplay();
387
+ fetchDaemonVersionOnce();
388
+ syncVolumeSlider();
389
+ startLogsStream();
390
+
391
+ // Host-requested leave (user clicks the top-bar leave
392
+ // button, or page lifecycle tears down). Best-effort
393
+ // cleanup — the iframe is about to be unmounted anyway.
394
+ handle.onLeave(() => {
395
+ try { if (detachVideo) detachVideo(); } catch (_) {}
396
+ detachVideo = null;
397
+ stopLatencyDisplay();
398
+ stopLogsStream();
399
+ });
400
+ } catch (err) {
401
+ console.error('[embed] connectToHost failed:', err);
402
+ updateStatus('', 'Embed connect failed: ' + (err?.message || err));
403
  }
404
  });
405
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  function showMainApp() {
 
407
  document.getElementById('mainApp').classList.remove('hidden');
 
408
  }
409
 
410
  // ===================== Robot Events =====================
411
+ //
412
+ // We only bind the events that drive *in-iframe* UI (state echo,
413
+ // mic-support gate, telemetry-driven slider sync, server-initiated
414
+ // session-stop messages). Sign-in / picker / connect-button state
415
+ // events are gone — the host shell handles them outside the iframe.
416
  function initRobotEvents() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  robot.addEventListener('sessionStopped', (e) => {
 
 
 
418
  enableControls(false);
419
+ // e.detail.message is set when the stop was server-initiated
420
+ // (e.g. a local Python app took over the robot). Surfaced in
421
+ // the in-iframe status pill so the user has a reason for the
422
+ // sudden silence.
423
+ updateStatus('connected', e.detail?.message || 'Session ended');
424
  updateMicButton();
425
  stopLatencyDisplay();
426
+ // Disable the volume slider and clear the version pill —
427
+ // they only make sense while the data channel is open.
428
  document.getElementById('volumeSlider').disabled = true;
429
  document.getElementById('volumeValue').textContent = '--';
430
  document.getElementById('daemonVersion').classList.add('hidden');
 
442
  enableControls(robot.state === 'streaming');
443
  });
444
 
 
 
 
 
 
 
445
  robot.addEventListener('error', (e) => {
446
  console.error(`[${e.detail.source}]`, e.detail.error);
447
  if (e.detail.source === 'webrtc') {
 
616
  });
617
  })();
618
 
619
+ // Connection bring-up (auth, picker, session start, wake) is
620
+ // owned by the host shell now see `mountHost()` in the
621
+ // dispatcher above. The legacy `connectAndStream` /
622
+ // `presentRobotPicker` / `refreshPickerOnList` helpers are gone.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
 
624
  // ===================== Latency =====================
625
  function startLatencyDisplay() {
 
683
  }
684
 
685
  // ===================== Session =====================
686
+ // Session lifecycle (start / stop / wake / sleep on leave) lives
687
+ // in the host shell. The iframe receives an already-connected,
688
+ // already-awake handle via connectToHost(). The `handle.onLeave()`
689
+ // wired in the dispatcher above is our only teardown hook.
 
 
 
 
 
690
 
691
  // ===================== Audio =====================
692
  function toggleMute() {
style.css CHANGED
@@ -342,39 +342,8 @@ video {
342
  color: var(--pollen-coral);
343
  }
344
 
345
- /* Robot Selector */
346
- .robot-list {
347
- display: flex;
348
- flex-direction: column;
349
- gap: 8px;
350
- }
351
-
352
- .robot-card {
353
- padding: 10px 14px;
354
- background: var(--pollen-darker);
355
- border: 2px solid transparent;
356
- border-radius: 8px;
357
- cursor: pointer;
358
- }
359
-
360
- .robot-card:hover {
361
- background: var(--pollen-card-light);
362
- }
363
-
364
- .robot-card.selected {
365
- border-color: var(--pollen-coral);
366
- }
367
-
368
- .robot-card .name {
369
- font-weight: 600;
370
- font-size: 0.9em;
371
- }
372
-
373
- .robot-card .id {
374
- font-size: 0.75em;
375
- color: var(--text-muted);
376
- font-family: monospace;
377
- }
378
 
379
  /* Desktop Layout - Side by Side */
380
  @media (min-width: 900px) {
@@ -391,11 +360,6 @@ video {
391
  grid-row: 1 / 3;
392
  }
393
 
394
- #robotSelector {
395
- grid-column: 1;
396
- grid-row: 3;
397
- }
398
-
399
  .panel:nth-of-type(1) { /* Head Control */
400
  grid-column: 2;
401
  grid-row: 1;
@@ -412,56 +376,4 @@ video {
412
  }
413
  }
414
 
415
- /* Login View */
416
- .login-view {
417
- min-height: 100vh;
418
- min-height: 100dvh;
419
- display: flex;
420
- align-items: center;
421
- justify-content: center;
422
- padding: 20px;
423
- }
424
-
425
- .login-card {
426
- background: var(--pollen-card);
427
- padding: 40px;
428
- border-radius: 16px;
429
- text-align: center;
430
- max-width: 380px;
431
- }
432
-
433
- .login-logo {
434
- width: 72px;
435
- height: 72px;
436
- margin-bottom: 20px;
437
- border-radius: 12px;
438
- }
439
-
440
- .login-card h2 {
441
- color: var(--pollen-coral);
442
- margin-bottom: 10px;
443
- font-size: 1.5em;
444
- }
445
-
446
- .login-card p {
447
- color: var(--text-secondary);
448
- margin-bottom: 24px;
449
- font-size: 0.9em;
450
- line-height: 1.5;
451
- }
452
-
453
- .btn-hf {
454
- background: #FFD21E;
455
- color: #000;
456
- border: none;
457
- padding: 12px 28px;
458
- border-radius: 8px;
459
- font-size: 0.95em;
460
- font-weight: 700;
461
- cursor: pointer;
462
- display: inline-flex;
463
- align-items: center;
464
- gap: 8px;
465
- }
466
-
467
  .hidden { display: none !important; }
 
342
  color: var(--pollen-coral);
343
  }
344
 
345
+ /* Robot picker + login view styles dropped — both are owned by the
346
+ * host shell now (mountHost renders them outside this page). */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  /* Desktop Layout - Side by Side */
349
  @media (min-width: 900px) {
 
360
  grid-row: 1 / 3;
361
  }
362
 
 
 
 
 
 
363
  .panel:nth-of-type(1) { /* Head Control */
364
  grid-column: 2;
365
  grid-row: 1;
 
376
  }
377
  }
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  .hidden { display: none !important; }