cduss commited on
Commit
c8ef110
·
1 Parent(s): 1e1d196

Revert "Rebuild as Reachy Mini Dashboard on the modern host-shell pattern"

Browse files

This reverts commit 1e1d196871c83d2a0cb4e821365a3f9d63309396.

README.md CHANGED
@@ -1,120 +1,35 @@
1
  ---
2
- title: Reachy Mini Dashboard
3
- emoji: 🛰️
4
- colorFrom: indigo
5
- colorTo: pink
6
  sdk: static
7
  pinned: false
8
  hf_oauth: true
9
  hf_oauth_expiration_minutes: 480
10
- short_description: One-screen control surface for a Reachy Mini robot.
11
  tags:
12
- - reachy_mini
13
- - reachy_mini_js_app
14
  ---
15
 
16
- # Reachy Mini Dashboard
17
 
18
- A power-user control surface for a Reachy Mini robot, packed into a
19
- single screen. Built on the modern host-shell pattern documented in
20
- the SDK's [`ts/APP_CREATION_GUIDE.md`](https://github.com/pollen-robotics/reachy_mini/blob/main/ts/APP_CREATION_GUIDE.md).
21
-
22
- The host (OAuth, robot picker, leave flow) lives outside the iframe;
23
- this app renders the full control surface inside the embed.
24
 
25
  ## Features
26
 
27
- | Card | Surfaces |
28
- |-------------|--------------------------------------------------------------------------------------------------|
29
- | **Video** | Live camera feed (4:3), LIVE / WAITING pill, audio-direction indicator, verbose latency overlay.|
30
- | **Audio** | Mic + speaker toggles, gain sliders both directions, FFT spectrum bars on each channel. |
31
- | **Head** | Roll / pitch / yaw sliders, two-way bound to daemon state, atomic frame on every change. |
32
- | **Body+Ant**| Body yaw (tank-coupled to head yaw), right / left antennas. |
33
- | **Motion** | Wake-up, sleep, torque toggle (auto-labels with current motor mode). |
34
- | **Sound** | Free-text file name + preset chips, fire-and-forget playback. |
35
- | **Logs** | Live `journalctl` over WebRTC data channel, fuzzy filter, sticky scroll, clear. |
36
-
37
- Top-bar pills show the link health (RTT / buffer / jitter, colour-
38
- coded), motor mode, daemon version, and signed-in HF user.
39
-
40
- ## Keyboard shortcuts
41
-
42
- | Key | Action |
43
- |-----|-------------------|
44
- | `M` | toggle speaker |
45
- | `N` | toggle microphone |
46
- | `T` | toggle torque |
47
- | `W` | wake up |
48
- | `S` | go to sleep |
49
- | `/` | focus log filter |
50
-
51
- Skipped when an input field has focus (so `/` doesn't fight your
52
- typing); `/` still hands off to the log filter from any context.
53
-
54
- ## Stack
55
-
56
- - **React 19** + **TypeScript** (strict) + **Vite 7**
57
- - **MUI 7** with the same theme tokens as `reachy_mini_mobile_app`
58
- - **`@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c`**, the
59
- same exact pin used by all three Pollen reference apps. Mixing
60
- versions across the SDK, the host shell, and the daemon causes
61
- silent protocol drift — see APP_CREATION_GUIDE §10.
62
-
63
- ## Local dev
64
-
65
- ```bash
66
- npm install
67
- npm run dev
68
- # → http://localhost:5173
69
- ```
70
-
71
- Local sign-in has two options, both forwarded to `mountHost()` in
72
- `src/dispatch.ts`. Copy `.env.example` to `.env.local` and fill in
73
- ONE of:
74
-
75
- - **Option A — personal access token** (recommended for most dev):
76
- `VITE_HF_TOKEN=...` + `VITE_HF_USERNAME=...`. Skips the OAuth
77
- redirect entirely.
78
- - **Option B — OAuth client ID** (mirrors prod): create an app at
79
- <https://huggingface.co/settings/applications/new>, set the
80
- redirect URI to `http://localhost:5173`, paste the client ID into
81
- `VITE_HF_OAUTH_CLIENT_ID`.
82
-
83
- `.env.local` is gitignored — never commit a token.
84
-
85
- ## Build
86
-
87
- ```bash
88
- npm run build
89
- npm run preview
90
- # → http://localhost:8080
91
- ```
92
-
93
- The build emits a static `dist/` folder. HF serves it directly from
94
- its CDN (`sdk: static`) and replaces `__OAUTH_CLIENT_ID__` /
95
- `__OAUTH_SCOPES__` / `__SPACE_HOST__` / `__SPACE_ID__` inside
96
- `index.html` at file-serve time, thanks to `hf_oauth: true` above.
97
-
98
- ## Deploy to a Hugging Face Space
99
-
100
- ```bash
101
- hf repos create reachy-mini-dashboard --repo-type space --space-sdk static
102
- git remote add space https://huggingface.co/spaces/<you>/reachy-mini-dashboard
103
- # Build, then push README.md + dist/ contents (and the public/icon.svg
104
- # duplicated so the mobile catalog's `siblings` probe finds it).
105
- ```
106
-
107
- See APP_CREATION_GUIDE §11.2 for the full deploy recipe.
108
 
109
- ## URL parameters
110
 
111
- - `?embedded=1` plus `#creds=<base64>` — iframe handoff from a mobile
112
- app shell. The dispatcher skips the host and boots the embed
113
- directly.
114
- - `?theme=light|dark` — overrides auto theme detection.
115
- - `?config=<base64({"initialSoundPreset":"..."})>` — deep-link a
116
- starting sound preset into the Sound card.
117
 
118
- ## License
119
 
120
- MIT.
 
 
1
  ---
2
+ title: Reachy Mini WebRTC Demo
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  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
13
  ---
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
assets/auto-CcEOuy4Q.js DELETED
The diff for this file is too large to render. See raw diff
 
assets/embed-DSsDnZHG.js DELETED
The diff for this file is too large to render. See raw diff
 
assets/index-CsmyDA4N.js DELETED
@@ -1,4 +0,0 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/embed-DSsDnZHG.js","assets/protocol-yMW1n-O_.js","assets/auto-CcEOuy4Q.js"])))=>i.map(i=>d[i]);
2
- (function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const n of i.addedNodes)n.tagName==="LINK"&&n.rel==="modulepreload"&&o(n)}).observe(document,{childList:!0,subtree:!0});function t(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function o(s){if(s.ep)return;s.ep=!0;const i=t(s);fetch(s.href,i)}})();const x="modulepreload",N=function(r){return"/"+r},U={},P=function(e,t,o){let s=Promise.resolve();if(t&&t.length>0){let c=function(l){return Promise.all(l.map(d=>Promise.resolve(d).then(h=>({status:"fulfilled",value:h}),h=>({status:"rejected",reason:h}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),a=n?.nonce||n?.getAttribute("nonce");s=c(t.map(l=>{if(l=N(l),l in U)return;U[l]=!0;const d=l.endsWith(".css"),h=d?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${h}`))return;const _=document.createElement("link");if(_.rel=d?"stylesheet":x,d||(_.as="script"),_.crossOrigin="",_.href=l,a&&_.setAttribute("nonce",a),document.head.appendChild(_),d)return new Promise((f,p)=>{_.addEventListener("load",f),_.addEventListener("error",()=>p(new Error(`Unable to preload CSS for ${l}`)))})}))}function i(n){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=n,window.dispatchEvent(a),!a.defaultPrevented)throw n}return s.then(n=>{for(const a of n||[])a.status==="rejected"&&i(a.reason);return e().catch(i)})};var F=Object.defineProperty,B=(r,e,t)=>e in r?F(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t,A=(r,e,t)=>(B(r,typeof e!="symbol"?e+"":e,t),t),O="https://huggingface.co";async function E(r,e){var t,o;const s=new V(r.url,r.status,(t=r.headers.get("X-Request-Id"))!=null?t:void 0);s.message=`Api error with status ${s.statusCode}`;const i=[`URL: ${s.url}`,s.requestId?`Request ID: ${s.requestId}`:void 0].filter(Boolean).join(". ");if((o=r.headers.get("Content-Type"))!=null&&o.startsWith("application/json")){const n=await r.json();s.message=n.error||n.message||s.message,s.data=n}else s.data={message:await r.text()};throw s.message+=`. ${i}`,s}var V=class extends Error{constructor(r,e,t,o){super(o),A(this,"statusCode"),A(this,"url"),A(this,"requestId"),A(this,"data"),this.statusCode=e,this.requestId=t,this.url=r}};new Promise(r=>{});function q(r){if(globalThis.Buffer)return globalThis.Buffer.from(r).toString("base64");{const e=[];return r.forEach(t=>{e.push(String.fromCharCode(t))}),globalThis.btoa(e.join(""))}}async function j(r){var e,t;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const o=new URLSearchParams(window.location.search),[s,i]=[o.get("error"),o.get("error_description")];if(s)throw new Error(`${s}: ${i}`);const n=o.get("code"),a=localStorage.getItem("huggingface.co:oauth:nonce");if(!n)throw new Error("Missing oauth code from query parameters in redirected URL");if(!a)throw new Error("Missing oauth nonce from localStorage");const c=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!c)throw new Error("Missing oauth code_verifier from localStorage");const l=o.get("state");if(!l)throw new Error("Missing oauth state from query parameters in redirected URL");let d;try{d=JSON.parse(l)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+l)}if(d.nonce!==a)throw new Error("Invalid oauth state in redirected URL");const h=O,_=`${new URL(h).origin}/.well-known/openid-configuration`,f=await fetch(_,{headers:{Accept:"application/json"}});if(!f.ok)throw await E(f);const p=await f.json(),v=await fetch(p.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:n,redirect_uri:d.redirectUri,code_verifier:c}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!v.ok)throw await E(v);const w=await v.json(),g=new Date(Date.now()+w.expires_in*1e3),S=await fetch(p.userinfo_endpoint,{headers:{Authorization:`Bearer ${w.access_token}`}});if(!S.ok)throw await E(S);const u=await S.json();return{accessToken:w.access_token,accessTokenExpiresAt:g,userInfo:{id:u.sub,name:u.name,fullname:u.preferred_username,email:u.email,emailVerified:u.email_verified,avatarUrl:u.picture,websiteUrl:u.website,isPro:u.isPro,orgs:(t=(e=u.orgs)==null?void 0:e.map(m=>({id:m.sub,name:m.name,fullname:m.name,isEnterprise:m.isEnterprise,canPay:m.canPay,avatarUrl:m.picture,roleInOrg:m.roleInOrg})))!=null?t:[]},state:d.state,scope:w.scope}}async function H(r){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const e=new URLSearchParams(window.location.search);return e.has("error")?j():e.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?j():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function W(r){var e,t;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const o=r?.hubUrl||O,s=`${new URL(o).origin}/.well-known/openid-configuration`,i=await fetch(s,{headers:{Accept:"application/json"}});if(!i.ok)throw await E(i);const n=await i.json(),a=globalThis.crypto.randomUUID(),c=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",a),localStorage.setItem("huggingface.co:oauth:code_verifier",c);const l=r?.redirectUrl||window.location.href,d=JSON.stringify({nonce:a,redirectUri:l,state:r?.state}),h=(t=(e=window?.huggingface)==null?void 0:e.variables)!=null?t:null,_=r?.clientId||h?.OAUTH_CLIENT_ID;if(!_)throw h?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const f=q(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(c)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${n.authorization_endpoint}?${new URLSearchParams({client_id:_,scope:r?.scopes||h?.OAUTH_SCOPES||"openid profile",response_type:"code",redirect_uri:l,state:d,code_challenge:f,code_challenge_method:"S256"}).toString()}`}function R(r){return r*Math.PI/180}function C(r){return r*180/Math.PI}function z(r,e,t){const o=R(r),s=R(e),i=R(t),n=Math.cos(i),a=Math.sin(i),c=Math.cos(s),l=Math.sin(s),d=Math.cos(o),h=Math.sin(o);return[[n*c,n*l*h-a*d,n*l*d+a*h,0],[a*c,a*l*h+n*d,a*l*d-n*h,0],[-l,c*h,c*d,0],[0,0,0,1]]}function te(r){return{roll:C(Math.atan2(r[2][1],r[2][2])),pitch:C(Math.asin(-r[2][0])),yaw:C(Math.atan2(r[1][0],r[0][0]))}}function J(){if(typeof window>"u"||!window.location.hash)return;const r=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let e;try{e=new URLSearchParams(r)}catch{return}const t=e.get("hf_token");if(!t)return;const o=e.get("hf_username")||"user",s=e.get("hf_token_expires"),i=s&&!Number.isNaN(new Date(s).getTime())?s:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",t),sessionStorage.setItem("hf_username",o),sessionStorage.setItem("hf_token_expires",i)}catch(c){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",c)}e.delete("hf_token"),e.delete("hf_username"),e.delete("hf_token_expires");const n=e.toString(),a=window.location.pathname+window.location.search+(n?"#"+n:"");try{window.history.replaceState(null,"",a)}catch{}}function G(){if(typeof window>"u")return null;if(window.location.hash){const r=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const t=new URLSearchParams(r).get("robot_peer_id");if(t)return t}catch{}}if(window.location.search)try{const e=new URLSearchParams(window.location.search).get("robot_peer_id");if(e)return e}catch{}return null}function K(r){const e=r.split(`\r
3
- `);let t=!1;for(const o of e)if(o.startsWith("m=audio")?t=!0:o.startsWith("m=")&&(t=!1),t&&o==="a=sendrecv")return!0;return!1}const y=12*1024,k=1*1024*1024,Q=512*1024;function X(){return typeof CompressionStream<"u"}function L(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function T(r){let e="";for(let o=0;o<r.length;o+=32768)e+=String.fromCharCode.apply(null,Array.from(r.subarray(o,o+32768)));return btoa(e)}async function Y(r){const e=new TextEncoder().encode(r),t=await new Response(new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return T(new Uint8Array(t))}function D(r){const e=Math.round(Number(r)||0);return Math.max(0,Math.min(100,e))}class Z extends EventTarget{_signalingUrl;_clientId;_appName;_videoJitterBufferTargetMs;_autoStartFromUrl;_autoStartAttempted;_state="disconnected";_robots=[];_robotState={};_preselectedRobotId;_token=null;_username=null;_tokenExpires=null;_sseAbortController=null;_pc=null;_dc=null;_sessionId=null;_selectedRobotId=null;_pendingRemoteIce=[];_micStream=null;_micMuted=!0;_audioMuted=!0;_micSupported=!1;_latencyMonitorId=null;_stateRefreshInterval=null;_versionResolve=null;_hardwareIdResolve=null;_volumeResolve=null;_micVolumeResolve=null;_applyAudioConfigResolve=null;_readAudioParameterResolve=null;_logSubscribers=new Set;_broadcastWaiters=[];_activeMoveUploadId=null;_activeAudioUploadId=null;_sessionResolve=null;_sessionReject=null;_iceConnected=!1;_dcOpen=!1;_pendingMotionCompletions={wake_up:[],goto_sleep:[]};_videoElement=null;constructor(e={}){super(),this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=G()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(o=>o.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const t=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(t).catch(o=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",o)})},0)}async authenticate(){try{J();const e=await H();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),o=sessionStorage.getItem("hf_username"),s=sessionStorage.getItem("hf_token_expires");return t&&o&&s&&new Date(s)>new Date?(this._token=t,this._username=o,this._tokenExpires=s,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await W(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token — call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(o){throw this._sseAbortController=null,o}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((o,s)=>{let i=!1;const n=t.body.getReader(),a=new TextDecoder;let c="";(async()=>{try{for(;;){const{done:d,value:h}=await n.read();if(d)break;c+=a.decode(h,{stream:!0});const _=c.split(`
4
- `);c=_.pop()??"";for(const f of _)if(f.startsWith("data:"))try{const p=JSON.parse(f.slice(5).trim());!i&&p.type==="welcome"&&p.peerId&&(i=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:p.peerId}),o()),this._handleSignalingMessage(p)}catch{}}}catch(d){if(d.name!=="AbortError"&&this._emit("error",{source:"signaling",error:d}),!i){s(d);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),i||s(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){const{token:t,pickRobot:o,autoPickIfSingle:s=!0,filterBusy:i=!0,wakeOnConnect:n=!0}=e;if(this._state==="streaming"){const c=this._robots?.find(l=>l.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:c?.meta?.name??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const a=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated — call login() or pass a token");this._state==="disconnected"&&await this.connect();let c,l=null;if(this.isEmbedded){c=this._preselectedRobotId;try{await this._waitForRobotInList(c,5e3)}catch{}l=this._robots?.find(h=>h.id===c)?.meta?.name??null}else{const d=await this._fetchOwnedRobots({filterBusy:i});if(d.length===0)throw new Error("No reachable robots");if(s&&d.length===1&&!d[0].busy)c=d[0].id,l=d[0].name;else if(o){const h=await o(d);if(!h)throw new Error("Robot selection cancelled");c=h,l=d.find(_=>_.id===h)?.name??null}else throw new Error("Multiple robots available — pass a pickRobot callback to autoConnect()")}if(await this.startSession(c),n&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(d){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",d)}return{robotId:c,robotName:l,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=a}}async _fetchOwnedRobots({filterBusy:e=!0}={}){try{const t=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const o=await t.json(),s=new Map;for(const i of o.robots||[]){if(e&&i.busy)continue;const n=i.meta?.install_id??i.meta?.hardware_id??i.peerId;s.set(n,{id:i.peerId,name:i.robotName??i.meta?.name??null,busy:!!i.busy,activeApp:i.activeApp??null,meta:i.meta??{},lastSeenAgeSeconds:i.last_seen_age_seconds??null})}return Array.from(s.values()).sort((i,n)=>(i.lastSeenAgeSeconds??1/0)-(n.lastSeenAgeSeconds??1/0))}catch(t){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",t),(this._robots||[]).map(o=>({id:o.id,name:o.meta?.name??null,busy:!1,activeApp:null,meta:o.meta??{},lastSeenAgeSeconds:null}))}}_waitForRobotInList(e,t){return this._robots?.find(o=>o.id===e)?Promise.resolve():new Promise((o,s)=>{const i=()=>{this._robots?.find(a=>a.id===e)&&(this.removeEventListener("robotsChanged",i),clearTimeout(n),o())},n=setTimeout(()=>{this.removeEventListener("robotsChanged",i),s(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",i)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,o=t.AudioContext??t.webkitAudioContext;if(!o)throw new Error("AudioContext not supported");const s=new o,i=s.createMediaStreamDestination(),n=s.createOscillator(),a=s.createGain();a.gain.value=0,n.connect(a).connect(i),n.start();const c=i.stream;c.getAudioTracks().forEach(l=>{l.enabled=!1}),this._micStream=c,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),new Promise((t,o)=>{this._sessionResolve=t,this._sessionReject=o,this._pc.ontrack=s=>{if(s.track.kind==="video"){const i=this._videoJitterBufferTargetMs;try{s.receiver.jitterBufferTarget=i}catch{}try{s.receiver.playoutDelayHint=i/1e3}catch{}this._emit("videoTrack",{track:s.track,stream:s.streams[0]})}},this._pc.onicecandidate=async s=>{s.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:s.candidate.candidate,sdpMLineIndex:s.candidate.sdpMLineIndex,sdpMid:s.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{const s=this._pc?.iceConnectionState;if(s)if(s==="connected"||s==="completed")this._iceConnected=!0,this._checkSessionReady();else if(s==="failed"){const i=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(i),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:i})}else s==="disconnected"&&this._emit("error",{source:"webrtc",error:new Error("ICE disconnected")})},this._pc.ondatachannel=s=>{this._dc=s.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=i=>this._handleRobotMessage(JSON.parse(i.data))},this._sendToServer({type:"startSession",peerId:e}).then(s=>{if(s?.type==="sessionRejected"){this._failSessionRejected(s);return}s?.sessionId&&(this._sessionId=s.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(o=>o.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const o=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,o(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(t=>t.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}setTarget({head:e,antennas:t,body_yaw:o}={}){const s={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);s.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);s.antennas=t}if(o!==void 0){if(!Number.isFinite(o))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${o}`);s.body_yaw=o}return this._sendCommand(s)}gotoTarget({head:e,antennas:t,body_yaw:o,duration:s}){const i={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(n=>Number.isFinite(n)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);i.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(n=>Number.isFinite(n)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);i.antennas=t}if(o!==void 0){if(!Number.isFinite(o))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${o}`);i.body_yaw=o}if(!Number.isFinite(s)||s<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${s}`);return i.duration=s,this._sendCommand(i)}setHeadRpyDeg(e,t,o){return this.setTarget({head:z(e,t,o).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[R(e),R(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:R(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((o,s)=>{const i={resolve:o,reject:s,timer:setTimeout(()=>{const n=this._pendingMotionCompletions[e],a=n.indexOf(i);a!==-1&&n.splice(a,1),s(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(i)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const o=this._pendingMotionCompletions[t];for(;o.length;){const s=o.shift();clearTimeout(s.timer),s.reject(e)}}}isAwake(){const e=this._robotState?.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){return this._robotState?.motor_mode===void 0&&await new Promise(t=>{const o=()=>{this.removeEventListener("state",o),clearTimeout(s),t()},s=setTimeout(o,e);this.addEventListener("state",o),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:D(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:D(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,o=>{this._applyAudioConfigResolve=o},{type:"apply_audio_config",config:e,verify:t}).then(o=>o===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,o){return new Promise((s,i)=>{if(!this._dc||this._dc.readyState!=="open"){i(new Error("Data channel not open"));return}const n=e();n&&n(null),t(s),this._sendCommand(o)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const o={onLine:e,onError:t},s=this._logSubscribers.size===0;this._logSubscribers.add(o),s&&this._sendCommand({type:"subscribe_logs"});let i=!1;return()=>{i||(i=!0,this._logSubscribers.delete(o),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=s=>{const i=s;e.srcObject=i.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},o=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",o),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",o),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:o=-100,description:s="move",encoding:i="gzip+base64",playFrequency:n=100,initialGotoDuration:a=0,startTimeoutMs:c=8e3,onProgress:l=()=>{},onStarted:d=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!e?.time?.length||!e?.set_target_data?.length)throw new Error("playMove: motion must have time + set_target_data");const h=L();this._activeMoveUploadId=h;const _={description:s,time:e.time,set_target_data:e.set_target_data},f=JSON.stringify(_);let p,v;i==="gzip+base64"&&X()?(p=await Y(f),v="gzip+base64"):(p=f,v="json");const w=Math.ceil(p.length/y)||1;l({phase:"starting",sent:0,total:w,bytes:p.length,encoding:v}),this._sendCommand({type:"upload_move_start",upload_id:h,total_chunks:w,description:s,encoding:v});for(let u=0;u<w;u++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const m=u*y;this._sendCommand({type:"upload_move_chunk",upload_id:h,chunk_index:u,chunk:p.slice(m,m+y)}),l({phase:"upload",sent:u+1,total:w})}if(this._sendCommand({type:"upload_move_finish",upload_id:h}),l({phase:"uploaded",sent:w,total:w}),t){const u=new Uint8Array(await t.arrayBuffer()),m=T(u),b=Math.ceil(m.length/y)||1;l({phase:"audio-starting",sent:0,total:b,bytes:m.length}),this._sendCommand({type:"upload_audio_start",upload_id:h,total_chunks:b,encoding:"wav-base64",description:s});for(let I=0;I<b;I++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const M=I*y;this._sendCommand({type:"upload_audio_chunk",upload_id:h,chunk_index:I,chunk:m.slice(M,M+y)}),l({phase:"audio-upload",sent:I+1,total:b})}this._sendCommand({type:"upload_audio_finish",upload_id:h}),l({phase:"audio-uploaded",sent:b,total:b})}this._sendCommand({type:"play_uploaded_move",upload_id:h,play_frequency:n,initial_goto_duration:a,audio_lead_ms:o});let g;try{g=await this._waitForBroadcast(u=>u?.type==="play_uploaded_move"&&u?.upload_id===h&&(u.started===!0||typeof u.error=="string"),{timeoutMs:c,debugLabel:"play_uploaded_move started"})}catch(u){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${u.message}`)}if(typeof g.error=="string")throw new Error(`play_uploaded_move: ${g.error}`);try{d({duration_s:g.duration_s,has_audio:g.has_audio===!0})}catch(u){console.warn("playMove.onStarted threw:",u)}l({phase:"playing",duration_s:g.duration_s});const S=await this._waitForBroadcast(u=>u?.type==="play_uploaded_move"&&u?.upload_id===h&&(u.finished===!0||u.cancelled===!0||typeof u.error=="string"),{timeoutMs:(g.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===h&&(this._activeMoveUploadId=null),S}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:o=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const s=L(),i=new Uint8Array(await e.arrayBuffer()),n=T(i),a=Math.ceil(n.length/y)||1;o({phase:"audio-starting",sent:0,total:a,bytes:n.length}),this._sendCommand({type:"upload_audio_start",upload_id:s,total_chunks:a,encoding:"wav-base64",description:t});for(let c=0;c<a;c++){this._dc.bufferedAmount>k&&await this._awaitDataChannelDrain();const l=c*y;this._sendCommand({type:"upload_audio_chunk",upload_id:s,chunk_index:c,chunk:n.slice(l,l+y)}),o({phase:"audio-upload",sent:c+1,total:a})}return this._sendCommand({type:"upload_audio_finish",upload_id:s}),o({phase:"audio-uploaded",sent:a,total:a}),s}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const o=this._waitForBroadcast(i=>i?.type==="play_uploaded_audio"&&i?.upload_id===e&&(i.started===!0||typeof i.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const s=await o;if(typeof s.error=="string")throw new Error(s.error);return this._activeAudioUploadId=e,s}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:o=""}={}){return new Promise((s,i)=>{const n={predicate:e,resolve:s,timer:setTimeout(()=>{const a=this._broadcastWaiters.indexOf(n);a!==-1&&this._broadcastWaiters.splice(a,1),i(new Error(`broadcast timeout (${t} ms): ${o}`))},t)};this._broadcastWaiters.push(n)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>Q;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token — authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let o="";try{o=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e?.type}; body=${o||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t?.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,o=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const s=new Error(o||`Session ended before it could start: ${t||"unknown reason"}`);s.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(n=>n.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(s);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:o}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const s=K(t.sdp);if(this._micSupported=s,this._emit("micSupported",{supported:s}),s&&this._micStream)for(const n of this._micStream.getAudioTracks())this._pc.addTrack(n,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=await this._pc.createAnswer();await this._pc.setLocalDescription(i),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:i.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const o=this._pendingRemoteIce;if(o&&o.length){this._pendingRemoteIce=[];for(const s of o)try{await this._pc.addIceCandidate(new RTCIceCandidate(s))}catch(i){console.warn("[reachy-mini] buffered ICE candidate rejected:",i)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const o=t.shift();clearTimeout(o.timer),o.resolve();return}if(e.error&&t.length>0){const o=t.shift();clearTimeout(o.timer),o.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(o){console.error("subscribeLogs onLine threw:",o)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(o){console.error("subscribeLogs onError threw:",o)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const o=this._broadcastWaiters[t];if(o.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(o.timer),o.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const o=t.end(t.length-1),s=o-e.currentTime;s>.5&&(console.log(`Latency correction: was ${s.toFixed(2)}s behind`),e.currentTime=o-.1)}},2e3)}}window.ReachyMini=Z;window.dispatchEvent(new Event("reachymini:ready"));const $=new URLSearchParams(window.location.search),ee=$.get("embedded")==="1"||$.get("embed")==="1";ee?P(()=>import("./embed-DSsDnZHG.js"),__vite__mapDeps([0,1])):P(async()=>{const{mountHost:r}=await import("./auto-CcEOuy4Q.js");return{mountHost:r}},__vite__mapDeps([2,1])).then(({mountHost:r})=>{r({appName:"Reachy Mini Dashboard",appIconUrl:"/icon.svg",appEmoji:"🛰️",enableMicrophone:!0,devToken:void 0,clientId:void 0})});export{z as a,R as d,te as m,C as r};
 
 
 
 
 
assets/protocol-yMW1n-O_.js DELETED
@@ -1,3 +0,0 @@
1
- var L=Object.defineProperty,$=(n,e,t)=>e in n?L(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,A=(n,e,t)=>($(n,typeof e!="symbol"?e+"":e,t),t),P="https://huggingface.co";async function C(n,e){var t,s;const o=new D(n.url,n.status,(t=n.headers.get("X-Request-Id"))!=null?t:void 0);o.message=`Api error with status ${o.statusCode}`;const i=[`URL: ${o.url}`,o.requestId?`Request ID: ${o.requestId}`:void 0].filter(Boolean).join(". ");if((s=n.headers.get("Content-Type"))!=null&&s.startsWith("application/json")){const r=await n.json();o.message=r.error||r.message||o.message,o.data=r}else o.data={message:await n.text()};throw o.message+=`. ${i}`,o}var D=class extends Error{constructor(n,e,t,s){super(s),A(this,"statusCode"),A(this,"url"),A(this,"requestId"),A(this,"data"),this.statusCode=e,this.requestId=t,this.url=n}};new Promise(n=>{});function x(n){if(globalThis.Buffer)return globalThis.Buffer.from(n).toString("base64");{const e=[];return n.forEach(t=>{e.push(String.fromCharCode(t))}),globalThis.btoa(e.join(""))}}async function T(n){var e,t;if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const s=new URLSearchParams(window.location.search),[o,i]=[s.get("error"),s.get("error_description")];if(o)throw new Error(`${o}: ${i}`);const r=s.get("code"),l=localStorage.getItem("huggingface.co:oauth:nonce");if(!r)throw new Error("Missing oauth code from query parameters in redirected URL");if(!l)throw new Error("Missing oauth nonce from localStorage");const d=localStorage.getItem("huggingface.co:oauth:code_verifier");if(!d)throw new Error("Missing oauth code_verifier from localStorage");const a=s.get("state");if(!a)throw new Error("Missing oauth state from query parameters in redirected URL");let u;try{u=JSON.parse(a)}catch{throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: "+a)}if(u.nonce!==l)throw new Error("Invalid oauth state in redirected URL");const h=P,p=`${new URL(h).origin}/.well-known/openid-configuration`,m=await fetch(p,{headers:{Accept:"application/json"}});if(!m.ok)throw await C(m);const y=await m.json(),v=await fetch(y.token_endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:r,redirect_uri:u.redirectUri,code_verifier:d}).toString()});if(localStorage.removeItem("huggingface.co:oauth:code_verifier"),localStorage.removeItem("huggingface.co:oauth:nonce"),!v.ok)throw await C(v);const f=await v.json(),g=new Date(Date.now()+f.expires_in*1e3),R=await fetch(y.userinfo_endpoint,{headers:{Authorization:`Bearer ${f.access_token}`}});if(!R.ok)throw await C(R);const c=await R.json();return{accessToken:f.access_token,accessTokenExpiresAt:g,userInfo:{id:c.sub,name:c.name,fullname:c.preferred_username,email:c.email,emailVerified:c.email_verified,avatarUrl:c.picture,websiteUrl:c.website,isPro:c.isPro,orgs:(t=(e=c.orgs)==null?void 0:e.map(_=>({id:_.sub,name:_.name,fullname:_.name,isEnterprise:_.isEnterprise,canPay:_.canPay,avatarUrl:_.picture,roleInOrg:_.roleInOrg})))!=null?t:[]},state:u.state,scope:f.scope}}async function O(n){if(typeof window>"u")throw new Error("oauthHandleRedirect is only available in the browser");const e=new URLSearchParams(window.location.search);return e.has("error")?T():e.has("code")?localStorage.getItem("huggingface.co:oauth:nonce")?T():(console.warn("Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."),!1):!1}async function N(n){var e,t;if(typeof window>"u")throw new Error("oauthLogin is only available in the browser");const s=n?.hubUrl||P,o=`${new URL(s).origin}/.well-known/openid-configuration`,i=await fetch(o,{headers:{Accept:"application/json"}});if(!i.ok)throw await C(i);const r=await i.json(),l=globalThis.crypto.randomUUID(),d=globalThis.crypto.randomUUID()+globalThis.crypto.randomUUID();localStorage.setItem("huggingface.co:oauth:nonce",l),localStorage.setItem("huggingface.co:oauth:code_verifier",d);const a=n?.redirectUrl||window.location.href,u=JSON.stringify({nonce:l,redirectUri:a,state:n?.state}),h=(t=(e=window?.huggingface)==null?void 0:e.variables)!=null?t:null,p=n?.clientId||h?.OAUTH_CLIENT_ID;if(!p)throw h?new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space"):new Error("Missing clientId");const m=x(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256",new TextEncoder().encode(d)))).replace(/[+]/g,"-").replace(/[/]/g,"_").replace(/=/g,"");return`${r.authorization_endpoint}?${new URLSearchParams({client_id:p,scope:n?.scopes||h?.OAUTH_SCOPES||"openid profile",response_type:"code",redirect_uri:a,state:u,code_challenge:m,code_challenge_method:"S256"}).toString()}`}function S(n){return n*Math.PI/180}function B(n,e,t){const s=S(n),o=S(e),i=S(t),r=Math.cos(i),l=Math.sin(i),d=Math.cos(o),a=Math.sin(o),u=Math.cos(s),h=Math.sin(s);return[[r*d,r*a*h-l*u,r*a*u+l*h,0],[l*d,l*a*h+r*u,l*a*u-r*h,0],[-a,d*h,d*u,0],[0,0,0,1]]}function F(){if(typeof window>"u"||!window.location.hash)return;const n=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;let e;try{e=new URLSearchParams(n)}catch{return}const t=e.get("hf_token");if(!t)return;const s=e.get("hf_username")||"user",o=e.get("hf_token_expires"),i=o&&!Number.isNaN(new Date(o).getTime())?o:new Date(Date.now()+365*24*60*60*1e3).toISOString();try{sessionStorage.setItem("hf_token",t),sessionStorage.setItem("hf_username",s),sessionStorage.setItem("hf_token_expires",i)}catch(d){console.warn("[reachy-mini] could not persist pre-seeded HF credentials:",d)}e.delete("hf_token"),e.delete("hf_username"),e.delete("hf_token_expires");const r=e.toString(),l=window.location.pathname+window.location.search+(r?"#"+r:"");try{window.history.replaceState(null,"",l)}catch{}}function V(){if(typeof window>"u")return null;if(window.location.hash){const n=window.location.hash.startsWith("#")?window.location.hash.slice(1):window.location.hash;try{const e=new URLSearchParams(n).get("robot_peer_id");if(e)return e}catch{}}if(window.location.search)try{const n=new URLSearchParams(window.location.search).get("robot_peer_id");if(n)return n}catch{}return null}function q(n){const e=n.split(`\r
2
- `);let t=!1;for(const s of e)if(s.startsWith("m=audio")?t=!0:s.startsWith("m=")&&(t=!1),t&&s==="a=sendrecv")return!0;return!1}const w=12*1024,E=1*1024*1024,W=512*1024;function J(){return typeof CompressionStream<"u"}function U(){return"u"+Math.random().toString(36).slice(2,11)+Date.now().toString(36)}function k(n){let e="";for(let t=0;t<n.length;t+=32768)e+=String.fromCharCode.apply(null,Array.from(n.subarray(t,t+32768)));return btoa(e)}async function z(n){const e=new TextEncoder().encode(n),t=await new Response(new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer();return k(new Uint8Array(t))}function j(n){const e=Math.round(Number(n)||0);return Math.max(0,Math.min(100,e))}class Y extends EventTarget{_signalingUrl;_clientId;_appName;_videoJitterBufferTargetMs;_autoStartFromUrl;_autoStartAttempted;_state="disconnected";_robots=[];_robotState={};_preselectedRobotId;_token=null;_username=null;_tokenExpires=null;_sseAbortController=null;_pc=null;_dc=null;_sessionId=null;_selectedRobotId=null;_pendingRemoteIce=[];_micStream=null;_micMuted=!0;_audioMuted=!0;_micSupported=!1;_latencyMonitorId=null;_stateRefreshInterval=null;_versionResolve=null;_hardwareIdResolve=null;_volumeResolve=null;_micVolumeResolve=null;_applyAudioConfigResolve=null;_readAudioParameterResolve=null;_logSubscribers=new Set;_broadcastWaiters=[];_activeMoveUploadId=null;_activeAudioUploadId=null;_sessionResolve=null;_sessionReject=null;_iceConnected=!1;_dcOpen=!1;_pendingMotionCompletions={wake_up:[],goto_sleep:[]};_videoElement=null;constructor(e={}){super(),this._signalingUrl=e.signalingUrl||"https://pollen-robotics-reachy-mini-central.hf.space",this._clientId=e.clientId||null,this._appName=e.appName||"unknown",this._videoJitterBufferTargetMs=e.videoJitterBufferTargetMs??0,this._autoStartFromUrl=e.autoStartFromUrl===!0,this._autoStartAttempted=!1,this._preselectedRobotId=V()}get state(){return this._state}get robots(){return this._robots}get robotState(){return this._robotState}get username(){return this._username}get isAuthenticated(){return!!this._token}get micSupported(){return this._micSupported}get micMuted(){return this._micMuted}get audioMuted(){return this._audioMuted}get preselectedRobotId(){return this._preselectedRobotId}get isEmbedded(){return this._preselectedRobotId!==null}_maybeAutoStart(){if(!this._autoStartFromUrl||this._autoStartAttempted||!this._preselectedRobotId||this._state!=="connected"||!this._robots.find(t=>t.id===this._preselectedRobotId))return;this._autoStartAttempted=!0;const e=this._preselectedRobotId;setTimeout(()=>{this._state==="connected"&&this.startSession(e).catch(t=>{console.warn("[reachy-mini] autoStartFromUrl: startSession rejected:",t)})},0)}async authenticate(){try{F();const e=await O();if(e)return this._username=e.userInfo.preferred_username||e.userInfo.name||null,this._token=e.accessToken,this._tokenExpires=e.accessTokenExpiresAt,sessionStorage.setItem("hf_token",this._token),sessionStorage.setItem("hf_username",this._username??""),sessionStorage.setItem("hf_token_expires",typeof this._tokenExpires=="string"?this._tokenExpires:this._tokenExpires.toISOString()),!0;const t=sessionStorage.getItem("hf_token"),s=sessionStorage.getItem("hf_username"),o=sessionStorage.getItem("hf_token_expires");return t&&s&&o&&new Date(o)>new Date?(this._token=t,this._username=s,this._tokenExpires=o,!0):!1}catch(e){return console.error("Auth error:",e),!1}}async login(){const e={};this._clientId&&(e.clientId=this._clientId),window.location.href=await N(e)}logout(){sessionStorage.removeItem("hf_token"),sessionStorage.removeItem("hf_username"),sessionStorage.removeItem("hf_token_expires"),this._username=null,this._tokenExpires=null,this.disconnect()}async connect(e){if(this._state!=="disconnected")throw new Error("Already connected");if(e&&(this._token=e),!this._token)throw new Error("No token — call authenticate() first or pass a token");this._sseAbortController=new AbortController;let t;try{t=await fetch(`${this._signalingUrl}/events`,{signal:this._sseAbortController.signal,headers:{Authorization:`Bearer ${this._token}`}})}catch(s){throw this._sseAbortController=null,s}if(!t.ok)throw this._sseAbortController=null,new Error(`HTTP ${t.status}`);return new Promise((s,o)=>{let i=!1;const r=t.body.getReader(),l=new TextDecoder;let d="";(async()=>{try{for(;;){const{done:a,value:u}=await r.read();if(a)break;d+=l.decode(u,{stream:!0});const h=d.split(`
3
- `);d=h.pop()??"";for(const p of h)if(p.startsWith("data:"))try{const m=JSON.parse(p.slice(5).trim());!i&&m.type==="welcome"&&m.peerId&&(i=!0,this._state="connected",await this._sendToServer({type:"setPeerStatus",roles:["listener"],meta:{name:this._appName}}),this._emit("connected",{peerId:m.peerId}),s()),this._handleSignalingMessage(m)}catch{}}}catch(a){if(a.name!=="AbortError"&&this._emit("error",{source:"signaling",error:a}),!i){o(a);return}}this._state!=="disconnected"&&(this._state="disconnected",this._emit("disconnected",{reason:"SSE closed"})),i||o(new Error("Connection closed before welcome"))})()})}async autoConnect(e={}){const{token:t,pickRobot:s,autoPickIfSingle:o=!0,filterBusy:i=!0,wakeOnConnect:r=!0}=e;if(this._state==="streaming"){const d=this._robots?.find(a=>a.id===this._selectedRobotId);return{robotId:this._selectedRobotId,robotName:d?.meta?.name??null,isEmbedded:this.isEmbedded,alreadyStreaming:!0}}const l=this._autoStartFromUrl;this._autoStartFromUrl=!1;try{if(t)this._token=t;else if(!this._token&&!await this.authenticate())throw new Error("Not authenticated — call login() or pass a token");this._state==="disconnected"&&await this.connect();let d,a=null;if(this.isEmbedded){d=this._preselectedRobotId;try{await this._waitForRobotInList(d,5e3)}catch{}a=this._robots?.find(u=>u.id===d)?.meta?.name??null}else{const u=await this._fetchOwnedRobots({filterBusy:i});if(u.length===0)throw new Error("No reachable robots");if(o&&u.length===1&&!u[0].busy)d=u[0].id,a=u[0].name;else if(s){const h=await s(u);if(!h)throw new Error("Robot selection cancelled");d=h,a=u.find(p=>p.id===h)?.name??null}else throw new Error("Multiple robots available — pass a pickRobot callback to autoConnect()")}if(await this.startSession(d),r&&typeof this.ensureAwake=="function")try{await this.ensureAwake()}catch(u){console.warn("[reachy-mini] autoConnect: ensureAwake failed:",u)}return{robotId:d,robotName:a,isEmbedded:this.isEmbedded}}finally{this._autoStartFromUrl=l}}async _fetchOwnedRobots({filterBusy:e=!0}={}){try{const t=await fetch(`${this._signalingUrl}/api/robot-status`,{headers:{Authorization:`Bearer ${this._token}`}});if(!t.ok)throw new Error(`HTTP ${t.status}`);const s=await t.json(),o=new Map;for(const i of s.robots||[]){if(e&&i.busy)continue;const r=i.meta?.install_id??i.meta?.hardware_id??i.peerId;o.set(r,{id:i.peerId,name:i.robotName??i.meta?.name??null,busy:!!i.busy,activeApp:i.activeApp??null,meta:i.meta??{},lastSeenAgeSeconds:i.last_seen_age_seconds??null})}return Array.from(o.values()).sort((i,r)=>(i.lastSeenAgeSeconds??1/0)-(r.lastSeenAgeSeconds??1/0))}catch(t){return console.warn("[reachy-mini] /api/robot-status unavailable, using SSE list:",t),(this._robots||[]).map(s=>({id:s.id,name:s.meta?.name??null,busy:!1,activeApp:null,meta:s.meta??{},lastSeenAgeSeconds:null}))}}_waitForRobotInList(e,t){return this._robots?.find(s=>s.id===e)?Promise.resolve():new Promise((s,o)=>{const i=()=>{this._robots?.find(l=>l.id===e)&&(this.removeEventListener("robotsChanged",i),clearTimeout(r),s())},r=setTimeout(()=>{this.removeEventListener("robotsChanged",i),o(new Error(`Timeout waiting for robot ${e} in list`))},t);this.addEventListener("robotsChanged",i)})}async startSession(e){if(this._state!=="connected")throw new Error("Not connected");this._selectedRobotId=e,this._iceConnected=!1,this._dcOpen=!1,this._micSupported=!1,this._pendingRemoteIce=[];try{const t=window,s=t.AudioContext??t.webkitAudioContext;if(!s)throw new Error("AudioContext not supported");const o=new s,i=o.createMediaStreamDestination(),r=o.createOscillator(),l=o.createGain();l.gain.value=0,r.connect(l).connect(i),r.start();const d=i.stream;d.getAudioTracks().forEach(a=>{a.enabled=!1}),this._micStream=d,this._micMuted=!0}catch(t){console.warn("Audio sender placeholder setup failed:",t),this._micStream=null}return this._pc=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),new Promise((t,s)=>{this._sessionResolve=t,this._sessionReject=s,this._pc.ontrack=o=>{if(o.track.kind==="video"){const i=this._videoJitterBufferTargetMs;try{o.receiver.jitterBufferTarget=i}catch{}try{o.receiver.playoutDelayHint=i/1e3}catch{}this._emit("videoTrack",{track:o.track,stream:o.streams[0]})}},this._pc.onicecandidate=async o=>{o.candidate&&this._sessionId&&await this._sendToServer({type:"peer",sessionId:this._sessionId,ice:{candidate:o.candidate.candidate,sdpMLineIndex:o.candidate.sdpMLineIndex,sdpMid:o.candidate.sdpMid}})},this._pc.oniceconnectionstatechange=()=>{const o=this._pc?.iceConnectionState;if(o)if(o==="connected"||o==="completed")this._iceConnected=!0,this._checkSessionReady();else if(o==="failed"){const i=new Error("ICE connection failed");this._sessionReject&&(this._sessionReject(i),this._sessionResolve=null,this._sessionReject=null),this._emit("error",{source:"webrtc",error:i})}else o==="disconnected"&&this._emit("error",{source:"webrtc",error:new Error("ICE disconnected")})},this._pc.ondatachannel=o=>{this._dc=o.channel,this._dc.onopen=()=>{this._dcOpen=!0,this._checkSessionReady()},this._dc.onmessage=i=>this._handleRobotMessage(JSON.parse(i.data))},this._sendToServer({type:"startSession",peerId:e}).then(o=>{if(o?.type==="sessionRejected"){this._failSessionRejected(o);return}o?.sessionId&&(this._sessionId=o.sessionId)})})}_failSessionRejected(e){const t=new Error(e.reason==="robot_busy"?`Robot is busy: "${e.activeApp||"another app"}" is already connected`:`Session rejected: ${e.reason||"unknown reason"}`);if(t.reason=e.reason??null,t.activeApp=e.activeApp??null,this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(s=>s.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1,this._emit("sessionRejected",{reason:e.reason,activeApp:e.activeApp}),this._sessionReject){const s=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,s(t)}}async stopSession(){this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Session stopped")),this._sessionReject&&(this._sessionReject(new Error("Session stopped")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&await this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._micMuted=!0,this._micSupported=!1,this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._iceConnected=!1,this._dcOpen=!1,this._state==="streaming"&&(this._state="connected",this._emit("sessionStopped",{reason:"user"}))}disconnect(){this._sseAbortController&&(this._sseAbortController.abort(),this._sseAbortController=null),this._versionResolve&&(this._versionResolve(null),this._versionResolve=null),this._hardwareIdResolve&&(this._hardwareIdResolve(null),this._hardwareIdResolve=null),this._volumeResolve&&(this._volumeResolve(null),this._volumeResolve=null),this._micVolumeResolve&&(this._micVolumeResolve(null),this._micVolumeResolve=null),this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(!1),this._applyAudioConfigResolve=null),this._readAudioParameterResolve&&(this._readAudioParameterResolve(null),this._readAudioParameterResolve=null),this._logSubscribers.clear(),this._rejectPendingMotionCompletions(new Error("Disconnected")),this._sessionReject&&(this._sessionReject(new Error("Disconnected")),this._sessionResolve=null,this._sessionReject=null),this._stateRefreshInterval&&(clearInterval(this._stateRefreshInterval),this._stateRefreshInterval=null),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),this._sessionId&&this._token&&this._sendToServer({type:"endSession",sessionId:this._sessionId}),this._micStream&&(this._micStream.getTracks().forEach(e=>e.stop()),this._micStream=null),this._pc&&(this._pc.close(),this._pc=null),this._dc&&(this._dc.close(),this._dc=null),this._sessionId=null,this._micMuted=!0,this._micSupported=!1,this._iceConnected=!1,this._dcOpen=!1,this._robots=[],this._state="disconnected",this._emit("disconnected",{reason:"user"})}setTarget({head:e,antennas:t,body_yaw:s}={}){const o={type:"set_full_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);o.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(i=>Number.isFinite(i)))throw new TypeError(`setTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);o.antennas=t}if(s!==void 0){if(!Number.isFinite(s))throw new TypeError(`setTarget: body_yaw must be a finite number (radians); got ${s}`);o.body_yaw=s}return this._sendCommand(o)}gotoTarget({head:e,antennas:t,body_yaw:s,duration:o}){const i={type:"goto_target"};if(e!==void 0){if(!Array.isArray(e)||e.length!==16||!e.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: head must be a 16-element flat row-major 4×4 matrix of finite numbers; got ${Array.isArray(e)?`Array(${e.length})`:typeof e}`);i.head=e}if(t!==void 0){if(!Array.isArray(t)||t.length!==2||!t.every(r=>Number.isFinite(r)))throw new TypeError(`gotoTarget: antennas must be [rightRad, leftRad] (2 finite numbers); got ${Array.isArray(t)?`Array(${t.length})`:typeof t}`);i.antennas=t}if(s!==void 0){if(!Number.isFinite(s))throw new TypeError(`gotoTarget: body_yaw must be a finite number (radians); got ${s}`);i.body_yaw=s}if(!Number.isFinite(o)||o<=0)throw new TypeError(`gotoTarget: duration must be a positive finite number (seconds); got ${o}`);return i.duration=o,this._sendCommand(i)}setHeadRpyDeg(e,t,s){return this.setTarget({head:B(e,t,s).flat()})}setAntennasDeg(e,t){return this.setTarget({antennas:[S(e),S(t)]})}setBodyYawDeg(e){return this.setTarget({body_yaw:S(e)})}playSound(e){return this._sendCommand({type:"play_sound",file:e})}setMotorMode(e){return this._sendCommand({type:"set_motor_mode",mode:e})}setMotorTorque(e,t=null){return this._sendCommand({type:"set_torque",on:e,ids:t})}wakeUp({timeoutMs:e=8e3}={}){return this._sendCommand({type:"set_motor_mode",mode:"enabled"}),this._sendCommandAwaitCompletion("wake_up",e)}gotoSleep({timeoutMs:e=8e3}={}){return this._sendCommandAwaitCompletion("goto_sleep",e)}_sendCommandAwaitCompletion(e,t){return this._sendCommand({type:e})?new Promise((s,o)=>{const i={resolve:s,reject:o,timer:setTimeout(()=>{const r=this._pendingMotionCompletions[e],l=r.indexOf(i);l!==-1&&r.splice(l,1),o(new Error(`${e} timed out after ${t}ms`))},t)};this._pendingMotionCompletions[e].push(i)}):Promise.reject(new Error(`${e}: data channel not open`))}_rejectPendingMotionCompletions(e){for(const t of Object.keys(this._pendingMotionCompletions)){const s=this._pendingMotionCompletions[t];for(;s.length;){const o=s.shift();clearTimeout(o.timer),o.reject(e)}}}isAwake(){const e=this._robotState?.motor_mode;return e==="enabled"||e==="gravity_compensation"}async ensureAwake(e=1e3){return this._robotState?.motor_mode===void 0&&await new Promise(t=>{const s=()=>{this.removeEventListener("state",s),clearTimeout(o),t()},o=setTimeout(s,e);this.addEventListener("state",s),this.requestState()}),this.isAwake()||this.wakeUp().catch(()=>{}),!0}getVersion(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._versionResolve&&this._versionResolve(null),this._versionResolve=e,this._sendCommand({type:"get_version"})})}getHardwareId(){return new Promise((e,t)=>{if(!this._dc||this._dc.readyState!=="open"){t(new Error("Data channel not open"));return}this._hardwareIdResolve&&this._hardwareIdResolve(null),this._hardwareIdResolve=e,this._sendCommand({type:"get_hardware_id"})})}getVolume(){return this._slotRoundtrip(()=>this._volumeResolve,e=>{this._volumeResolve=e},{type:"get_volume"})}setVolume(e){return this._slotRoundtrip(()=>this._volumeResolve,t=>{this._volumeResolve=t},{type:"set_volume",volume:j(e)})}getMicrophoneVolume(){return this._slotRoundtrip(()=>this._micVolumeResolve,e=>{this._micVolumeResolve=e},{type:"get_microphone_volume"})}setMicrophoneVolume(e){return this._slotRoundtrip(()=>this._micVolumeResolve,t=>{this._micVolumeResolve=t},{type:"set_microphone_volume",volume:j(e)})}applyAudioConfig(e,{verify:t=!0}={}){return this._slotRoundtrip(()=>this._applyAudioConfigResolve,s=>{this._applyAudioConfigResolve=s},{type:"apply_audio_config",config:e,verify:t}).then(s=>s===!0)}readAudioParameter(e){return this._slotRoundtrip(()=>this._readAudioParameterResolve,t=>{this._readAudioParameterResolve=t},{type:"read_audio_parameter",name:e})}_slotRoundtrip(e,t,s){return new Promise((o,i)=>{if(!this._dc||this._dc.readyState!=="open"){i(new Error("Data channel not open"));return}const r=e();r&&r(null),t(o),this._sendCommand(s)})}sendRaw(e){return this._sendCommand(e)}subscribeLogs({onLine:e,onError:t}){if(typeof e!="function")throw new TypeError("subscribeLogs: onLine callback is required");const s={onLine:e,onError:t},o=this._logSubscribers.size===0;this._logSubscribers.add(s),o&&this._sendCommand({type:"subscribe_logs"});let i=!1;return()=>{i||(i=!0,this._logSubscribers.delete(s),this._logSubscribers.size===0&&this._sendCommand({type:"unsubscribe_logs"}))}}requestState(){return this._sendCommand({type:"get_state"})}setAudioMuted(e){this._audioMuted=e,this._videoElement&&(this._videoElement.muted=e)}setMicMuted(e){this._micMuted=e,this._micStream&&this._micStream.getAudioTracks().forEach(t=>{t.enabled=!e})}attachVideo(e){this._videoElement=e,e.muted=this._audioMuted;const t=o=>{const i=o;e.srcObject=i.detail.stream,e.playsInline=!0,"requestVideoFrameCallback"in e&&this._startLatencyMonitor(e)},s=()=>{e.srcObject=null};return this.addEventListener("videoTrack",t),this.addEventListener("sessionStopped",s),()=>{this.removeEventListener("videoTrack",t),this.removeEventListener("sessionStopped",s),this._latencyMonitorId&&(clearInterval(this._latencyMonitorId),this._latencyMonitorId=null),e.srcObject=null,this._videoElement=null}}async playMove(e,{audioBlob:t=null,audioLeadMs:s=-100,description:o="move",encoding:i="gzip+base64",playFrequency:r=100,initialGotoDuration:l=0,startTimeoutMs:d=8e3,onProgress:a=()=>{},onStarted:u=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!e?.time?.length||!e?.set_target_data?.length)throw new Error("playMove: motion must have time + set_target_data");const h=U();this._activeMoveUploadId=h;const p={description:o,time:e.time,set_target_data:e.set_target_data},m=JSON.stringify(p);let y,v;i==="gzip+base64"&&J()?(y=await z(m),v="gzip+base64"):(y=m,v="json");const f=Math.ceil(y.length/w)||1;a({phase:"starting",sent:0,total:f,bytes:y.length,encoding:v}),this._sendCommand({type:"upload_move_start",upload_id:h,total_chunks:f,description:o,encoding:v});for(let c=0;c<f;c++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const _=c*w;this._sendCommand({type:"upload_move_chunk",upload_id:h,chunk_index:c,chunk:y.slice(_,_+w)}),a({phase:"upload",sent:c+1,total:f})}if(this._sendCommand({type:"upload_move_finish",upload_id:h}),a({phase:"uploaded",sent:f,total:f}),t){const c=new Uint8Array(await t.arrayBuffer()),_=k(c),b=Math.ceil(_.length/w)||1;a({phase:"audio-starting",sent:0,total:b,bytes:_.length}),this._sendCommand({type:"upload_audio_start",upload_id:h,total_chunks:b,encoding:"wav-base64",description:o});for(let I=0;I<b;I++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const M=I*w;this._sendCommand({type:"upload_audio_chunk",upload_id:h,chunk_index:I,chunk:_.slice(M,M+w)}),a({phase:"audio-upload",sent:I+1,total:b})}this._sendCommand({type:"upload_audio_finish",upload_id:h}),a({phase:"audio-uploaded",sent:b,total:b})}this._sendCommand({type:"play_uploaded_move",upload_id:h,play_frequency:r,initial_goto_duration:l,audio_lead_ms:s});let g;try{g=await this._waitForBroadcast(c=>c?.type==="play_uploaded_move"&&c?.upload_id===h&&(c.started===!0||typeof c.error=="string"),{timeoutMs:d,debugLabel:"play_uploaded_move started"})}catch(c){throw new Error(`Daemon did not respond to play_uploaded_move (requires the reachy_mini daemon with feature/daemon-side-move-upload). Underlying: ${c.message}`)}if(typeof g.error=="string")throw new Error(`play_uploaded_move: ${g.error}`);try{u({duration_s:g.duration_s,has_audio:g.has_audio===!0})}catch(c){console.warn("playMove.onStarted threw:",c)}a({phase:"playing",duration_s:g.duration_s});const R=await this._waitForBroadcast(c=>c?.type==="play_uploaded_move"&&c?.upload_id===h&&(c.finished===!0||c.cancelled===!0||typeof c.error=="string"),{timeoutMs:(g.duration_s+30)*1e3,debugLabel:"play_uploaded_move final"});return this._activeMoveUploadId===h&&(this._activeMoveUploadId=null),R}cancelMove(e=null){const t=e??this._activeMoveUploadId;return t?this._sendCommand({type:"cancel_move",upload_id:t}):!1}async uploadAudio(e,{description:t="audio",onProgress:s=()=>{}}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");if(!(e instanceof Blob))throw new TypeError("uploadAudio: expected a Blob");const o=U(),i=new Uint8Array(await e.arrayBuffer()),r=k(i),l=Math.ceil(r.length/w)||1;s({phase:"audio-starting",sent:0,total:l,bytes:r.length}),this._sendCommand({type:"upload_audio_start",upload_id:o,total_chunks:l,encoding:"wav-base64",description:t});for(let d=0;d<l;d++){this._dc.bufferedAmount>E&&await this._awaitDataChannelDrain();const a=d*w;this._sendCommand({type:"upload_audio_chunk",upload_id:o,chunk_index:d,chunk:r.slice(a,a+w)}),s({phase:"audio-upload",sent:d+1,total:l})}return this._sendCommand({type:"upload_audio_finish",upload_id:o}),s({phase:"audio-uploaded",sent:l,total:l}),o}async playUploadedAudio(e,{timeoutMs:t=8e3}={}){if(!this._dc||this._dc.readyState!=="open")throw new Error("data channel not open");const s=this._waitForBroadcast(i=>i?.type==="play_uploaded_audio"&&i?.upload_id===e&&(i.started===!0||typeof i.error=="string"),{timeoutMs:t,debugLabel:"play_uploaded_audio started"});this._sendCommand({type:"play_uploaded_audio",upload_id:e});const o=await s;if(typeof o.error=="string")throw new Error(o.error);return this._activeAudioUploadId=e,o}cancelAudio(e=null){const t=e??this._activeAudioUploadId;return t?(this._activeAudioUploadId===t&&(this._activeAudioUploadId=null),this._sendCommand({type:"cancel_audio",upload_id:t})):!1}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t}))}_waitForBroadcast(e,{timeoutMs:t=5e3,debugLabel:s=""}={}){return new Promise((o,i)=>{const r={predicate:e,resolve:o,timer:setTimeout(()=>{const l=this._broadcastWaiters.indexOf(r);l!==-1&&this._broadcastWaiters.splice(l,1),i(new Error(`broadcast timeout (${t} ms): ${s}`))},t)};this._broadcastWaiters.push(r)})}async _awaitDataChannelDrain(){for(;this._dc&&this._dc.bufferedAmount>W;)if(await new Promise(e=>setTimeout(e,30)),!this._dc||this._dc.readyState!=="open")throw new Error("data channel closed mid-upload")}async _sendToServer(e){if(!this._token)throw new Error("No token — authenticate() first");try{const t=await fetch(`${this._signalingUrl}/send`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this._token}`},body:JSON.stringify(e)});if(!t.ok){let s="";try{s=await t.text()}catch{}return console.warn(`[reachy-mini] /send rejected (${t.status}) for type=${e?.type}; body=${s||"<empty>"}`),null}return await t.json()}catch(t){return console.error("Send error:",t),null}}_sendCommand(e){return!this._dc||this._dc.readyState!=="open"?!1:(this._dc.send(JSON.stringify(e)),!0)}_checkSessionReady(){this._iceConnected&&this._dcOpen&&this._sessionResolve&&(this._state="streaming",this.requestState(),this._stateRefreshInterval=setInterval(()=>this.requestState(),500),this._emit("streaming",{sessionId:this._sessionId,robotId:this._selectedRobotId}),this._sessionResolve(),this._sessionResolve=null,this._sessionReject=null)}async _handleSignalingMessage(e){switch(e.type){case"welcome":break;case"list":this._robots=e.producers||[],this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart();break;case"peerStatusChanged":{const t=await this._sendToServer({type:"list"});t?.producers&&(this._robots=t.producers,this._emit("robotsChanged",{robots:this._robots}),this._maybeAutoStart());break}case"sessionStarted":this._sessionId=e.sessionId??null;break;case"sessionRejected":this._failSessionRejected(e);break;case"endSession":this._handleEndSession(e);break;case"peer":this._handlePeerMessage(e);break}}_handleEndSession(e){const t=e.reason,s=t==="robot_busy_local_app"?"Robot is busy: a local Python app is running":t==="local_app_started"?"Disconnected: a local Python app started on the robot":t==="robot_busy_local"?"Robot is busy: another session is already active":null;if(this._sessionReject){const o=new Error(s||`Session ended before it could start: ${t||"unknown reason"}`);o.reason=t??null,this._emit("sessionRejected",{reason:t,activeApp:null}),this._pc&&(this._pc.close(),this._pc=null),this._micStream&&(this._micStream.getTracks().forEach(r=>r.stop()),this._micStream=null),this._iceConnected=!1,this._dcOpen=!1,this._micMuted=!0,this._micSupported=!1;const i=this._sessionReject;this._sessionResolve=null,this._sessionReject=null,i(o);return}this._state==="streaming"&&(this._emit("sessionStopped",{reason:t||"remote_end",message:s}),this.stopSession().catch(()=>{}))}async _handlePeerMessage(e){if(this._pc)try{if(e.sdp){const t=e.sdp;if(t.type==="offer"){const o=q(t.sdp);if(this._micSupported=o,this._emit("micSupported",{supported:o}),o&&this._micStream)for(const r of this._micStream.getAudioTracks())this._pc.addTrack(r,this._micStream);await this._pc.setRemoteDescription(new RTCSessionDescription(t));const i=await this._pc.createAnswer();await this._pc.setLocalDescription(i),await this._sendToServer({type:"peer",sessionId:this._sessionId,sdp:{type:"answer",sdp:i.sdp}})}else await this._pc.setRemoteDescription(new RTCSessionDescription(t));const s=this._pendingRemoteIce;if(s&&s.length){this._pendingRemoteIce=[];for(const o of s)try{await this._pc.addIceCandidate(new RTCIceCandidate(o))}catch(i){console.warn("[reachy-mini] buffered ICE candidate rejected:",i)}}}if(e.ice){if(!e.ice.candidate)return;this._pc.remoteDescription?await this._pc.addIceCandidate(new RTCIceCandidate(e.ice)):(this._pendingRemoteIce||(this._pendingRemoteIce=[]),this._pendingRemoteIce.push(e.ice))}}catch(t){console.error("WebRTC error:",t),this._emit("error",{source:"webrtc",error:t})}}_handleRobotMessage(e){if("version"in e&&this._versionResolve){this._versionResolve(e.version),this._versionResolve=null;return}if("hardware_id"in e&&this._hardwareIdResolve){this._hardwareIdResolve(e.hardware_id),this._hardwareIdResolve=null;return}if(e.command==="get_volume"||e.command==="set_volume"){this._volumeResolve&&(this._volumeResolve(e.status==="error"?null:e.volume),this._volumeResolve=null);return}if(e.command==="get_microphone_volume"||e.command==="set_microphone_volume"){this._micVolumeResolve&&(this._micVolumeResolve(e.status==="error"?null:e.volume),this._micVolumeResolve=null);return}if(e.command==="apply_audio_config"){this._applyAudioConfigResolve&&(this._applyAudioConfigResolve(e.error?!1:!!e.applied),this._applyAudioConfigResolve=null);return}if(e.command==="read_audio_parameter"){this._readAudioParameterResolve&&(this._readAudioParameterResolve(e.error?null:e.values??null),this._readAudioParameterResolve=null);return}if((e.command==="wake_up"||e.command==="goto_sleep")&&this._pendingMotionCompletions&&this._pendingMotionCompletions[e.command]){const t=this._pendingMotionCompletions[e.command];if(e.completed===!0&&t.length>0){const s=t.shift();clearTimeout(s.timer),s.resolve();return}if(e.error&&t.length>0){const s=t.shift();clearTimeout(s.timer),s.reject(new Error(`${e.command}: ${e.error}`));return}}if(e.type==="log_line"){for(const t of this._logSubscribers)try{t.onLine({timestamp:e.timestamp,line:e.line})}catch(s){console.error("subscribeLogs onLine threw:",s)}return}if(e.type==="log_stream_error"){for(const t of this._logSubscribers)if(typeof t.onError=="function")try{t.onError(e.error)}catch(s){console.error("subscribeLogs onError threw:",s)}return}if(e.state){const t=e.state;t.head_pose&&(this._robotState.head=t.head_pose.flat()),t.antennas&&(this._robotState.antennas=[t.antennas[0],t.antennas[1]]),typeof t.body_yaw=="number"&&(this._robotState.body_yaw=t.body_yaw),t.motor_mode&&(this._robotState.motor_mode=t.motor_mode),typeof t.is_move_running=="boolean"&&(this._robotState.is_move_running=t.is_move_running),this._emit("state",{...this._robotState})}if(e.error&&this._emit("error",{source:"robot",error:e.error}),this._broadcastWaiters.length>0)for(let t=this._broadcastWaiters.length-1;t>=0;t--){const s=this._broadcastWaiters[t];if(s.predicate(e)){this._broadcastWaiters.splice(t,1),clearTimeout(s.timer),s.resolve(e);return}}}_startLatencyMonitor(e){this._latencyMonitorId&&clearInterval(this._latencyMonitorId),this._latencyMonitorId=setInterval(()=>{if(!e.srcObject||e.paused)return;const t=e.buffered;if(t.length>0){const s=t.end(t.length-1),o=s-e.currentTime;o>.5&&(console.log(`Latency correction: was ${o.toFixed(2)}s behind`),e.currentTime=s-.1)}},2e3)}}const K=1,H="reachy-mini";function Q(n){if(!n||typeof n!="object")return!1;const e=n;return e.source===H&&typeof e.type=="string"&&e.version===1}function Z(n){const e=JSON.stringify(n),t=G(e);return`creds=${encodeURIComponent(t)}`}function ee(n){if(!n)return null;const e=n.startsWith("#")?n.slice(1):n;for(const t of e.split("&"))if(t.startsWith("creds="))try{const s=decodeURIComponent(t.slice(6)),o=X(s);return JSON.parse(o)}catch(s){return console.warn("[reachy-mini-sdk/host] failed to decode creds hash",s),null}return null}function G(n){if(typeof window<"u"&&typeof window.btoa=="function"){const e=new TextEncoder().encode(n);let t="";for(const s of e)t+=String.fromCharCode(s);return window.btoa(t)}return Buffer.from(n,"utf8").toString("base64")}function X(n){if(typeof window<"u"&&typeof window.atob=="function"){const e=window.atob(n),t=new Uint8Array(e.length);for(let s=0;s<e.length;s++)t[s]=e.charCodeAt(s);return new TextDecoder().decode(t)}return Buffer.from(n,"base64").toString("utf8")}export{Y as J,ee as a,H as c,Q as d,K as f,Z as u};
 
 
 
 
icon.svg DELETED
index.html CHANGED
@@ -1,100 +1,1160 @@
1
  <!doctype html>
2
  <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta
6
- name="viewport"
7
- content="width=device-width, initial-scale=1.0, viewport-fit=cover"
8
- />
9
- <title>Reachy Mini · Dashboard</title>
10
- <link rel="icon" href="/icon.svg" type="image/svg+xml" />
11
-
12
- <link rel="preconnect" href="https://fonts.googleapis.com" />
13
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
- <link
15
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
16
- rel="stylesheet"
17
- />
18
-
19
- <!--
20
- Theme bootstrap: paints the right palette BEFORE the React tree
21
- mounts, so there's no flash of the wrong palette. Resolution
22
- order:
23
- 1. `?theme=dark|light` query param (host shell sets this when
24
- iframing us, mobile catalog deep-links too)
25
- 2. `prefers-color-scheme` (standalone)
26
- The MUI theme picks the same source-of-truth on mount.
27
- -->
 
 
28
  <script>
29
- (function () {
30
- try {
31
- var params = new URLSearchParams(window.location.search);
32
- var raw = params.get("theme");
33
- var mode;
34
- if (raw === "dark" || raw === "light") {
35
- mode = raw;
36
- } else if (
37
- window.matchMedia &&
38
- window.matchMedia("(prefers-color-scheme: dark)").matches === false
39
- ) {
40
- mode = "light";
41
- } else {
42
- mode = "dark";
43
- }
44
- document.documentElement.setAttribute("data-theme", mode);
45
- document.documentElement.style.backgroundColor =
46
- mode === "dark" ? "#101013" : "#fafafa";
47
- } catch (_) {
48
- document.documentElement.setAttribute("data-theme", "dark");
49
- }
50
- })();
51
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- <!--
54
- Hugging Face Spaces helper variables.
55
-
56
- - In production (`sdk: static` + `hf_oauth: true`), HF substitutes
57
- these placeholders inside any `.html` file at the Space root at
58
- serve time. The substituted values get exposed on
59
- `window.huggingface.variables`, where the SDK picks them up.
60
- - In `npm run dev`, the placeholders stay untouched (the leading
61
- `__` gives them away); we detect that and drop the global so
62
- the SDK falls back to whatever was passed to `mountHost({
63
- clientId, devToken })`.
64
- -->
65
- <script>
66
- (function () {
67
- var clientId = "__OAUTH_CLIENT_ID__";
68
- var scopes = "__OAUTH_SCOPES__";
69
- var spaceHost = "__SPACE_HOST__";
70
- var spaceId = "__SPACE_ID__";
71
- var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
72
- if (looksSubstituted) {
73
- window.huggingface = window.huggingface || {};
74
- window.huggingface.variables = {
75
- OAUTH_CLIENT_ID: clientId,
76
- OAUTH_SCOPES:
77
- scopes && scopes.indexOf("__") !== 0
78
- ? scopes
79
- : "openid profile",
80
- SPACE_HOST:
81
- spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
82
- SPACE_ID:
83
- spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
84
- };
85
- }
86
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </script>
88
- <script type="module" crossorigin src="/assets/index-CsmyDA4N.js"></script>
89
- </head>
90
- <body>
91
- <div id="root"></div>
92
- <!--
93
- Single dispatcher. Picks the host shell (standalone visit) or
94
- the embedded app (host's iframe) based on `?embedded=1`.
95
- `src/dispatch.ts` also self-assigns `window.ReachyMini` so the
96
- host's own bootstrap (which historically read it from a CDN
97
- `<script>`) keeps working without a CDN dep.
98
- -->
99
- </body>
100
  </html>
 
1
  <!doctype html>
2
  <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
6
+ <title>Reachy Mini - Pollen Robotics</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link 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">
75
+ <img src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
76
+ <div class="logo-text">Reachy Mini <span>by Pollen Robotics</span></div>
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
+
88
+ <div class="app-container">
89
+ <!-- Video -->
90
+ <div class="video-container">
91
+ <video id="remoteVideo" autoplay playsinline muted></video>
92
+
93
+ <div class="video-overlay-top">
94
+ <div class="connection-badge">
95
+ <div class="status-indicator" id="statusIndicator"></div>
96
+ <span id="statusText">Disconnected</span>
97
+ </div>
98
+ <div class="robot-name" id="robotName"></div>
99
+ <div class="latency-badge hidden" id="latencyBadge">
100
+ <span id="latencyValue">--</span>
101
+ </div>
102
+ </div>
103
+
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>
120
+ <line x1="23" y1="9" x2="17" y2="15"></line>
121
+ <line x1="17" y1="9" x2="23" y2="15"></line>
122
+ </svg>
123
+ <svg id="speakerOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
124
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
125
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
126
+ </svg>
127
+ <span id="muteText">Unmute</span>
128
+ </button>
129
+ <button class="btn btn-mute muted" id="micBtn" onclick="toggleMic()" disabled>
130
+ <svg id="micOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
131
+ <line x1="1" y1="1" x2="23" y2="23"></line>
132
+ <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
133
+ <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.13 1.49-.35 2.17"></path>
134
+ <line x1="12" y1="19" x2="12" y2="23"></line>
135
+ <line x1="8" y1="23" x2="16" y2="23"></line>
136
+ </svg>
137
+ <svg id="micOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
138
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
139
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
140
+ <line x1="12" y1="19" x2="12" y2="23"></line>
141
+ <line x1="8" y1="23" x2="16" y2="23"></line>
142
+ </svg>
143
+ <span id="micText">Mic Off</span>
144
+ </button>
145
+ </div>
146
+ </div>
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">
162
+ <div class="panel-header">Head Orientation</div>
163
+ <div class="panel-content">
164
+ <div class="slider-row">
165
+ <span class="slider-label">Roll</span>
166
+ <input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0" step="0.5">
167
+ <span class="slider-value" id="rollValue">0.0°</span>
168
+ </div>
169
+ <div class="slider-row">
170
+ <span class="slider-label">Pitch</span>
171
+ <input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0" step="0.5">
172
+ <span class="slider-value" id="pitchValue">0.0°</span>
173
+ </div>
174
+ <div class="slider-row">
175
+ <span class="slider-label">Yaw</span>
176
+ <input type="range" class="slider" id="yawSlider" min="-160" max="160" value="0" step="0.5">
177
+ <span class="slider-value" id="yawValue">0.0°</span>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Body / Base Yaw -->
183
+ <div class="panel">
184
+ <div class="panel-header">Body</div>
185
+ <div class="panel-content">
186
+ <div class="slider-row">
187
+ <span class="slider-label">Yaw</span>
188
+ <input type="range" class="slider" id="bodyYawSlider" min="-160" max="160" value="0" step="1">
189
+ <span class="slider-value" id="bodyYawValue">0°</span>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <!-- Antennas -->
195
+ <div class="panel">
196
+ <div class="panel-header">Antennas</div>
197
+ <div class="panel-content">
198
+ <div class="slider-row">
199
+ <span class="slider-label">Right</span>
200
+ <input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0">
201
+ <span class="slider-value" id="rightAntValue">0°</span>
202
+ </div>
203
+ <div class="slider-row">
204
+ <span class="slider-label">Left</span>
205
+ <input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0">
206
+ <span class="slider-value" id="leftAntValue">0°</span>
207
+ </div>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- Animations -->
212
+ <div class="panel">
213
+ <div class="panel-header">Animations</div>
214
+ <div class="panel-content">
215
+ <div class="sound-row">
216
+ <button class="btn btn-primary" id="btnWakeUp" onclick="playWakeUp()" disabled>Wake up</button>
217
+ <button class="btn btn-secondary" id="btnGotoSleep" onclick="playGotoSleep()" disabled>Go to sleep</button>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ <!-- Torque -->
223
+ <div class="panel">
224
+ <div class="panel-header">Torque</div>
225
+ <div class="panel-content">
226
+ <div class="sound-row">
227
+ <!-- Label and action flip based on the current motor
228
+ mode, which is surfaced via the 'state' event. -->
229
+ <button class="btn btn-primary" id="btnTorqueToggle" onclick="toggleTorque()" disabled>Torque …</button>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <!-- Sound -->
235
+ <div class="panel">
236
+ <div class="panel-header">Sound</div>
237
+ <div class="panel-content">
238
+ <div class="sound-row">
239
+ <input type="text" class="sound-input" id="soundInput" placeholder="Sound file...">
240
+ <button class="btn btn-primary" id="btnPlaySound" onclick="playSound()" disabled>Play</button>
241
+ </div>
242
+ <div class="sound-presets">
243
+ <span class="preset-chip" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
244
+ <span class="preset-chip" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
245
+ <span class="preset-chip" onclick="playSoundPreset('yes.wav')">yes</span>
246
+ <span class="preset-chip" onclick="playSoundPreset('no.wav')">no</span>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ <!-- Volume -->
252
+ <div class="panel">
253
+ <div class="panel-header">Volume</div>
254
+ <div class="panel-content">
255
+ <div class="slider-row">
256
+ <span class="slider-label">Speaker</span>
257
+ <input type="range" class="slider" id="volumeSlider" min="0" max="100" value="50" disabled>
258
+ <span class="slider-value" id="volumeValue">--</span>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <!-- Daemon logs -->
264
+ <div class="panel">
265
+ <div class="panel-header">
266
+ Daemon logs
267
+ <button class="btn btn-secondary" id="btnClearLogs" onclick="clearLogs()" style="margin-left:auto;font-size:0.8em;padding:4px 8px;">Clear</button>
268
+ </div>
269
+ <div class="panel-content">
270
+ <pre id="logsView" style="max-height:240px;overflow:auto;background:#0b0b0b;color:#c8c8c8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:8px;margin:0;white-space:pre-wrap;word-break:break-word;border-radius:6px;">Waiting for stream...</pre>
271
+ </div>
272
+ </div>
273
+
274
+ </div>
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;
372
+ window.playGotoSleep = playGotoSleep;
373
+ window.toggleTorque = toggleTorque;
374
+ window.toggleMute = toggleMute;
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');
501
+ // Reset the torque button label so it doesn't claim a stale
502
+ // state between sessions.
503
+ const tb = document.getElementById('btnTorqueToggle');
504
+ tb.textContent = 'Torque …';
505
+ delete tb.dataset.currentMode;
506
+ stopLogsStream();
507
+ });
508
+
509
+ robot.addEventListener('state', (e) => updateStateDisplay(e.detail));
510
+
511
+ robot.addEventListener('micSupported', () => {
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') {
524
+ updateStatus('', 'Connection lost');
525
+ }
526
+ });
527
+ }
528
+
529
+ // ===================== Connection =====================
530
+ function updateStatus(status, text) {
531
+ document.getElementById('statusIndicator').className = 'status-indicator ' + status;
532
+ document.getElementById('statusText').textContent = text;
533
+ }
534
+
535
+ // Wake-on-stream is handled inside autoConnect() now (it calls
536
+ // robot.ensureAwake() after startSession resolves), so no
537
+ // ensureAwakeOnce() wrapper lives here any more.
538
+
539
+ // ---- Daemon version (fetched once per session over WebRTC) ----
540
+ async function fetchDaemonVersionOnce() {
541
+ try {
542
+ const v = await robot.getVersion();
543
+ if (!v) return;
544
+ const el = document.getElementById('daemonVersion');
545
+ el.textContent = `Daemon v${v}`;
546
+ el.classList.remove('hidden');
547
+ } catch (e) {
548
+ // Data channel closed mid-request, or lib version too old
549
+ // to support get_version. Non-fatal — but log at WARN so
550
+ // a stale CDN-cached lib (missing the method) is visible
551
+ // in the browser console without "Verbose" level turned on.
552
+ console.warn('getVersion failed:', e);
553
+ }
554
+ }
555
+
556
+ // ---- Volume slider wiring ----
557
+ //
558
+ // Two events matter:
559
+ // - 'input' fires continuously while dragging — used for the
560
+ // live "75%" label so the UI feels responsive.
561
+ // - 'change' fires on release — the only moment we actually
562
+ // call setVolume over WebRTC. Avoids spamming the
563
+ // robot with one command per pixel of drag.
564
+ //
565
+ // syncVolumeSlider() runs on streaming-start to pull the robot's
566
+ // real current volume so the slider reflects reality rather than
567
+ // the default "50" in the markup.
568
+ async function syncVolumeSlider() {
569
+ const slider = document.getElementById('volumeSlider');
570
+ const label = document.getElementById('volumeValue');
571
+ // Early detection: if the loaded JS lib predates the volume
572
+ // commands (stale jsdelivr cache, old branch), fail loudly
573
+ // rather than silently rendering "n/a".
574
+ if (typeof robot.getVolume !== 'function') {
575
+ console.warn(
576
+ 'robot.getVolume is not a function — the reachy-mini.js ' +
577
+ 'lib loaded from CDN is older than the webrtc_example UI. ' +
578
+ 'Purge jsdelivr and hard-refresh the page.'
579
+ );
580
+ label.textContent = 'lib';
581
+ return;
582
+ }
583
+ try {
584
+ const v = await robot.getVolume();
585
+ if (v == null) {
586
+ // Volume control unavailable (unsupported platform or
587
+ // audio stack down) — keep the slider disabled.
588
+ label.textContent = 'n/a';
589
+ return;
590
+ }
591
+ slider.value = v;
592
+ label.textContent = `${v}%`;
593
+ slider.disabled = false;
594
+ } catch (e) {
595
+ // Log at WARN so it's visible without "Verbose" level.
596
+ console.warn('getVolume failed:', e);
597
+ label.textContent = 'n/a';
598
+ }
599
+ }
600
+
601
+ // ===================== Daemon log stream =====================
602
+ //
603
+ // The daemon exposes its `journalctl -u reachy-mini-daemon` lines as
604
+ // typed `log_line` messages on the WebRTC data channel. We open the
605
+ // subscription on streaming-start and tear it down on stop so the
606
+ // daemon-side subprocess only runs while a peer is actively viewing.
607
+ // The same SDK call works for the LAN-direct GStreamer signaling
608
+ // path and the Central + WebRTC remote path — both share the typed
609
+ // command surface.
610
+ function appendLogLine({ timestamp, line }) {
611
+ const view = document.getElementById('logsView');
612
+ if (!view) return;
613
+ const wasInitial = view.firstChild && view.firstChild.nodeType === Node.TEXT_NODE
614
+ && view.textContent === 'Waiting for stream...';
615
+ if (wasInitial) view.textContent = '';
616
+ // Auto-scroll only if the user is already pinned to the bottom;
617
+ // otherwise let them browse history without snapping back.
618
+ const stick = view.scrollTop + view.clientHeight >= view.scrollHeight - 4;
619
+ const div = document.createElement('div');
620
+ div.textContent = timestamp ? `${timestamp} ${line}` : line;
621
+ view.appendChild(div);
622
+ // Trim oldest lines past the cap so the DOM doesn't grow forever.
623
+ while (view.childElementCount > LOGS_MAX_LINES) {
624
+ view.removeChild(view.firstChild);
625
+ }
626
+ if (stick) view.scrollTop = view.scrollHeight;
627
+ }
628
+
629
+ function startLogsStream() {
630
+ if (typeof robot.subscribeLogs !== 'function') {
631
+ console.warn(
632
+ 'robot.subscribeLogs is not a function — the reachy-mini.js ' +
633
+ 'lib loaded from CDN predates the daemon log feature. ' +
634
+ 'Purge jsdelivr and hard-refresh the page.'
635
+ );
636
+ document.getElementById('logsView').textContent = 'subscribeLogs not in SDK';
637
+ return;
638
+ }
639
+ stopLogsStream();
640
+ document.getElementById('logsView').textContent = 'Subscribing...';
641
+ try {
642
+ logsUnsub = robot.subscribeLogs({
643
+ onLine: appendLogLine,
644
+ onError: (err) => {
645
+ appendLogLine({ timestamp: '', line: `[log_stream_error] ${err}` });
646
+ },
647
+ });
648
+ } catch (e) {
649
+ console.warn('subscribeLogs failed:', e);
650
+ document.getElementById('logsView').textContent = `Failed: ${e.message || e}`;
651
+ }
652
+ }
653
+
654
+ function stopLogsStream() {
655
+ if (logsUnsub) {
656
+ try { logsUnsub(); } catch (e) { console.warn('logsUnsub threw:', e); }
657
+ logsUnsub = null;
658
+ }
659
+ }
660
+
661
+ function clearLogs() {
662
+ const view = document.getElementById('logsView');
663
+ if (view) view.textContent = '';
664
+ }
665
+
666
+ (function initVolumeSlider() {
667
+ const slider = document.getElementById('volumeSlider');
668
+ const label = document.getElementById('volumeValue');
669
+ slider.addEventListener('input', () => {
670
+ label.textContent = `${slider.value}%`;
671
+ });
672
+ slider.addEventListener('change', async () => {
673
+ const requested = parseInt(slider.value, 10);
674
+ if (typeof robot.setVolume !== 'function') {
675
+ console.warn(
676
+ 'robot.setVolume is not a function — old JS lib on CDN; ' +
677
+ 'purge jsdelivr and hard-refresh.'
678
+ );
679
+ return;
680
+ }
681
+ try {
682
+ const applied = await robot.setVolume(requested);
683
+ if (applied != null) {
684
+ // Server may clamp/round — reflect the truth back
685
+ // into the UI so the slider isn't a lie.
686
+ slider.value = applied;
687
+ label.textContent = `${applied}%`;
688
+ }
689
+ } catch (e) {
690
+ console.warn('setVolume failed:', e);
691
+ }
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() {
821
+ const badge = document.getElementById('latencyBadge');
822
+ const label = document.getElementById('latencyValue');
823
+ badge.classList.remove('hidden');
824
+
825
+ latencyIntervalId = setInterval(async () => {
826
+ const video = document.getElementById('remoteVideo');
827
+ let bufLagMs = null;
828
+ let rttMs = null;
829
+ let vidJitterMs = null;
830
+ let audJitterMs = null;
831
+
832
+ // Buffer lag (how far behind live edge)
833
+ if (video && video.buffered && video.buffered.length > 0) {
834
+ const end = video.buffered.end(video.buffered.length - 1);
835
+ bufLagMs = Math.round((end - video.currentTime) * 1000);
836
+ }
837
+
838
+ // WebRTC stats: RTT + jitter buffer delay (video & audio)
839
+ if (robot._pc) {
840
+ try {
841
+ const stats = await robot._pc.getStats();
842
+ stats.forEach(report => {
843
+ if (report.type === 'candidate-pair' && report.currentRoundTripTime != null) {
844
+ rttMs = Math.round(report.currentRoundTripTime * 1000);
845
+ }
846
+ if (report.type === 'inbound-rtp' && report.jitterBufferDelay != null && report.jitterBufferEmittedCount > 0) {
847
+ const jMs = Math.round((report.jitterBufferDelay / report.jitterBufferEmittedCount) * 1000);
848
+ if (report.kind === 'video') vidJitterMs = jMs;
849
+ if (report.kind === 'audio') audJitterMs = jMs;
850
+ }
851
+ });
852
+ } catch (_) { /* no stats yet */ }
853
+ }
854
+
855
+ // Display
856
+ const parts = [];
857
+ if (bufLagMs != null) parts.push(`buf ${bufLagMs}ms`);
858
+ if (rttMs != null) parts.push(`rtt ${rttMs}ms`);
859
+ if (vidJitterMs != null) parts.push(`v-jit ${vidJitterMs}ms`);
860
+ if (audJitterMs != null) parts.push(`a-jit ${audJitterMs}ms`);
861
+ label.textContent = parts.length ? parts.join(' · ') : '--';
862
+
863
+ // Color based on buffer lag
864
+ badge.classList.remove('good', 'ok', 'bad');
865
+ if (bufLagMs != null) {
866
+ if (bufLagMs < 200) badge.classList.add('good');
867
+ else if (bufLagMs < 500) badge.classList.add('ok');
868
+ else badge.classList.add('bad');
869
+ }
870
+ }, 1000);
871
+ }
872
+
873
+ function stopLatencyDisplay() {
874
+ if (latencyIntervalId) { clearInterval(latencyIntervalId); latencyIntervalId = null; }
875
+ const badge = document.getElementById('latencyBadge');
876
+ badge.classList.add('hidden');
877
+ badge.classList.remove('good', 'ok', 'bad');
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() {
893
+ robot.setAudioMuted(!robot.audioMuted);
894
+ updateMuteButton();
895
+ }
896
+
897
+ function toggleMic() {
898
+ robot.setMicMuted(!robot.micMuted);
899
+ updateMicButton();
900
+ }
901
+
902
+ function updateMuteButton() {
903
+ const muted = robot.audioMuted;
904
+ const btn = document.getElementById('muteBtn');
905
+ btn.classList.toggle('muted', muted);
906
+ document.getElementById('speakerOffIcon').classList.toggle('hidden', !muted);
907
+ document.getElementById('speakerOnIcon').classList.toggle('hidden', muted);
908
+ document.getElementById('muteText').textContent = muted ? 'Unmute' : 'Mute';
909
+ }
910
+
911
+ function updateMicButton() {
912
+ const muted = robot.micMuted;
913
+ const btn = document.getElementById('micBtn');
914
+ btn.classList.toggle('muted', muted);
915
+ document.getElementById('micOffIcon').classList.toggle('hidden', !muted);
916
+ document.getElementById('micOnIcon').classList.toggle('hidden', muted);
917
+ document.getElementById('micText').textContent = muted ? 'Mic Off' : 'Mic On';
918
+ }
919
+
920
+ function enableControls(enabled) {
921
+ document.getElementById('btnPlaySound').disabled = !enabled;
922
+ document.getElementById('btnWakeUp').disabled = !enabled;
923
+ document.getElementById('btnGotoSleep').disabled = !enabled;
924
+ document.getElementById('btnTorqueToggle').disabled = !enabled;
925
+ document.getElementById('muteBtn').disabled = !enabled;
926
+ document.getElementById('micBtn').disabled = !enabled || !robot.micSupported;
927
+ }
928
+
929
+ // ===================== State Display =====================
930
+ // The "state" event ships raw wire units (flat 4×4 matrix, radians);
931
+ // the UI works in degrees, so we convert at the boundary.
932
+ function updateStateDisplay(state) {
933
+ // Also gate on bodyYawSliderActive: dragging the body slider
934
+ // mutates yawSlider.value (head/body rotate together), and the
935
+ // daemon's mid-IK present_head_pose lags the target. Without
936
+ // this gate, matrixToRpy of the in-flight pose pumps drifted
937
+ // roll/pitch/yaw back into the head sliders, which the next
938
+ // body-input event reads and re-sends — visible as pitch/roll
939
+ // jitter while the body slider is being dragged.
940
+ if (state.head && !headSlidersActive && !bodyYawSliderActive) {
941
+ // state.head is number[16] (flat row-major 4×4); matrixToRpy
942
+ // wants nested 4×4, so unflatten.
943
+ const m = [
944
+ state.head.slice(0, 4),
945
+ state.head.slice(4, 8),
946
+ state.head.slice(8, 12),
947
+ state.head.slice(12, 16),
948
+ ];
949
+ const rpy = matrixToRpy(m);
950
+ document.getElementById('rollSlider').value = rpy.roll;
951
+ document.getElementById('rollValue').textContent = rpy.roll.toFixed(1) + '°';
952
+ document.getElementById('pitchSlider').value = rpy.pitch;
953
+ document.getElementById('pitchValue').textContent = rpy.pitch.toFixed(1) + '°';
954
+ document.getElementById('yawSlider').value = rpy.yaw;
955
+ document.getElementById('yawValue').textContent = rpy.yaw.toFixed(1) + '°';
956
+ }
957
+ if (state.antennas) {
958
+ const r = radToDeg(state.antennas[0]).toFixed(0);
959
+ const l = radToDeg(state.antennas[1]).toFixed(0);
960
+ document.getElementById('rightAntSlider').value = r;
961
+ document.getElementById('rightAntValue').textContent = r + '°';
962
+ document.getElementById('leftAntSlider').value = l;
963
+ document.getElementById('leftAntValue').textContent = l + '°';
964
+ }
965
+ if (typeof state.body_yaw === 'number' && !bodyYawSliderActive) {
966
+ const y = radToDeg(state.body_yaw).toFixed(0);
967
+ document.getElementById('bodyYawSlider').value = y;
968
+ document.getElementById('bodyYawValue').textContent = y + '°';
969
+ // Sync the delta baseline so the next drag computes from
970
+ // the robot's actual current yaw, not whatever the slider
971
+ // happened to be at on page load.
972
+ lastBodyYawDeg = parseFloat(y);
973
+ }
974
+ if (state.motor_mode) updateTorqueButton(state.motor_mode);
975
+ }
976
+
977
+ function updateTorqueButton(mode) {
978
+ const btn = document.getElementById('btnTorqueToggle');
979
+ if (!btn) return;
980
+ // Normalise: anything other than "disabled" is considered "on"
981
+ // for toggle purposes. gravity_compensation is torque-on too.
982
+ const isOn = mode && mode !== 'disabled';
983
+ btn.textContent = isOn ? 'Torque: On (click to disable)' : 'Torque: Off (click to enable)';
984
+ // Stash the current intent so the click handler knows which
985
+ // direction to flip without rereading _robotState.
986
+ btn.dataset.currentMode = mode;
987
+ }
988
+
989
+ // ===================== Sliders =====================
990
+ function initSliders() {
991
+ // Head
992
+ const rollSlider = document.getElementById('rollSlider');
993
+ const pitchSlider = document.getElementById('pitchSlider');
994
+ const yawSlider = document.getElementById('yawSlider');
995
+ const rollValue = document.getElementById('rollValue');
996
+ const pitchValue = document.getElementById('pitchValue');
997
+ const yawValue = document.getElementById('yawValue');
998
+
999
+ // Body yaw
1000
+ const bodyYawSlider = document.getElementById('bodyYawSlider');
1001
+ const bodyYawValue = document.getElementById('bodyYawValue');
1002
+
1003
+ // Antennas
1004
+ const rightSlider = document.getElementById('rightAntSlider');
1005
+ const leftSlider = document.getElementById('leftAntSlider');
1006
+ const rightAntValue = document.getElementById('rightAntValue');
1007
+ const leftAntValue = document.getElementById('leftAntValue');
1008
+
1009
+ // Single source of truth: read every slider's DOM state and
1010
+ // ship it in one atomic setTarget command. Per-slider input
1011
+ // handlers update their label, optionally run cross-slider
1012
+ // compensation (body-yaw drag → head-yaw counter-rotation),
1013
+ // then call this. One round-trip per gesture, no chance for
1014
+ // the daemon to see a half-applied target between components.
1015
+ function sendCurrentTarget() {
1016
+ robot.setTarget({
1017
+ head: rpyToMatrix(
1018
+ parseFloat(rollSlider.value),
1019
+ parseFloat(pitchSlider.value),
1020
+ parseFloat(yawSlider.value),
1021
+ ).flat(),
1022
+ antennas: [
1023
+ degToRad(parseFloat(rightSlider.value)),
1024
+ degToRad(parseFloat(leftSlider.value)),
1025
+ ],
1026
+ body_yaw: degToRad(parseFloat(bodyYawSlider.value)),
1027
+ });
1028
+ }
1029
+
1030
+ const onStart = () => { headSlidersActive = true; };
1031
+ const onEnd = () => { headSlidersActive = false; };
1032
+
1033
+ for (const s of [rollSlider, pitchSlider, yawSlider]) {
1034
+ s.addEventListener('mousedown', onStart);
1035
+ s.addEventListener('touchstart', onStart);
1036
+ s.addEventListener('mouseup', onEnd);
1037
+ s.addEventListener('touchend', onEnd);
1038
+ }
1039
+
1040
+ rollSlider.addEventListener('input', () => { rollValue.textContent = parseFloat(rollSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
1041
+ pitchSlider.addEventListener('input', () => { pitchValue.textContent = parseFloat(pitchSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
1042
+ yawSlider.addEventListener('input', () => { yawValue.textContent = parseFloat(yawSlider.value).toFixed(1) + '°'; sendCurrentTarget(); });
1043
+
1044
+ // Body yaw — the head pose in setTarget is interpreted in world
1045
+ // frame (see look_at_world in the Python SDK), so the IK splits
1046
+ // the requested world yaw between body rotation and the stewart
1047
+ // platform. Mechanical limit: |head_yaw_world − body_yaw| ≤ 65°.
1048
+ //
1049
+ // To keep that relative yaw constant as the user drags the body
1050
+ // slider, we move the head's commanded world yaw by the same
1051
+ // delta — head and body rotate together (tank-style). The
1052
+ // per-component values are then shipped atomically by
1053
+ // sendCurrentTarget().
1054
+ const onBodyStart = () => { bodyYawSliderActive = true; };
1055
+ const onBodyEnd = () => { bodyYawSliderActive = false; };
1056
+ bodyYawSlider.addEventListener('mousedown', onBodyStart);
1057
+ bodyYawSlider.addEventListener('touchstart', onBodyStart);
1058
+ bodyYawSlider.addEventListener('mouseup', onBodyEnd);
1059
+ bodyYawSlider.addEventListener('touchend', onBodyEnd);
1060
+
1061
+ const yawMin = parseFloat(yawSlider.min);
1062
+ const yawMax = parseFloat(yawSlider.max);
1063
+ bodyYawSlider.addEventListener('input', () => {
1064
+ const newBodyDeg = parseFloat(bodyYawSlider.value);
1065
+ const delta = newBodyDeg - lastBodyYawDeg;
1066
+ // Add the body-yaw delta to the head-yaw slider, clamped
1067
+ // to its range. Once the head slider saturates, the head's
1068
+ // world yaw stops keeping up with the body, the relative
1069
+ // yaw starts to grow, and beyond ~65° the daemon's safe-IK
1070
+ // will modulate body_yaw to stay within mechanical limits.
1071
+ const newHeadYaw = Math.max(yawMin, Math.min(yawMax,
1072
+ parseFloat(yawSlider.value) + delta));
1073
+ yawSlider.value = newHeadYaw;
1074
+ yawValue.textContent = newHeadYaw.toFixed(1) + '°';
1075
+ bodyYawValue.textContent = newBodyDeg.toFixed(0) + '°';
1076
+ sendCurrentTarget();
1077
+ lastBodyYawDeg = newBodyDeg;
1078
+ });
1079
+
1080
+ rightSlider.addEventListener('input', () => {
1081
+ rightAntValue.textContent = rightSlider.value + '°';
1082
+ sendCurrentTarget();
1083
+ });
1084
+ leftSlider.addEventListener('input', () => {
1085
+ leftAntValue.textContent = leftSlider.value + '°';
1086
+ sendCurrentTarget();
1087
+ });
1088
+ }
1089
+
1090
+ // ===================== Sound =====================
1091
+ function playSound() {
1092
+ const file = document.getElementById('soundInput').value.trim();
1093
+ if (file) robot.playSound(file);
1094
+ }
1095
+
1096
+ function playSoundPreset(file) {
1097
+ document.getElementById('soundInput').value = file;
1098
+ robot.playSound(file);
1099
+ }
1100
+
1101
+ // ===================== Animations =====================
1102
+ //
1103
+ // Fire-and-forget: the robot plays the full trajectory (~2 s) and
1104
+ // we don't await completion. The buttons are briefly disabled
1105
+ // during the motion to prevent the user from spamming the same
1106
+ // animation on top of itself, using is_move_running from the
1107
+ // state event as the authoritative "busy" signal.
1108
+ //
1109
+ // Staleness detection: if the lib loaded from CDN predates these
1110
+ // methods (typeof check), warn instead of silently failing. Same
1111
+ // pattern as the volume controls.
1112
+ function playWakeUp() {
1113
+ // Click receipt log: present so that "no log at all" definitively
1114
+ // means the click event never reached this function (button still
1115
+ // disabled, stale HTML in browser cache, or redeploy not live).
1116
+ console.log('[animations] playWakeUp clicked; robot.state=', robot.state,
1117
+ '; robot.wakeUp typeof=', typeof robot.wakeUp);
1118
+ if (typeof robot.wakeUp !== 'function') {
1119
+ console.warn('robot.wakeUp is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
1120
+ return;
1121
+ }
1122
+ const ok = robot.wakeUp();
1123
+ console.log('[animations] robot.wakeUp() returned', ok);
1124
+ }
1125
+
1126
+ function playGotoSleep() {
1127
+ console.log('[animations] playGotoSleep clicked; robot.state=', robot.state,
1128
+ '; robot.gotoSleep typeof=', typeof robot.gotoSleep);
1129
+ if (typeof robot.gotoSleep !== 'function') {
1130
+ console.warn('robot.gotoSleep is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
1131
+ return;
1132
+ }
1133
+ const ok = robot.gotoSleep();
1134
+ console.log('[animations] robot.gotoSleep() returned', ok);
1135
+ }
1136
+
1137
+ // ===================== Torque =====================
1138
+ //
1139
+ // The "current mode" read from the button's dataset is populated by
1140
+ // updateTorqueButton() on every state-event. If a user clicks before
1141
+ // the first state tick arrives, currentMode is falsy and we default
1142
+ // to enabling — the safe-ish choice that matches what wakeUp() would
1143
+ // do. The label then self-corrects on the next state tick.
1144
+ function toggleTorque() {
1145
+ if (typeof robot.setMotorMode !== 'function') {
1146
+ console.warn('robot.setMotorMode is not a function — stale JS lib; purge jsdelivr + hard-refresh.');
1147
+ return;
1148
+ }
1149
+ const btn = document.getElementById('btnTorqueToggle');
1150
+ const currentMode = btn.dataset.currentMode;
1151
+ const next = (currentMode && currentMode !== 'disabled') ? 'disabled' : 'enabled';
1152
+ console.log('[torque] toggle click; currentMode=', currentMode, '→ sending', next);
1153
+ robot.setMotorMode(next);
1154
+ // Optimistic UI update. If the robot rejects or differs, the
1155
+ // next state tick (within ~500 ms) will overwrite.
1156
+ updateTorqueButton(next);
1157
+ }
1158
  </script>
1159
+ </body>
 
 
 
 
 
 
 
 
 
 
 
1160
  </html>
style.css ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --pollen-coral: #FF6B35;
3
+ --pollen-coral-light: #FF8A5C;
4
+ --pollen-coral-dark: #E55A2B;
5
+ --pollen-dark: #1A1A2E;
6
+ --pollen-darker: #0F0F1A;
7
+ --pollen-card: #16213E;
8
+ --pollen-card-light: #1E2A4A;
9
+ --text-primary: #FFFFFF;
10
+ --text-secondary: #A0AEC0;
11
+ --text-muted: #718096;
12
+ --success: #48BB78;
13
+ --warning: #ECC94B;
14
+ --danger: #F56565;
15
+ }
16
+
17
+ * { box-sizing: border-box; margin: 0; padding: 0; }
18
+
19
+ body {
20
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
21
+ background: var(--pollen-darker);
22
+ color: var(--text-primary);
23
+ min-height: 100vh;
24
+ min-height: 100dvh;
25
+ overflow-x: hidden;
26
+ }
27
+
28
+ /* Header */
29
+ .header {
30
+ background: rgba(0,0,0,0.4);
31
+ backdrop-filter: blur(10px);
32
+ padding: 8px 16px;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ border-bottom: 1px solid rgba(255,107,53,0.2);
37
+ }
38
+
39
+ .logo {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 10px;
43
+ }
44
+
45
+ .logo img {
46
+ width: 32px;
47
+ height: 32px;
48
+ border-radius: 6px;
49
+ }
50
+
51
+ .logo-text {
52
+ font-weight: 700;
53
+ font-size: 1em;
54
+ color: var(--pollen-coral);
55
+ }
56
+
57
+ .logo-text span {
58
+ color: var(--text-secondary);
59
+ font-weight: 400;
60
+ font-size: 0.85em;
61
+ }
62
+
63
+ .user-section {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ }
68
+
69
+ .user-badge {
70
+ background: var(--pollen-card);
71
+ padding: 4px 12px;
72
+ border-radius: 16px;
73
+ font-size: 0.8em;
74
+ }
75
+
76
+ .btn-logout {
77
+ background: transparent;
78
+ border: 1px solid var(--text-muted);
79
+ color: var(--text-secondary);
80
+ padding: 4px 12px;
81
+ border-radius: 12px;
82
+ cursor: pointer;
83
+ font-size: 0.75em;
84
+ }
85
+
86
+ /* Main Layout - Single Column */
87
+ .app-container {
88
+ display: flex;
89
+ flex-direction: column;
90
+ padding: 8px;
91
+ gap: 8px;
92
+ max-width: 800px;
93
+ margin: 0 auto;
94
+ }
95
+
96
+ /* Video Section */
97
+ .video-container {
98
+ position: relative;
99
+ background: #000;
100
+ border-radius: 12px;
101
+ overflow: hidden;
102
+ aspect-ratio: 16/9;
103
+ width: 100%;
104
+ }
105
+
106
+ video {
107
+ width: 100%;
108
+ height: 100%;
109
+ object-fit: cover;
110
+ background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
111
+ }
112
+
113
+ .video-overlay-top {
114
+ position: absolute;
115
+ top: 0;
116
+ left: 0;
117
+ right: 0;
118
+ padding: 12px;
119
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
120
+ display: flex;
121
+ justify-content: space-between;
122
+ align-items: flex-start;
123
+ }
124
+
125
+ .connection-badge {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 6px;
129
+ background: rgba(0,0,0,0.5);
130
+ padding: 6px 12px;
131
+ border-radius: 16px;
132
+ font-size: 0.8em;
133
+ }
134
+
135
+ .latency-badge {
136
+ display: flex;
137
+ align-items: center;
138
+ background: rgba(0,0,0,0.5);
139
+ padding: 4px 10px;
140
+ border-radius: 12px;
141
+ font-size: 0.75em;
142
+ font-variant-numeric: tabular-nums;
143
+ color: var(--text-secondary);
144
+ font-family: 'Inter', monospace;
145
+ }
146
+
147
+ .latency-badge.good { color: var(--success); }
148
+ .latency-badge.ok { color: var(--warning); }
149
+ .latency-badge.bad { color: var(--danger); }
150
+
151
+ .status-indicator {
152
+ width: 8px;
153
+ height: 8px;
154
+ border-radius: 50%;
155
+ background: var(--danger);
156
+ }
157
+
158
+ .status-indicator.connected {
159
+ background: var(--success);
160
+ box-shadow: 0 0 8px var(--success);
161
+ }
162
+
163
+ .status-indicator.connecting {
164
+ background: var(--warning);
165
+ animation: blink 0.8s infinite;
166
+ }
167
+
168
+ @keyframes blink {
169
+ 0%, 100% { opacity: 1; }
170
+ 50% { opacity: 0.4; }
171
+ }
172
+
173
+ .robot-name {
174
+ background: rgba(0,0,0,0.5);
175
+ padding: 6px 12px;
176
+ border-radius: 16px;
177
+ font-size: 0.8em;
178
+ font-weight: 500;
179
+ }
180
+
181
+ .video-overlay-bottom {
182
+ position: absolute;
183
+ bottom: 0;
184
+ left: 0;
185
+ right: 0;
186
+ padding: 12px;
187
+ background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
188
+ }
189
+
190
+ .video-controls {
191
+ display: flex;
192
+ justify-content: center;
193
+ gap: 8px;
194
+ flex-wrap: wrap;
195
+ }
196
+
197
+ .btn {
198
+ padding: 8px 16px;
199
+ border: none;
200
+ border-radius: 8px;
201
+ font-weight: 600;
202
+ font-size: 0.85em;
203
+ cursor: pointer;
204
+ transition: all 0.2s;
205
+ }
206
+
207
+ .btn-primary {
208
+ background: var(--pollen-coral);
209
+ color: white;
210
+ }
211
+
212
+ .btn-secondary {
213
+ background: rgba(255,255,255,0.15);
214
+ color: white;
215
+ }
216
+
217
+ .btn-danger {
218
+ background: var(--danger);
219
+ color: white;
220
+ }
221
+
222
+ .btn:disabled {
223
+ opacity: 0.4;
224
+ cursor: not-allowed;
225
+ }
226
+
227
+ .btn-mute {
228
+ background: rgba(255,255,255,0.15);
229
+ color: white;
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 6px;
233
+ }
234
+
235
+ .btn-mute.muted {
236
+ background: var(--danger);
237
+ }
238
+
239
+ .btn-mute svg {
240
+ width: 16px;
241
+ height: 16px;
242
+ }
243
+
244
+ /* Panels */
245
+ .panel {
246
+ background: var(--pollen-card);
247
+ border-radius: 12px;
248
+ overflow: hidden;
249
+ }
250
+
251
+ .panel-header {
252
+ padding: 10px 14px;
253
+ background: rgba(0,0,0,0.2);
254
+ font-weight: 600;
255
+ font-size: 0.85em;
256
+ color: var(--pollen-coral);
257
+ }
258
+
259
+ .panel-content {
260
+ padding: 12px;
261
+ }
262
+
263
+ /* Sliders */
264
+ .slider-row {
265
+ display: flex;
266
+ gap: 12px;
267
+ align-items: center;
268
+ margin-bottom: 12px;
269
+ }
270
+
271
+ .slider-row:last-child {
272
+ margin-bottom: 0;
273
+ }
274
+
275
+ .slider-label {
276
+ font-size: 0.8em;
277
+ color: var(--text-secondary);
278
+ min-width: 80px;
279
+ }
280
+
281
+ .slider {
282
+ flex: 1;
283
+ height: 8px;
284
+ -webkit-appearance: none;
285
+ background: var(--pollen-darker);
286
+ border-radius: 4px;
287
+ }
288
+
289
+ .slider::-webkit-slider-thumb {
290
+ -webkit-appearance: none;
291
+ width: 20px;
292
+ height: 20px;
293
+ background: var(--pollen-coral);
294
+ border-radius: 50%;
295
+ cursor: pointer;
296
+ }
297
+
298
+ .slider-value {
299
+ font-family: monospace;
300
+ font-size: 0.8em;
301
+ color: var(--pollen-coral);
302
+ min-width: 45px;
303
+ text-align: right;
304
+ }
305
+
306
+ /* Sound */
307
+ .sound-row {
308
+ display: flex;
309
+ gap: 8px;
310
+ margin-bottom: 10px;
311
+ }
312
+
313
+ .sound-input {
314
+ flex: 1;
315
+ padding: 8px 10px;
316
+ background: var(--pollen-darker);
317
+ border: 1px solid var(--pollen-card-light);
318
+ border-radius: 6px;
319
+ color: var(--text-primary);
320
+ font-size: 0.85em;
321
+ }
322
+
323
+ .sound-presets {
324
+ display: flex;
325
+ flex-wrap: wrap;
326
+ gap: 6px;
327
+ margin-bottom: 12px;
328
+ }
329
+
330
+ .preset-chip {
331
+ padding: 4px 10px;
332
+ background: var(--pollen-darker);
333
+ border: 1px solid var(--pollen-card-light);
334
+ border-radius: 12px;
335
+ color: var(--text-secondary);
336
+ font-size: 0.7em;
337
+ cursor: pointer;
338
+ }
339
+
340
+ .preset-chip:hover {
341
+ border-color: var(--pollen-coral);
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) {
381
+ .app-container {
382
+ display: grid;
383
+ grid-template-columns: 1fr 1fr;
384
+ grid-template-rows: auto auto auto auto;
385
+ max-width: 1200px;
386
+ gap: 12px;
387
+ }
388
+
389
+ .video-container {
390
+ grid-column: 1;
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;
402
+ }
403
+
404
+ .panel:nth-of-type(2) { /* Antennas */
405
+ grid-column: 2;
406
+ grid-row: 2;
407
+ }
408
+
409
+ .panel:nth-of-type(3) { /* Sound */
410
+ grid-column: 2;
411
+ grid-row: 3;
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; }