GeoTalk / robot_viewer.html
ShebMichel
Add Chair/moderator who introduces the topic and speakers
77a5ef5
Raw
History Blame Contribute Delete
12.9 kB
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#111; font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; }
#app {
width:100%; height:640px; display:flex; flex-direction:column;
background:#111; color:#fff;
}
/* Header */
#header {
display:flex; align-items:center; justify-content:space-between;
padding:12px 20px; background:#1a1a1a; border-bottom:1px solid #333;
}
#header .title { font-size:15px; font-weight:700; }
#header .live-badge {
display:flex; align-items:center; gap:6px; font-size:12px; color:#aaa;
}
#header .live-dot { width:8px; height:8px; border-radius:50%; background:#e53935; animation:pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Robot panels - side by side like video call */
#panels {
flex:1; display:flex; gap:8px; padding:8px; min-height:0;
}
.panel {
flex:1; position:relative; border-radius:12px; overflow:hidden;
border:2px solid #333; transition:border-color 0.3s, box-shadow 0.3s;
background:linear-gradient(180deg, #1a2332 0%, #0d1520 100%);
}
.panel.active {
border-color:#4caf50;
box-shadow:0 0 20px rgba(76,175,80,0.3);
}
.panel.active-b {
border-color:#9c27b0;
box-shadow:0 0 20px rgba(156,39,176,0.3);
}
.panel.active-chair {
border-color:#ff9800;
box-shadow:0 0 20px rgba(255,152,0,0.3);
}
.panel-chair {
flex:0.4; display:flex; flex-direction:column; align-items:center; justify-content:center;
background:linear-gradient(180deg, #1a2332 0%, #0d1520 100%);
}
.chair-icon { font-size:48px; opacity:0.6; transition:opacity 0.3s; }
.panel-chair.active-chair .chair-icon { opacity:1; animation:chair-pulse 1s ease-in-out infinite; }
@keyframes chair-pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.1)} }
.panel canvas { width:100%; height:100%; display:block; }
.panel-label {
position:absolute; bottom:12px; left:12px;
display:flex; align-items:center; gap:8px;
background:rgba(0,0,0,0.7); backdrop-filter:blur(6px);
padding:6px 12px; border-radius:20px;
}
.panel-label .dot { width:8px; height:8px; border-radius:50%; background:#555; transition:background 0.3s; }
.panel.active .panel-label .dot, .panel.active-b .panel-label .dot { background:#4caf50; }
.panel-label .name { font-size:12px; font-weight:700; }
.panel-label .desc { font-size:11px; color:#999; }
/* Caption bar */
#caption-bar {
padding:12px 20px; min-height:60px;
background:#1a1a1a; border-top:1px solid #333;
display:flex; align-items:center; gap:12px;
}
#caption-bar .cap-speaker { font-weight:700; font-size:14px; color:#4fc3f7; white-space:nowrap; }
#caption-bar .cap-speaker.chair { color:#ff9800; }
#caption-bar .cap-text { font-size:14px; color:#e0e0e0; line-height:1.4; }
/* Controls */
#controls {
display:flex; align-items:center; gap:12px;
padding:8px 20px; background:#111; border-top:1px solid #222;
}
#play-btn {
width:36px; height:36px; border-radius:50%; border:none;
background:#4fc3f7; color:#111; font-size:16px; cursor:pointer;
display:flex; align-items:center; justify-content:center;
}
#play-btn:hover { background:#81d4fa; }
#progress { flex:1; height:4px; background:#333; border-radius:2px; cursor:pointer; position:relative; }
#progress-fill { height:100%; background:#4fc3f7; border-radius:2px; width:0%; transition:width 0.15s linear; }
#time { font-size:11px; color:#888; }
</style>
</head>
<body>
<div id="app">
<div id="header">
<span class="title">🎙️ GeoTalk Live</span>
<div class="live-badge"><div class="live-dot"></div>LIVE</div>
</div>
<div id="panels">
<div class="panel" id="panel-a">
<canvas id="canvas-a"></canvas>
<div class="panel-label">
<div class="dot"></div>
<span class="name" id="name-a">__SPEAKER_A__</span>
</div>
</div>
<div class="panel panel-chair" id="panel-chair">
<div class="chair-icon">🎙️</div>
<div class="panel-label">
<div class="dot"></div>
<span class="name">The Chair</span>
</div>
</div>
<div class="panel" id="panel-b">
<canvas id="canvas-b"></canvas>
<div class="panel-label">
<div class="dot"></div>
<span class="name" id="name-b">__SPEAKER_B__</span>
</div>
</div>
</div>
<div id="caption-bar">
<span class="cap-speaker" id="cap-spk"></span>
<span class="cap-text" id="cap-txt">Click ▶ to start the episode</span>
</div>
<div id="controls">
<button id="play-btn"></button>
<div id="progress"><div id="progress-fill"></div></div>
<span id="time">0:00 / 0:00</span>
</div>
</div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
// --- Two separate Three.js scenes (one per panel) ---
function createScene(canvasId) {
const canvas = document.getElementById(canvasId);
const container = canvas.parentElement;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, container.clientWidth / container.clientHeight, 0.1, 50);
camera.position.set(0, 1.2, 3.5);
camera.lookAt(0, 0.8, 0);
scene.add(new THREE.AmbientLight(0x404060, 1.0));
const key = new THREE.DirectionalLight(0xffffff, 1.0); key.position.set(2, 4, 3); scene.add(key);
const fill = new THREE.DirectionalLight(0x4fc3f7, 0.3); fill.position.set(-2, 2, -1); scene.add(fill);
return { renderer, scene, camera, container };
}
function createRobot(scene, color) {
const g = new THREE.Group();
const mc = new THREE.Color(color);
// Body
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf5f5f5, roughness: 0.4, metalness: 0.2 });
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.35, 0.5, 16, 16), bodyMat);
body.position.y = 0.6; g.add(body);
// Head
const headMat = new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.3, metalness: 0.1 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.32, 24, 24), headMat);
head.position.y = 1.4; g.add(head);
// Flatten slightly for Reachy look
head.scale.set(1, 0.85, 0.9);
// Eyes (camera lenses)
const lensMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.1, metalness: 0.9 });
const lensRingMat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.8, roughness: 0.3 });
for (const ex of [-0.12, 0.12]) {
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.05, 16), lensMat);
lens.position.set(ex, 1.42, 0.28); lens.rotation.x = Math.PI/2; g.add(lens);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.065, 0.012, 8, 16), lensRingMat);
ring.position.set(ex, 1.42, 0.29); g.add(ring);
}
// Antenna
const antMat = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.7, roughness: 0.4 });
const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.012, 0.25, 8), antMat);
ant.position.set(0, 1.75, 0); g.add(ant);
const tipMat = new THREE.MeshStandardMaterial({ color: mc, emissive: mc, emissiveIntensity: 0.5 });
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.025, 8, 8), tipMat);
tip.position.set(0, 1.88, 0); g.add(tip);
// Base
const baseMat = new THREE.MeshStandardMaterial({ color: 0xe0e0e0, roughness: 0.5 });
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.35, 0.2, 16), baseMat);
base.position.y = 0.1; g.add(base);
scene.add(g);
return { group: g, head, ant, tip, tipMat, body };
}
const scA = createScene('canvas-a');
const scB = createScene('canvas-b');
const robotA = createRobot(scA.scene, 0x4caf50);
const robotB = createRobot(scB.scene, 0x9c27b0);
let curSpk = -1;
function talkAnim(robot, t) {
const h = robot.head;
robot._phase = (robot._phase || 0) + 0.12;
h.position.y = 1.4 + Math.sin(robot._phase * 3) * 0.015;
h.rotation.x = Math.sin(robot._phase * 2) * 0.04;
h.rotation.z = Math.sin(robot._phase * 1.3) * 0.03;
robot.ant.rotation.z = Math.sin(robot._phase * 4) * 0.1;
robot.tipMat.emissiveIntensity = 0.5 + Math.sin(robot._phase * 5) * 0.4;
robot.group.rotation.y = Math.sin(robot._phase * 0.8) * 0.05;
}
function idleAnim(robot, t, offset) {
robot.head.position.y = 1.4 + Math.sin(t * 0.6 + offset) * 0.008;
robot.head.rotation.x = 0;
robot.head.rotation.z = Math.sin(t * 0.3 + offset) * 0.01;
robot.ant.rotation.z = Math.sin(t * 0.8 + offset) * 0.03;
robot.tipMat.emissiveIntensity = 0.3 + Math.sin(t * 1.5) * 0.1;
robot.group.rotation.y = 0;
}
function listenAnim(robot, t) {
robot.head.rotation.z = Math.sin(t * 0.4) * 0.06;
robot.head.rotation.x = -0.03;
robot.tipMat.emissiveIntensity = 0.2;
}
function animate() {
requestAnimationFrame(animate);
const t = performance.now() / 1000;
if (curSpk === 0) { talkAnim(robotA, t); listenAnim(robotB, t); }
else if (curSpk === 1) { talkAnim(robotB, t); listenAnim(robotA, t); }
else { idleAnim(robotA, t, 0); idleAnim(robotB, t, 2); }
scA.renderer.render(scA.scene, scA.camera);
scB.renderer.render(scB.scene, scB.camera);
}
animate();
// Resize
function resize() {
for (const sc of [scA, scB]) {
const w = sc.container.clientWidth, h = sc.container.clientHeight;
if (w > 0 && h > 0) {
sc.camera.aspect = w / h; sc.camera.updateProjectionMatrix();
sc.renderer.setSize(w, h);
}
}
}
window.addEventListener('resize', resize);
new ResizeObserver(resize).observe(document.getElementById('panels'));
// --- Audio + Timeline ---
const timeline = __TIMELINE_DATA__;
let totalDur = 0;
const cumTimes = [];
timeline.forEach(it => { cumTimes.push(totalDur); totalDur += it.duration; });
const audioB64 = "__AUDIO_B64__";
const audio = new Audio("data:audio/mpeg;base64," + audioB64);
const playBtn = document.getElementById('play-btn');
const progFill = document.getElementById('progress-fill');
const progBar = document.getElementById('progress');
const timeEl = document.getElementById('time');
const panelA = document.getElementById('panel-a');
const panelB = document.getElementById('panel-b');
const panelChair = document.getElementById('panel-chair');
const capSpk = document.getElementById('cap-spk');
const capTxt = document.getElementById('cap-txt');
function fmt(s) { return Math.floor(s/60)+':'+String(Math.floor(s%60)).padStart(2,'0'); }
timeEl.textContent = `0:00 / ${fmt(totalDur)}`;
playBtn.addEventListener('click', () => { if (audio.paused) audio.play(); else audio.pause(); });
audio.addEventListener('play', () => { playBtn.textContent = '⏸'; });
audio.addEventListener('pause', () => { playBtn.textContent = '▶'; });
audio.addEventListener('ended', () => {
playBtn.textContent = '▶'; curSpk = -1;
panelA.classList.remove('active'); panelB.classList.remove('active-b'); panelChair.classList.remove('active-chair');
capSpk.textContent = ''; capTxt.textContent = '✅ Episode complete!';
});
progBar.addEventListener('click', e => {
const r = progBar.getBoundingClientRect();
audio.currentTime = ((e.clientX - r.left) / r.width) * (audio.duration || totalDur);
});
let activeIdx = -1;
setInterval(() => {
if (!audio.duration) return;
const el = audio.currentTime;
const dur = audio.duration;
progFill.style.width = (el/dur*100) + '%';
timeEl.textContent = `${fmt(el)} / ${fmt(dur)}`;
if (audio.paused) return;
// Find active line
let ni = -1;
for (let i = 0; i < timeline.length; i++) {
if (el >= cumTimes[i] && el < cumTimes[i] + timeline[i].duration) { ni = i; break; }
}
if (ni !== activeIdx) {
activeIdx = ni;
if (ni >= 0) {
const it = timeline[ni];
curSpk = it.speakerIdx === 2 ? -1 : it.speakerIdx; // chair doesn't animate robots
capSpk.textContent = it.speaker + ':';
capSpk.className = 'cap-speaker' + (it.speakerIdx === 2 ? ' chair' : '');
capTxt.textContent = it.line;
panelA.classList.toggle('active', it.speakerIdx === 0);
panelB.classList.toggle('active-b', it.speakerIdx === 1);
panelChair.classList.toggle('active-chair', it.speakerIdx === 2);
}
}
}, 80);
// Auto-play on load
audio.play().catch(() => {
capTxt.textContent = '▶ Click play to start the episode';
});
</script>
</body>
</html>