Spaces:
Sleeping
Sleeping
| <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> | |