From 07f26e390649b2b76b41095a25366d9a1f879cce Mon Sep 17 00:00:00 2001 From: Kevin Date: Sun, 19 Apr 2026 15:18:29 +0300 Subject: [PATCH 1/2] Robot Ip input & cache --- public/animator.html | 145 +++++++ public/animator.js | 220 +++++++++++ public/app.js | 19 +- public/commander.html | 286 ++++++++++++++ public/home.js | 215 ++++++++++ public/index.html | 819 ++++++++++++--------------------------- public/style.css | 68 ++++ public/telepresence.html | 143 +++++++ public/telepresence.js | 242 ++++++++++++ server.js | 2 +- 10 files changed, 1589 insertions(+), 570 deletions(-) create mode 100644 public/animator.html create mode 100644 public/animator.js create mode 100644 public/commander.html create mode 100644 public/home.js create mode 100644 public/telepresence.html create mode 100644 public/telepresence.js diff --git a/public/animator.html b/public/animator.html new file mode 100644 index 0000000..cf3242d --- /dev/null +++ b/public/animator.html @@ -0,0 +1,145 @@ + + + + + + Re-Commander — Animator Mode + + + + +
+
+

Re-Commander — Animator

+ Connecting… + + +
+ +
+ + +
+ + +
+
Animation Library
+
+ + +
+ +
+ + +
+
Animation Sequence
+
+ +
+
+ + +
+
+ + +
+
Timeline
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ +
+
📹
+
Animation Preview
+
Start video to see animations being played
+
+
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+
Video Stream
+
+ + +
+ +
+
+ + +
+
+ Animation Log + +
+
+
+ +
+
+ + +
+
+ Photo +
+ + + + diff --git a/public/animator.js b/public/animator.js new file mode 100644 index 0000000..b21bfbe --- /dev/null +++ b/public/animator.js @@ -0,0 +1,220 @@ +'use strict'; + +// ── WebSocket to server ────────────────────────────────────────────────────── + +let ws; +let connected = false; +let sessionActive = false; +let videoActive = false; + +function connectWS() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws`); + + ws.onopen = () => { + connected = true; + }; + + ws.onclose = () => { + connected = false; + setStatus(false, 'Disconnected — reconnecting…'); + setTimeout(connectWS, 2000); + }; + + ws.onerror = () => {}; + + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + handleServerMessage(msg); + }; +} + +function handleServerMessage(msg) { + if (msg.type === 'status') { + sessionActive = !!msg.sessionID; + setStatus(msg.connected && sessionActive, + msg.connected + ? (sessionActive ? 'Connected • ' + msg.sessionID.slice(0, 8) + '…' : 'Connecting…') + : 'Disconnected'); + return; + } + + if (msg.type === 'jiboEvent') { + handleJiboEvent(msg.body, msg.txId); + } +} + +function handleJiboEvent(body, txId) { + if (!body) return; + const evt = body.Event || body.ResponseString || '?'; + logEvent(evt, body, txId); +} + +// ── REST helpers ───────────────────────────────────────────────────────────── + +async function api(method, path, body) { + try { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(path, opts); + return await res.json(); + } catch (err) { + logEvent('api-error: ' + path, { error: err.message }, null); + } +} + +const post = (path, body) => api('POST', path, body); +const get = (path) => api('GET', path); + +// ── Status ─────────────────────────────────────────────────────────────────── + +function setStatus(ok, label) { + const dot = document.getElementById('status-dot'); + const lbl = document.getElementById('status-label'); + dot.className = ok ? 'ok' : ''; + lbl.textContent = label; +} + +// ── Animation Control ──────────────────────────────────────────────────────── + +document.getElementById('btn-play-anim').addEventListener('click', () => { + const name = document.getElementById('anim-select').value; + if (name) { + post('/api/display/anim', { name }); + logEvent('Animation Played', { animation: name }, null); + } +}); + +document.getElementById('btn-play-sequence').addEventListener('click', () => { + const sequence = document.getElementById('sequence-list').value + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + if (sequence.length === 0) { + alert('Please enter at least one animation'); + return; + } + + const repeatCount = parseInt(document.getElementById('repeat-count').value) || 1; + const delay = parseInt(document.getElementById('anim-delay').value) || 500; + + post('/api/display/anim-sequence', { sequence, repeatCount, delay }); + logEvent('Sequence Started', { count: sequence.length, repeats: repeatCount }, null); +}); + +document.getElementById('btn-stop-sequence').addEventListener('click', () => { + post('/api/cancel', {}); + logEvent('Sequence Stopped', {}, null); +}); + +// ── Camera / Video ──────────────────────────────────────────────────────────── + +let videoTxId = null; + +document.getElementById('btn-video-start').addEventListener('click', async () => { + const r = await post('/api/video/start', { duration: 0 }); + if (r) videoTxId = r.txId; + document.getElementById('video-status').textContent = 'Waiting for VideoReady…'; +}); + +document.getElementById('btn-video-stop').addEventListener('click', () => { + post('/api/video/stop', {}); + stopVideoFeed(); +}); + +function startVideoFeed(uri) { + const feed = document.getElementById('camera-feed'); + const noFeed = document.getElementById('camera-no-feed'); + feed.src = '/proxy/stream?uri=' + encodeURIComponent(uri); + feed.style.display = 'block'; + noFeed.style.display = 'none'; + videoActive = true; +} + +function stopVideoFeed() { + const feed = document.getElementById('camera-feed'); + feed.src = ''; + feed.style.display = 'none'; + document.getElementById('camera-no-feed').style.display = ''; + videoActive = false; +} + +// ── Take Photo ──────────────────────────────────────────────────────────────── + +document.getElementById('btn-photo').addEventListener('click', () => { + post('/api/photo', { + camera: 'right', + resolution: document.getElementById('photo-res').value + }); +}); + +function addPhoto(url) { + const strip = document.getElementById('photo-strip'); + const img = document.createElement('img'); + img.src = url; + img.title = url; + img.addEventListener('click', () => openPhotoModal(img.src)); + strip.prepend(img); +} + +// ── Photo modal ─────────────────────────────────────────────────────────────── + +function openPhotoModal(src) { + document.getElementById('photo-modal-img').src = src; + document.getElementById('photo-modal').classList.add('open'); +} + +document.getElementById('photo-modal').addEventListener('click', (e) => { + if (e.target === e.currentTarget || e.target.id === 'photo-modal-close') { + document.getElementById('photo-modal').classList.remove('open'); + } +}); + +// ── Tabs ────────────────────────────────────────────────────────────────────── + +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', function () { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + this.classList.add('active'); + document.getElementById(this.dataset.tab).classList.add('active'); + }); +}); + +// ── Event log ───────────────────────────────────────────────────────────────── + +const MAX_LOG = 200; + +function logEvent(eventName, body, txId) { + const log = document.getElementById('event-log'); + const now = new Date().toLocaleTimeString(); + + let cls = 'evt'; + if (eventName.toLowerCase().includes('error')) cls = 'evt-error'; + + let detail = ''; + if (body.animation) detail = ' ' + body.animation; + + const el = document.createElement('div'); + el.className = 'log-entry'; + el.innerHTML = `${now} ${eventName}${detail}`; + log.prepend(el); + + while (log.children.length > MAX_LOG) log.removeChild(log.lastChild); +} + +document.getElementById('btn-clear-log').addEventListener('click', () => { + document.getElementById('event-log').innerHTML = ''; +}); + +// ── Back to home ────────────────────────────────────────────────────────────── + +document.getElementById('btn-back-home')?.addEventListener('click', () => { + window.location.href = 'index.html'; +}); + +// ── Initialization ──────────────────────────────────────────────────────────── + +connectWS(); diff --git a/public/app.js b/public/app.js index b104787..a0b5f0a 100644 --- a/public/app.js +++ b/public/app.js @@ -566,9 +566,26 @@ function flashHotword(utterance, score) { hotwordTimer = setTimeout(() => el.classList.remove('active'), 3000); } -// ── Init ────────────────────────────────────────────────────────────────────── +// ── Menu: Robot & Mode Selection ──────────────────────────────────────────── + +let selectedRobot = null; +let selectedMode = 'commander'; + +// Get the selected robot and mode from sessionStorage +function initializeMode() { + selectedRobot = sessionStorage.getItem('selectedRobot') || 'jibo-001'; + selectedMode = sessionStorage.getItem('selectedMode') || 'commander'; +} + +// Back to home button +document.getElementById('btn-back-home')?.addEventListener('click', () => { + window.location.href = 'index.html'; +}); + +// ── Initialization ─────────────────────────────────────────────────────────── connectWS(); +initializeMode(); // Populate LLM fields from server config (.env defaults) get('/api/config').then(cfg => { diff --git a/public/commander.html b/public/commander.html new file mode 100644 index 0000000..dc0c58a --- /dev/null +++ b/public/commander.html @@ -0,0 +1,286 @@ + + + + + + Re-Commander — Commander Mode + + + + +
+
+

Re-Commander

+ Connecting… + + +
+ +
+ + +
+ + +
+
Head Navigation
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
Say
+
+ +
+
+ + +
+
+ + +
+
Listen
+
+ + + +
+
+ + + +
+
+ + +
+
(result appears here)
+
+ + +
+
+ + +
+
Voice AI
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Attention Mode
+
+ + + + + + + + +
+
+ + +
+
Volume
+
+ + 75% +
+ +
+ +
+ + +
+ + +
+ +
+
📷
+
No camera feed
+
Use the Camera tab to start video or take a photo
+
+
+
+ + +
+
+
Click on feed → look there
+
+ Track: + + +
+
+
+ + +
+
Photo Strip
+
+
+ +
+ + +
+
+ + + + +
+ + +
+
Video Stream
+
+ + +
+ +
Take Photo
+
+ + +
+
+ + +
+ + +
Subscriptions
+
+ + + +
+ +
+ + +
+
Eye
+ + +
Play Animation
+
+ +
+ + +
Display Text
+
+ +
+ + +
Display Image
+
+ +
+ + +
+ + +
+
Detected Entities
+
Waiting for entity events…
+
Head Touch
+
+
FL
+
ML
+
BL
+
FR
+
MR
+
BR
+
+ +
+ + +
+
+ Event Log + +
+
+
+ +
+
+ + +
+
+ Photo +
+ + + + diff --git a/public/home.js b/public/home.js new file mode 100644 index 0000000..5643b88 --- /dev/null +++ b/public/home.js @@ -0,0 +1,215 @@ +'use strict'; + +// ── WebSocket Setup ────────────────────────────────────────────────────────── + +let ws; +let connected = false; + +function connectWS() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws`); + + ws.onopen = () => { + connected = true; + updateStatus(); + }; + + ws.onclose = () => { + connected = false; + updateStatus(); + setTimeout(connectWS, 2000); + }; + + ws.onerror = () => {}; + + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + handleServerMessage(msg); + }; +} + +function handleServerMessage(msg) { + if (msg.type === 'status') { + updateStatus(); + } +} + +function updateStatus() { + const dot = document.getElementById('status-dot'); + const lbl = document.getElementById('status-label'); + if (connected) { + dot.className = 'ok'; + lbl.textContent = 'Ready'; + } else { + dot.className = ''; + lbl.textContent = 'Connecting…'; + } +} + +// ── Connection Management ──────────────────────────────────────────────────── + +let selectedRobot = null; +let selectedMode = null; +let connections = []; + +const STORAGE_KEY = 're-commander-connections'; + +// Load connections from localStorage +function loadConnections() { + const stored = localStorage.getItem(STORAGE_KEY); + connections = stored ? JSON.parse(stored) : []; + renderConnections(); +} + +// Save connections to localStorage +function saveConnections() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(connections)); +} + +// Render connections list +function renderConnections() { + const list = document.getElementById('connections-list'); + + if (connections.length === 0) { + list.innerHTML = '
No saved connections
'; + return; + } + + list.innerHTML = connections.map((conn, idx) => ` +
+
+
${conn.name}
+
${conn.ip}
+
+ +
+ `).join(''); + + // Add click handlers to select connection + document.querySelectorAll('.connection-item').forEach(item => { + const idx = parseInt(item.dataset.index); + item.addEventListener('click', (e) => { + if (!e.target.classList.contains('connection-delete')) { + selectConnection(idx); + } + }); + }); + + // Add click handlers to delete button + document.querySelectorAll('.connection-delete').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + deleteConnection(idx); + }); + }); + + // Highlight selected connection + if (selectedRobot !== null && selectedRobot < connections.length) { + document.querySelector(`.connection-item[data-index="${selectedRobot}"]`)?.classList.add('selected'); + } +} + +function selectConnection(idx) { + selectedRobot = idx; + const conn = connections[idx]; + sessionStorage.setItem('selectedRobot', conn.ip); + sessionStorage.setItem('selectedRobotName', conn.name); + renderConnections(); + updateLaunchButton(); +} + +function deleteConnection(idx) { + connections.splice(idx, 1); + saveConnections(); + if (selectedRobot === idx) { + selectedRobot = null; + sessionStorage.removeItem('selectedRobot'); + sessionStorage.removeItem('selectedRobotName'); + } + renderConnections(); + updateLaunchButton(); +} + +// Add new connection +document.getElementById('btn-add-connection').addEventListener('click', () => { + const ip = document.getElementById('robot-ip').value.trim(); + + if (!ip) { + alert('Please enter a robot IP or hostname'); + return; + } + + // Check if already exists + if (connections.find(c => c.ip === ip)) { + alert('This connection already exists'); + return; + } + + // Generate a name + const name = `Robot (${ip})`; + connections.push({ name, ip }); + saveConnections(); + renderConnections(); + + // Clear input and select the new connection + document.getElementById('robot-ip').value = ''; + selectConnection(connections.length - 1); +}); + +// Handle Enter key in IP input +document.getElementById('robot-ip').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + document.getElementById('btn-add-connection').click(); + } +}); + +// ── Mode Selection ─────────────────────────────────────────────────────────── + +document.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', function () { + const mode = this.dataset.mode; + selectedMode = mode; + + // Remove active class from all mode buttons + document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + + updateLaunchButton(); + }); +}); + +function updateLaunchButton() { + const btn = document.getElementById('btn-launch'); + if (selectedRobot !== null && selectedMode) { + btn.disabled = false; + } else { + btn.disabled = true; + } +} + +// ── Launch ─────────────────────────────────────────────────────────────────── + +document.getElementById('btn-launch').addEventListener('click', () => { + if (selectedRobot === null || !selectedMode) { + alert('Please select both a robot and a mode'); + return; + } + + const conn = connections[selectedRobot]; + + // Store selection in sessionStorage + sessionStorage.setItem('selectedRobot', conn.ip); + sessionStorage.setItem('selectedRobotName', conn.name); + sessionStorage.setItem('selectedMode', selectedMode); + + // Navigate to the appropriate page + const modeFile = selectedMode + '.html'; + window.location.href = modeFile; +}); + +// ── Initialization ─────────────────────────────────────────────────────────── + +connectWS(); +loadConnections(); diff --git a/public/index.html b/public/index.html index 67db010..59f1294 100644 --- a/public/index.html +++ b/public/index.html @@ -3,8 +3,220 @@ - Re-Commander — Jibo + Re-Commander — Robot Selection + @@ -12,581 +224,52 @@

Re-Commander

Connecting… - -
- - -
- - -
-
Head Navigation
-
-
-
- -
- -
- -
- -
-
+
+
+
Re-Commander
+
Select your robot and operating mode
+ + +
+ +
+
No saved connections
+
+ + +
+ +
- - -
-
Say
-
- -
-
- - + + +
+ +
+ + +
- - -
-
Listen
-
- - - -
-
- - - -
-
- - -
-
(result appears here)
-
- - -
-
- - -
-
Voice AI
-
- - - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
Attention Mode
-
- - - - - - - - -
-
- - -
-
Volume
-
- - 75% -
- -
- -
- - -
- - -
- -
-
📷
-
No camera feed
-
Use the Camera tab to start video or take a photo
-
-
-
- - -
-
-
Click on feed → look there
-
- Track: - - -
-
-
- - -
-
Photo Strip
-
-
- -
- - -
-
- - - - -
- - -
-
Video Stream
-
- - -
- -
Take Photo
-
- - -
-
- - -
- - -
Subscriptions
-
- - - -
- -
- - -
-
Eye
- - -
Play Animation
-
- -
- - -
Display Text
-
- -
- - -
Display Image
-
- -
- - -
- - -
-
Detected Entities
-
Waiting for entity events…
-
Head Touch
-
-
FL
-
ML
-
BL
-
FR
-
MR
-
BR
-
- -
- - -
-
- Event Log - -
-
-
- -
+ + + +
- -
-
- Photo -
+ - diff --git a/public/style.css b/public/style.css index e169858..c3c8aa9 100644 --- a/public/style.css +++ b/public/style.css @@ -374,3 +374,71 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 3px /* ── Video area ── */ .video-controls { display: flex; gap: 6px; align-items: center; } #video-status { font-size: 12px; color: var(--muted); } + +/* ── Menu Section (Robot & Mode) ── */ +.menu-section { + background: linear-gradient(135deg, var(--surface2), var(--surface)); + border-bottom: 2px solid var(--accent); +} + +#robot-select { + width: 100%; + padding: 7px 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +#robot-select:hover { border-color: var(--accent); } +#robot-select:focus { border-color: var(--accent); background: var(--surface); } + +/* ── Mode Buttons ── */ +.mode-buttons { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.mode-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text); + cursor: pointer; + font-size: 13px; + transition: background 0.15s, border-color 0.15s, transform 0.1s; + text-align: left; +} + +.mode-btn:hover { + background: var(--accent2); + border-color: var(--accent); + transform: translateX(2px); +} + +.mode-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #000; + font-weight: 600; + box-shadow: 0 0 10px rgba(0, 212, 255, 0.3); +} + +.mode-icon { + font-size: 18px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.mode-label { + flex: 1; +} diff --git a/public/telepresence.html b/public/telepresence.html new file mode 100644 index 0000000..64e6c62 --- /dev/null +++ b/public/telepresence.html @@ -0,0 +1,143 @@ + + + + + + Re-Commander — Telepresence Mode + + + + +
+
+

Re-Commander — Telepresence

+ Connecting… + + +
+ +
+ + +
+ + +
+
Presence Controls
+
+ + +
+
+ + +
+ +
+ + +
+
Head Navigation
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
Say
+
+ +
+
+ + +
+
+ + +
+
Audio
+
+ + +
+
+ + + 75% +
+
+ +
+ + +
+ + +
+ +
+
📹
+
Telepresence View
+
Start broadcasting to see live camera feed
+
+
+
+ + +
+
+ Waiting to start telepresence... +
+ + +
+ +
+ + +
+
+ + +
+ + +
+
Remote Users
+
+
No remote users connected
+
+
+ + +
+
+ Session Log + +
+
+
+ +
+
+ + + + diff --git a/public/telepresence.js b/public/telepresence.js new file mode 100644 index 0000000..4e75fee --- /dev/null +++ b/public/telepresence.js @@ -0,0 +1,242 @@ +'use strict'; + +// ── WebSocket to server ────────────────────────────────────────────────────── + +let ws; +let connected = false; +let sessionActive = false; +let videoActive = false; +let telepresenceActive = false; +let lastSayTx = null; + +function connectWS() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + ws = new WebSocket(`${proto}://${location.host}/ws`); + + ws.onopen = () => { + connected = true; + }; + + ws.onclose = () => { + connected = false; + setStatus(false, 'Disconnected — reconnecting…'); + setTimeout(connectWS, 2000); + }; + + ws.onerror = () => {}; + + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + handleServerMessage(msg); + }; +} + +function handleServerMessage(msg) { + if (msg.type === 'status') { + sessionActive = !!msg.sessionID; + setStatus(msg.connected && sessionActive, + msg.connected + ? (sessionActive ? 'Connected • ' + msg.sessionID.slice(0, 8) + '…' : 'Connecting…') + : 'Disconnected'); + return; + } + + if (msg.type === 'jiboEvent') { + handleJiboEvent(msg.body, msg.txId); + } +} + +function handleJiboEvent(body, txId) { + if (!body) return; + const evt = body.Event || body.ResponseString || '?'; + logEvent(evt, body, txId); +} + +// ── REST helpers ───────────────────────────────────────────────────────────── + +async function api(method, path, body) { + try { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(path, opts); + return await res.json(); + } catch (err) { + logEvent('api-error: ' + path, { error: err.message }, null); + } +} + +const post = (path, body) => api('POST', path, body); +const get = (path) => api('GET', path); + +// ── Status ─────────────────────────────────────────────────────────────────── + +function setStatus(ok, label) { + const dot = document.getElementById('status-dot'); + const lbl = document.getElementById('status-label'); + dot.className = ok ? 'ok' : ''; + lbl.textContent = label; +} + +// ── Telepresence Controls ──────────────────────────────────────────────────── + +document.getElementById('btn-start-telepresence').addEventListener('click', () => { + const userName = document.getElementById('user-name').value.trim(); + const status = document.getElementById('presence-status').value; + + if (!userName) { + alert('Please enter your name'); + return; + } + + telepresenceActive = true; + document.getElementById('btn-start-telepresence').textContent = '🔴 Broadcasting...'; + document.getElementById('btn-start-telepresence').disabled = true; + document.getElementById('remote-status').innerHTML = + `${userName} is now broadcasting (${status})`; + + logEvent('Telepresence Started', { user: userName, status: status }, null); + post('/api/telepresence/start', { userName, status }); +}); + +// ── Head Navigation ────────────────────────────────────────────────────────── + +const STEP_FRAC = 0.30; +const HORIZ_CENTER_Y = 0.38; + +let moveGen = 0; +let moveHeld = false; + +async function runMoveLoop(dir, gen) { + while (moveHeld && gen === moveGen) { + let pctX, pctY; + if (dir === 'left') { pctX = 0.5 - STEP_FRAC; pctY = HORIZ_CENTER_Y; } + else if (dir === 'right') { pctX = 0.5 + STEP_FRAC; pctY = HORIZ_CENTER_Y; } + else if (dir === 'up') { pctX = 0.5; pctY = 0.5 - STEP_FRAC; } + else { pctX = 0.5; pctY = 0.5 + STEP_FRAC; } + const x = Math.round((1 - pctX) * 640); + const y = Math.round(pctY * 480); + await post('/api/look/step', { x, y }); + } +} + +function startMove(dir) { + moveHeld = true; + runMoveLoop(dir, ++moveGen); +} + +function stopMove() { + moveHeld = false; +} + +function addMoveButton(id, dir) { + const btn = document.getElementById(id); + if (!btn) return; + btn.addEventListener('pointerdown', (e) => { e.preventDefault(); startMove(dir); }); + btn.addEventListener('pointerup', stopMove); + btn.addEventListener('pointercancel', stopMove); + btn.addEventListener('pointerleave', stopMove); +} + +addMoveButton('btn-up', 'up'); +addMoveButton('btn-down', 'down'); +addMoveButton('btn-left', 'left'); +addMoveButton('btn-right', 'right'); + +// ── Say ─────────────────────────────────────────────────────────────────────── + +document.getElementById('btn-say').addEventListener('click', async () => { + const text = document.getElementById('say-text').value.trim(); + if (!text) return; + const r = await post('/api/say', { text }); + if (r) lastSayTx = r.txId; +}); + +document.getElementById('btn-say-cancel').addEventListener('click', () => { + if (lastSayTx) post('/api/cancel', { txId: lastSayTx }); +}); + +// ── Camera / Video ──────────────────────────────────────────────────────────── + +let videoTxId = null; + +document.getElementById('btn-video-start').addEventListener('click', async () => { + const r = await post('/api/video/start', { duration: 0 }); + if (r) videoTxId = r.txId; +}); + +document.getElementById('btn-video-stop').addEventListener('click', () => { + post('/api/video/stop', {}); + stopVideoFeed(); +}); + +function startVideoFeed(uri) { + const feed = document.getElementById('camera-feed'); + const noFeed = document.getElementById('camera-no-feed'); + feed.src = '/proxy/stream?uri=' + encodeURIComponent(uri); + feed.style.display = 'block'; + noFeed.style.display = 'none'; + videoActive = true; +} + +function stopVideoFeed() { + const feed = document.getElementById('camera-feed'); + feed.src = ''; + feed.style.display = 'none'; + document.getElementById('camera-no-feed').style.display = ''; + videoActive = false; +} + +// ── Volume Control ──────────────────────────────────────────────────────────── + +document.getElementById('speaker-volume').addEventListener('input', function () { + document.getElementById('volume-label').textContent = + Math.round(this.value * 100) + '%'; +}); + +// ── Tabs ────────────────────────────────────────────────────────────────────── + +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', function () { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + this.classList.add('active'); + document.getElementById(this.dataset.tab).classList.add('active'); + }); +}); + +// ── Event log ───────────────────────────────────────────────────────────────── + +const MAX_LOG = 200; + +function logEvent(eventName, body, txId) { + const log = document.getElementById('event-log'); + const now = new Date().toLocaleTimeString(); + + let cls = 'evt'; + if (eventName.toLowerCase().includes('error')) cls = 'evt-error'; + + let detail = ''; + if (body.user) detail = ' [' + body.user + ']'; + + const el = document.createElement('div'); + el.className = 'log-entry'; + el.innerHTML = `${now} ${eventName}${detail}`; + log.prepend(el); + + while (log.children.length > MAX_LOG) log.removeChild(log.lastChild); +} + +document.getElementById('btn-clear-log').addEventListener('click', () => { + document.getElementById('event-log').innerHTML = ''; +}); + +// ── Back to home ────────────────────────────────────────────────────────────── + +document.getElementById('btn-back-home')?.addEventListener('click', () => { + window.location.href = 'index.html'; +}); + +// ── Initialization ──────────────────────────────────────────────────────────── + +connectWS(); diff --git a/server.js b/server.js index b6236cd..3f916aa 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ const fs = require('fs'); require('dotenv').config(); -const JIBO_HOST = '192.168.1.217'; +const JIBO_HOST = '192.168.1.10'; const JIBO_PORT = 8160; const APP_PORT = process.env.PORT || 3000; From 9b4cff9af1d8e43578a82cb496bc3c1e0699b0fb Mon Sep 17 00:00:00 2001 From: Kevin Date: Sun, 19 Apr 2026 22:55:40 +0300 Subject: [PATCH 2/2] telepresence and animation menus --- package-lock.json | 7 + package.json | 1 + public/animator-backup.html | 554 ++++++++++++++++++++++++++++++++++++ public/animator.html | 511 +++++++++++++++++++++++++-------- public/animator.js | 355 +++++++++++++++-------- public/style.css | 1 + public/telepresence.html | 281 ++++++++++-------- 7 files changed, 1349 insertions(+), 361 deletions(-) create mode 100644 public/animator-backup.html diff --git a/package-lock.json b/package-lock.json index 1825c1a..bcb55ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dotenv": "^17.4.2", "express": "^4.18.2", + "golden-layout": "^2.6.0", "ws": "^8.14.2" } }, @@ -371,6 +372,12 @@ "node": ">= 0.4" } }, + "node_modules/golden-layout": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/golden-layout/-/golden-layout-2.6.0.tgz", + "integrity": "sha512-sIVQCiRWOymHbVD1Aw/T9/ijbPYAVGBlgGYd1N9MRKfcyBNSpjr87Vg9nSHm+RCT8ELrvK8IJYJV0QRJuVUkCQ==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", diff --git a/package.json b/package.json index 4e15789..caf37ba 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "dotenv": "^17.4.2", "express": "^4.18.2", + "golden-layout": "^2.6.0", "ws": "^8.14.2" } } diff --git a/public/animator-backup.html b/public/animator-backup.html new file mode 100644 index 0000000..68de77c --- /dev/null +++ b/public/animator-backup.html @@ -0,0 +1,554 @@ + + + + + + Re-Commander — Animator + + + + + + +
+
+
+

Re-Commander — Animator

+
+ Connecting… + +
+
+ +
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+ + +
+
Animation Library
+
+
Eye_Blink_01
+
Eye_Blink_02
+
Eye_Double_Blink
+
eye_happy_01
+
eye_happy_02
+
eye_sad_01
+
eye_scared_00
+
Eye_Curious_01
+
Eye_Look_Center
+
Eye_Look_Left
+
Eye_Look_Right
+
Eye_Look_Up
+
Eye_Look_Down
+
Gesture_Wave
+
Gesture_Nod
+
+
+ + +
+
+
Canvas
+
🎬 Preview
+
+
+ Imagine a 3d jibo model in a theater kinda thing +
+
+ + +
+
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+ Mode: + Virtual Robot +
+
+ Animation: + None +
+
+ Status: + Ready +
+
+ Ready for animation +
+
+ + + + diff --git a/public/animator.html b/public/animator.html index cf3242d..1ded7c4 100644 --- a/public/animator.html +++ b/public/animator.html @@ -3,143 +3,414 @@ - Re-Commander — Animator Mode + Re-Commander — Animator + + + +
+ +
+
+

Re-Commander — Animator

+ Connecting… +
-
-
-

Re-Commander — Animator

- Connecting… - - -
- -
- - -
- - -
-
Animation Library
-
- - -
- + +
+
+ + +
- -
-
Animation Sequence
-
- -
-
- - -
+
+ +
+ +
- -
-
Timeline
-
+
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + + + + + + + + -
-
+ + - -
-
- Photo -
- - + + diff --git a/public/animator.js b/public/animator.js index b21bfbe..e6392f3 100644 --- a/public/animator.js +++ b/public/animator.js @@ -48,6 +48,11 @@ function handleServerMessage(msg) { function handleJiboEvent(body, txId) { if (!body) return; const evt = body.Event || body.ResponseString || '?'; + + if (evt === 'onVideoReady' && body.URI) { + startVideoFeed(body.URI); + } + logEvent(evt, body, txId); } @@ -72,149 +77,247 @@ const get = (path) => api('GET', path); function setStatus(ok, label) { const dot = document.getElementById('status-dot'); const lbl = document.getElementById('status-label'); - dot.className = ok ? 'ok' : ''; - lbl.textContent = label; + if (dot) dot.className = ok ? 'ok' : ''; + if (lbl) lbl.textContent = label; } -// ── Animation Control ──────────────────────────────────────────────────────── +// ── Event Log ──────────────────────────────────────────────────────────────── -document.getElementById('btn-play-anim').addEventListener('click', () => { - const name = document.getElementById('anim-select').value; - if (name) { - post('/api/display/anim', { name }); - logEvent('Animation Played', { animation: name }, null); - } -}); - -document.getElementById('btn-play-sequence').addEventListener('click', () => { - const sequence = document.getElementById('sequence-list').value - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - - if (sequence.length === 0) { - alert('Please enter at least one animation'); - return; - } - - const repeatCount = parseInt(document.getElementById('repeat-count').value) || 1; - const delay = parseInt(document.getElementById('anim-delay').value) || 500; - - post('/api/display/anim-sequence', { sequence, repeatCount, delay }); - logEvent('Sequence Started', { count: sequence.length, repeats: repeatCount }, null); -}); - -document.getElementById('btn-stop-sequence').addEventListener('click', () => { - post('/api/cancel', {}); - logEvent('Sequence Stopped', {}, null); -}); - -// ── Camera / Video ──────────────────────────────────────────────────────────── - -let videoTxId = null; - -document.getElementById('btn-video-start').addEventListener('click', async () => { - const r = await post('/api/video/start', { duration: 0 }); - if (r) videoTxId = r.txId; - document.getElementById('video-status').textContent = 'Waiting for VideoReady…'; -}); - -document.getElementById('btn-video-stop').addEventListener('click', () => { - post('/api/video/stop', {}); - stopVideoFeed(); -}); - -function startVideoFeed(uri) { - const feed = document.getElementById('camera-feed'); - const noFeed = document.getElementById('camera-no-feed'); - feed.src = '/proxy/stream?uri=' + encodeURIComponent(uri); - feed.style.display = 'block'; - noFeed.style.display = 'none'; - videoActive = true; -} - -function stopVideoFeed() { - const feed = document.getElementById('camera-feed'); - feed.src = ''; - feed.style.display = 'none'; - document.getElementById('camera-no-feed').style.display = ''; - videoActive = false; -} - -// ── Take Photo ──────────────────────────────────────────────────────────────── - -document.getElementById('btn-photo').addEventListener('click', () => { - post('/api/photo', { - camera: 'right', - resolution: document.getElementById('photo-res').value - }); -}); - -function addPhoto(url) { - const strip = document.getElementById('photo-strip'); - const img = document.createElement('img'); - img.src = url; - img.title = url; - img.addEventListener('click', () => openPhotoModal(img.src)); - strip.prepend(img); -} - -// ── Photo modal ─────────────────────────────────────────────────────────────── - -function openPhotoModal(src) { - document.getElementById('photo-modal-img').src = src; - document.getElementById('photo-modal').classList.add('open'); -} - -document.getElementById('photo-modal').addEventListener('click', (e) => { - if (e.target === e.currentTarget || e.target.id === 'photo-modal-close') { - document.getElementById('photo-modal').classList.remove('open'); - } -}); - -// ── Tabs ────────────────────────────────────────────────────────────────────── - -document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', function () { - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - this.classList.add('active'); - document.getElementById(this.dataset.tab).classList.add('active'); - }); -}); - -// ── Event log ───────────────────────────────────────────────────────────────── - -const MAX_LOG = 200; +const MAX_LOG = 100; function logEvent(eventName, body, txId) { const log = document.getElementById('event-log'); const now = new Date().toLocaleTimeString(); + // Pick color class based on event type let cls = 'evt'; if (eventName.toLowerCase().includes('error')) cls = 'evt-error'; + else if (eventName.toLowerCase().includes('play')) cls = 'evt-success'; + else if (eventName.toLowerCase().includes('stop')) cls = 'evt-error'; + else if (eventName.toLowerCase().includes('selected')) cls = 'evt-entity'; + // Extract detail from body let detail = ''; - if (body.animation) detail = ' ' + body.animation; + if (body && body.animation) detail = ' "' + body.animation + '"'; + else if (body && body.error) detail = ' — ' + body.error; + else if (body && body.uri) detail = ' ' + body.uri; const el = document.createElement('div'); el.className = 'log-entry'; el.innerHTML = `${now} ${eventName}${detail}`; - log.prepend(el); + + if (log) { + log.prepend(el); + // Trim log + while (log.children.length > MAX_LOG) log.removeChild(log.lastChild); + } - while (log.children.length > MAX_LOG) log.removeChild(log.lastChild); + console.log(`[${now}]`, eventName, body); } -document.getElementById('btn-clear-log').addEventListener('click', () => { - document.getElementById('event-log').innerHTML = ''; +// ── Video Helpers ─────────────────────────────────────────────────────────── + +function startVideoFeed(uri) { + logEvent('Video feed ready', { uri }, null); +} + +function stopVideoFeed() { + logEvent('Video stopped', {}, null); +} + +// ── GoldenLayout Setup ─────────────────────────────────────────────────────── + +const layoutConfig = { + root: { + type: 'row', + children: [ + { + type: 'component', + componentType: 'library', + componentState: { title: 'Animation Library' }, + width: 20, + title: 'Library' + }, + { + type: 'column', + children: [ + { + type: 'component', + componentType: 'canvas', + componentState: { title: 'Canvas' }, + title: 'Canvas', + height: 70 + } + ], + width: 60 + }, + { + type: 'column', + children: [ + { + type: 'stack', + activeItemIndex: 0, + width: 20, + children: [ + { + type: 'component', + componentType: 'inspector', + componentState: { title: 'Inspector' }, + title: 'Inspector' + }, + { + type: 'component', + componentType: 'log', + componentState: { title: 'Log' }, + title: 'Log' + } + ] + } + ] + } + ] + } +}; + +// ── Initialization ────────────────────────────────────────────────────────── + +function setupComponentRegistry() { + const componentRegistry = new GoldenLayout.ComponentRegistry(); + + componentRegistry.registerComponent('library', (container, state) => { + const content = document.querySelector('#tpl-library').content.cloneNode(true); + container.element.appendChild(content); + + const libraryList = container.element.querySelector('#library-list'); + const animations = [ + 'Eye_Blink_01', 'Eye_Blink_02', 'Eye_Double_Blink', 'eye_happy_01', 'eye_happy_02', + 'eye_sad_01', 'eye_scared_00', 'Eye_Curious_01', 'Eye_Look_Center', 'Eye_Look_Left', + 'Eye_Look_Right', 'Eye_Look_Up', 'Eye_Look_Down', 'Gesture_Wave', 'Gesture_Nod' + ]; + + animations.forEach(anim => { + const item = document.createElement('div'); + item.className = 'library-item'; + item.textContent = anim; + item.addEventListener('click', () => { + document.querySelectorAll('.library-item').forEach(i => i.classList.remove('selected')); + item.classList.add('selected'); + + const inspectorInput = document.querySelector('.inspector-field input'); + if (inspectorInput) inspectorInput.value = anim; + + logEvent('Animation selected', { animation: anim }, null); + }); + libraryList.appendChild(item); + }); + }); + + componentRegistry.registerComponent('canvas', (container, state) => { + const content = document.querySelector('#tpl-canvas').content.cloneNode(true); + container.element.appendChild(content); + }); + + componentRegistry.registerComponent('inspector', (container, state) => { + const content = document.querySelector('#tpl-inspector').content.cloneNode(true); + container.element.appendChild(content); + + const applyBtn = container.element.querySelector('.inspector-field button'); + if (applyBtn) { + applyBtn.addEventListener('click', async () => { + const animName = container.element.querySelector('.inspector-field input').value; + if (!animName) { + alert('Please select an animation'); + return; + } + + const inputs = container.element.querySelectorAll('.inspector-field input'); + const duration = parseInt(inputs[1]?.value) || 500; + const repeat = parseInt(inputs[2]?.value) || 1; + const delay = parseInt(inputs[3]?.value) || 0; + + await post('/api/display/anim', { name: animName, duration, repeat, delay }); + logEvent('Applied animation', { animation: animName, duration, repeat, delay }, null); + }); + } + }); + + componentRegistry.registerComponent('log', (container, state) => { + const content = document.querySelector('#tpl-log').content.cloneNode(true); + container.element.appendChild(content); + }); + + return componentRegistry; +} + +function setupToolbarButtons() { + const btn = (id, fn) => { + const el = document.getElementById(id); + if (el) el.addEventListener('click', fn); + }; + + btn('btn-new', () => alert('New animation - Coming soon')); + btn('btn-open', () => alert('Open animation - Coming soon')); + btn('btn-save', () => alert('Save animation - Coming soon')); + btn('btn-undo', () => logEvent('Undo', {}, null)); + btn('btn-redo', () => logEvent('Redo', {}, null)); + + btn('btn-play', async () => { + const inspectorInput = document.querySelector('.inspector-field input'); + const animName = inspectorInput ? inspectorInput.value : 'greeting'; + await post('/api/display/anim', { name: animName }); + logEvent('Playing animation', { animation: animName }, null); + }); + + btn('btn-pause', () => { + logEvent('Pause animation', {}, null); + }); + + btn('btn-stop', () => { + post('/api/cancel', {}); + logEvent('Stop animation', {}, null); + }); + + btn('btn-timeline', () => alert('Timeline view - Coming soon')); + + btn('btn-preview', async () => { + await post('/api/video/stop', {}); + await new Promise(resolve => setTimeout(resolve, 100)); + await post('/api/video/start', { duration: 0 }); + logEvent('Preview video started', {}, null); + }); + + btn('btn-settings', () => alert('Settings - Coming soon')); + + btn('btn-back-home', () => { + post('/api/video/stop', {}); + window.location.href = 'index.html'; + }); +} + +// ── Initialization ────────────────────────────────────────────────────────── + +function waitForGoldenLayout(callback) { + if (typeof GoldenLayout === 'undefined') { + setTimeout(() => waitForGoldenLayout(callback), 50); + } else { + callback(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + waitForGoldenLayout(() => { + // Setup components + const componentRegistry = setupComponentRegistry(); + + // Initialize GoldenLayout + const layout = new GoldenLayout(layoutConfig, componentRegistry, document.querySelector('#layout-root')); + layout.init(); + + // Setup toolbar + setupToolbarButtons(); + + // Connect to server + connectWS(); + logEvent('Animator initialized with GoldenLayout', {}, null); + }); }); - -// ── Back to home ────────────────────────────────────────────────────────────── - -document.getElementById('btn-back-home')?.addEventListener('click', () => { - window.location.href = 'index.html'; -}); - -// ── Initialization ──────────────────────────────────────────────────────────── - -connectWS(); diff --git a/public/style.css b/public/style.css index c3c8aa9..c9b2ae0 100644 --- a/public/style.css +++ b/public/style.css @@ -273,6 +273,7 @@ label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 3px .log-entry .evt-error { color: var(--danger); } .log-entry .evt-motion { color: var(--warn); } .log-entry .evt-entity { color: var(--success); } +.log-entry .evt-success { color: var(--success); } /* ── Tabs ── */ .tabs { diff --git a/public/telepresence.html b/public/telepresence.html index 64e6c62..960ec16 100644 --- a/public/telepresence.html +++ b/public/telepresence.html @@ -5,6 +5,137 @@ Re-Commander — Telepresence Mode + @@ -13,131 +144,51 @@

Re-Commander — Telepresence

Connecting… - -
- - -
- - -
-
Presence Controls
-
- - -
-
- - -
- -
- - -
-
Head Navigation
-
-
-
- -
- -
- -
- -
-
-
-
- - -
-
Say
-
- -
-
- - -
-
- - -
-
Audio
-
- - -
-
- - - 75% -
-
- -
- - -
- - +
+ + +
+ +
🎥 Telepresence Stream
+
Ready
+
+ + +
- +
📹
-
Telepresence View
-
Start broadcasting to see live camera feed
-
-
-
- - -
-
- Waiting to start telepresence... -
- - -
- -
- - -
-
- - -
- - -
-
Remote Users
-
-
No remote users connected
+
No camera feed
+
Click restart video below to start broadcasting
- - -
-
- Session Log - -
-
+
+ + +
+
+ +
- -
+ +
+ + + +
+
+
+ +