243 lines
8.8 KiB
JavaScript
243 lines
8.8 KiB
JavaScript
|
|
'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 =
|
||
|
|
`<strong>${userName}</strong> 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 = `<span class="ts">${now}</span> <span class="${cls}">${eventName}</span>${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();
|