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 new file mode 100644 index 0000000..1ded7c4 --- /dev/null +++ b/public/animator.html @@ -0,0 +1,416 @@ + + + + + + Re-Commander — Animator + + + + + + +
+ +
+
+

Re-Commander — Animator

+ Connecting… +
+ + +
+
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + diff --git a/public/animator.js b/public/animator.js new file mode 100644 index 0000000..e6392f3 --- /dev/null +++ b/public/animator.js @@ -0,0 +1,323 @@ +'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 || '?'; + + if (evt === 'onVideoReady' && body.URI) { + startVideoFeed(body.URI); + } + + 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'); + if (dot) dot.className = ok ? 'ok' : ''; + if (lbl) lbl.textContent = label; +} + +// ── Event Log ──────────────────────────────────────────────────────────────── + +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 && 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}`; + + if (log) { + log.prepend(el); + // Trim log + while (log.children.length > MAX_LOG) log.removeChild(log.lastChild); + } + + console.log(`[${now}]`, eventName, body); +} + +// ── 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); + }); +}); 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..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 { @@ -374,3 +375,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..960ec16 --- /dev/null +++ b/public/telepresence.html @@ -0,0 +1,194 @@ + + + + + + Re-Commander — Telepresence Mode + + + + + +
+
+

Re-Commander — Telepresence

+ Connecting… + +
+ +
+ + +
+ +
🎥 Telepresence Stream
+
Ready
+
+ + +
+
+ +
+
📹
+
No camera feed
+
Click restart video below to start broadcasting
+
+
+
+ + +
+
+ + +
+ +
+ + + +
+
+ +
+ + + + + + 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 a7ceb27..dc33640 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;