324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
'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 = `<span class="ts">${now}</span> <span class="${cls}">${eventName}</span>${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);
|
|
});
|
|
});
|