← back to Big Red
public/desktop.html
777 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Big Red</title>
<style>
:root {
--bg-1: #1a0506;
--bg-2: #320809;
--red: #d8323a;
--red-deep: #8b1419;
--red-glow: #ff5560;
--skin: #f3c9b1;
--skin-shadow: #c89377;
--hair: #b32221;
--eye: #2a1a0e;
--paper: #f7efe6;
--ink: #1a0d0d;
--good: #4caf50;
--warn: #ffb84d;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background:
radial-gradient(circle at 30% 10%, #44090b 0%, transparent 55%),
radial-gradient(circle at 80% 90%, #2a0506 0%, transparent 60%),
linear-gradient(160deg, var(--bg-1), var(--bg-2));
color: var(--paper);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: minmax(420px, 1.1fr) minmax(360px, 1fr);
grid-template-rows: 1fr;
height: 100vh;
gap: 0;
}
.stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.brand {
position: absolute;
top: 18px; left: 22px;
font-weight: 900;
letter-spacing: 0.18em;
font-size: 13px;
color: var(--red-glow);
text-shadow: 0 0 18px rgba(255,85,96,.45);
}
.status-dot {
position: absolute;
top: 18px; right: 22px;
display: flex; gap: 14px; align-items: center;
font-size: 12px;
color: rgba(255,255,255,.55);
}
.status-dot .pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
}
.status-dot .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 8px currentColor;
}
.status-dot .dot.ok { background: var(--good); }
.avatar-wrap {
width: min(78%, 520px);
aspect-ratio: 1 / 1.05;
position: relative;
filter: drop-shadow(0 18px 60px rgba(0,0,0,.6)) drop-shadow(0 0 80px rgba(216,50,58,.18));
}
svg.avatar { width: 100%; height: 100%; display: block; }
/* Lip-sync: scale mouth height with --speak (0..1) */
.mouth-inner {
transform-origin: 250px 360px;
transform: scaleY(calc(0.15 + var(--speak, 0) * 1));
transition: transform 80ms ease-out;
}
/* Photo-avatar mouth overlay uses its own (smaller) viewBox-centered origin */
#photoMouthOverlay .mouth-inner {
transform-origin: 60px 25px;
transform: scaleY(calc(0.15 + var(--speak, 0) * 1.6));
}
/* Subtle idle bob */
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
.avatar { animation: bob 6s ease-in-out infinite; }
/* Eye blink */
@keyframes blink { 0%, 92%, 100% { transform: scaleY(1); } 95%, 97% { transform: scaleY(0.05); } }
.eye-lid { transform-origin: center; animation: blink 5.2s infinite; }
.caption {
position: absolute;
left: 50%;
bottom: 38px;
transform: translateX(-50%);
max-width: 80%;
text-align: center;
font-size: 17px;
line-height: 1.4;
color: rgba(255,255,255,.85);
background: rgba(0,0,0,.42);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(10px);
border-radius: 14px;
padding: 10px 16px;
min-height: 22px;
opacity: 0;
transition: opacity .25s ease;
}
.caption.show { opacity: 1; }
.panel {
background: rgba(0,0,0,.32);
border-left: 1px solid rgba(255,255,255,.08);
display: flex;
flex-direction: column;
min-width: 0;
}
.tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,.08);
background: rgba(0,0,0,.18);
}
.tab {
flex: 1; padding: 14px 12px;
text-align: center; font-size: 13px;
cursor: pointer;
color: rgba(255,255,255,.55);
border-bottom: 2px solid transparent;
user-select: none;
}
.tab.active { color: var(--paper); border-bottom-color: var(--red); }
.tab-body { flex: 1; min-height: 0; display: none; flex-direction: column; }
.tab-body.active { display: flex; }
/* chat */
.chat-log {
flex: 1; overflow-y: auto; padding: 18px;
display: flex; flex-direction: column; gap: 10px;
}
.msg {
max-width: 85%;
padding: 10px 14px;
border-radius: 14px;
font-size: 14px;
line-height: 1.45;
word-wrap: break-word;
}
.msg.user { align-self: flex-end; background: var(--red); color: #fff; border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.08); border-bottom-left-radius: 4px; }
.msg.sys { align-self: center; font-size: 12px; color: rgba(255,255,255,.5); }
.composer {
display: flex; gap: 8px;
padding: 12px;
border-top: 1px solid rgba(255,255,255,.08);
}
.composer input {
flex: 1; padding: 12px 14px;
border-radius: 10px; border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
color: var(--paper);
font-size: 14px;
outline: none;
}
.composer input:focus { border-color: var(--red-glow); }
.btn {
padding: 12px 16px; border-radius: 10px;
border: 1px solid rgba(255,255,255,.12);
background: var(--red);
color: #fff;
font-weight: 600;
cursor: pointer;
font-size: 13px;
}
.btn:hover { background: var(--red-glow); }
.btn.ghost { background: transparent; color: rgba(255,255,255,.85); }
.btn.ghost:hover { background: rgba(255,255,255,.07); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* phone tab */
.phone-pane {
flex: 1; padding: 18px;
display: flex; flex-direction: column; gap: 14px;
overflow-y: auto;
}
.pair-card {
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.1);
border-radius: 14px;
padding: 16px;
text-align: center;
}
.pair-card img { width: 220px; height: 220px; border-radius: 10px; background: #fff; padding: 6px; }
.pair-card .url { margin-top: 10px; font-size: 12px; color: rgba(255,255,255,.55); word-break: break-all; }
.video-card {
background: #000;
border-radius: 14px;
overflow: hidden;
aspect-ratio: 16/10;
position: relative;
border: 1px solid rgba(255,255,255,.1);
}
.video-card video { width: 100%; height: 100%; object-fit: cover; background: #000; }
.video-card .label {
position: absolute; top: 10px; left: 12px;
padding: 4px 10px;
font-size: 11px; font-weight: 700;
letter-spacing: .08em;
background: rgba(0,0,0,.55);
border-radius: 999px;
color: #fff;
}
.video-card .empty {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,.45); font-size: 13px;
text-align: center; padding: 20px;
}
.analyze-row { display: flex; gap: 8px; }
.analyze-row input {
flex: 1;
padding: 10px 12px; border-radius: 10px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
color: var(--paper); font-size: 13px;
}
.analyze-result {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px; line-height: 1.45;
min-height: 32px;
}
.typing { display: inline-block; opacity: .65; }
.typing::after {
content: '…';
animation: dots 1.2s infinite steps(4);
}
@keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } 100% { content: ''; } }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<div class="app">
<section class="stage">
<div class="brand">BIG RED</div>
<div class="status-dot">
<span class="pill" id="pillVoice"><span class="dot" id="dotVoice"></span>voice</span>
<span class="pill" id="pillPhone"><span class="dot" id="dotPhone"></span>phone</span>
</div>
<div class="avatar-wrap" style="position:relative">
<!-- Photo avatar (shown when /api/avatar exists, hidden otherwise) -->
<img id="photoAvatar" alt="Steve" style="display:none;width:100%;max-width:500px;aspect-ratio:1/1.05;object-fit:cover;object-position:center 30%;border-radius:12px;box-shadow:0 0 60px rgba(232,65,71,.35);"/>
<!-- Animated mouth overlay (positioned over the photo's mouth area, scales with --speak) -->
<div id="photoMouthOverlay" style="display:none;position:absolute;left:50%;top:72%;transform:translate(-50%,-50%);pointer-events:none">
<svg width="120" height="50" viewBox="0 0 120 50">
<ellipse cx="60" cy="25" rx="42" ry="6" fill="rgba(58,10,12,.0)" stroke="rgba(255,255,255,0)" stroke-width="0"/>
<ellipse class="mouth-inner" cx="60" cy="25" rx="38" ry="9" fill="#3a0a0c" opacity=".75"
style="filter:drop-shadow(0 2px 6px rgba(0,0,0,.5))"/>
</svg>
</div>
<!-- Default SVG avatar (hidden when photo avatar is set) -->
<svg class="avatar" id="svgAvatar" viewBox="0 0 500 525" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="skinGrad" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#fbe0c8"/>
<stop offset="70%" stop-color="#f3c9b1"/>
<stop offset="100%" stop-color="#c89377"/>
</radialGradient>
<radialGradient id="hairGrad" cx="50%" cy="20%" r="80%">
<stop offset="0%" stop-color="#e84147"/>
<stop offset="60%" stop-color="#b32221"/>
<stop offset="100%" stop-color="#6b1010"/>
</radialGradient>
<radialGradient id="bgGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#5a0d10" stop-opacity=".55"/>
<stop offset="100%" stop-color="#1a0506" stop-opacity="0"/>
</radialGradient>
<linearGradient id="cheekGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#e88a7a" stop-opacity=".0"/>
<stop offset="100%" stop-color="#e88a7a" stop-opacity=".55"/>
</linearGradient>
</defs>
<!-- soft glow halo -->
<ellipse cx="250" cy="250" rx="240" ry="240" fill="url(#bgGrad)"/>
<!-- neck/collar -->
<path d="M170 470 Q250 430 330 470 L340 525 L160 525 Z" fill="#202020"/>
<path d="M170 470 Q250 430 330 470 L335 495 Q250 470 165 495 Z" fill="#d8323a"/>
<!-- hair back -->
<path d="M110 240 Q90 110 240 80 Q420 80 400 250 Q400 300 380 320 L370 240 Q330 180 250 175 Q170 180 130 240 L120 320 Q105 300 110 240 Z" fill="url(#hairGrad)"/>
<!-- face -->
<ellipse cx="250" cy="290" rx="155" ry="180" fill="url(#skinGrad)"/>
<!-- jaw shadow -->
<path d="M120 320 Q250 480 380 320 Q380 410 250 460 Q120 410 120 320 Z" fill="#e0a988" opacity=".35"/>
<!-- hair front sweep (Steve red hair) -->
<path d="M115 240 Q130 145 245 130 Q345 135 388 220 Q360 200 320 195 Q280 165 235 175 Q170 188 130 250 Q118 245 115 240 Z" fill="url(#hairGrad)"/>
<!-- ears -->
<ellipse cx="98" cy="295" rx="18" ry="32" fill="#e8b59b"/>
<ellipse cx="402" cy="295" rx="18" ry="32" fill="#e8b59b"/>
<!-- brows -->
<path d="M165 250 Q200 235 235 250" stroke="#7a1612" stroke-width="9" stroke-linecap="round" fill="none"/>
<path d="M265 250 Q300 235 335 250" stroke="#7a1612" stroke-width="9" stroke-linecap="round" fill="none"/>
<!-- eyes -->
<g>
<ellipse cx="200" cy="285" rx="22" ry="14" fill="#fff"/>
<ellipse cx="200" cy="285" rx="9" ry="9" fill="#3a2a1c"/>
<ellipse cx="203" cy="282" rx="3" ry="3" fill="#fff"/>
<ellipse class="eye-lid" cx="200" cy="285" rx="22" ry="14" fill="url(#skinGrad)"/>
</g>
<g>
<ellipse cx="300" cy="285" rx="22" ry="14" fill="#fff"/>
<ellipse cx="300" cy="285" rx="9" ry="9" fill="#3a2a1c"/>
<ellipse cx="303" cy="282" rx="3" ry="3" fill="#fff"/>
<ellipse class="eye-lid" cx="300" cy="285" rx="22" ry="14" fill="url(#skinGrad)"/>
</g>
<!-- cheeks -->
<ellipse cx="170" cy="345" rx="28" ry="14" fill="url(#cheekGrad)" opacity=".6"/>
<ellipse cx="330" cy="345" rx="28" ry="14" fill="url(#cheekGrad)" opacity=".6"/>
<!-- nose -->
<path d="M250 290 Q243 330 235 345 Q250 358 265 345 Q257 330 250 290 Z" fill="#e0a988" opacity=".55"/>
<!-- beard / scruff (Steve has beard) -->
<path d="M150 360 Q250 470 350 360 Q360 420 250 455 Q140 420 150 360 Z" fill="#7a1612" opacity=".55"/>
<path d="M165 365 Q250 460 335 365 Q345 410 250 440 Q155 410 165 365 Z" fill="#a02320" opacity=".4"/>
<!-- mouth (animated) -->
<g>
<path d="M205 380 Q250 395 295 380" stroke="#5b0d10" stroke-width="3" fill="none" stroke-linecap="round"/>
<ellipse class="mouth-inner" cx="250" cy="385" rx="38" ry="9" fill="#3a0a0c"/>
<path d="M218 385 Q250 380 282 385" stroke="#fff" stroke-width="2" fill="none" opacity=".75"/>
</g>
<!-- glasses (Steve's signature look) -->
<g stroke="#1a0d0d" stroke-width="3" fill="none" opacity=".75">
<rect x="160" y="260" width="80" height="48" rx="12"/>
<rect x="260" y="260" width="80" height="48" rx="12"/>
<line x1="240" y1="278" x2="260" y2="278"/>
<line x1="160" y1="270" x2="135" y2="265"/>
<line x1="340" y1="270" x2="365" y2="265"/>
</g>
</svg>
</div>
<div class="caption" id="caption"></div>
</section>
<aside class="panel">
<div class="tabs">
<div class="tab active" data-tab="chat">Chat</div>
<div class="tab" data-tab="phone">Phone Camera</div>
</div>
<div class="tab-body active" id="tab-chat">
<div class="chat-log" id="chatLog">
<div class="msg sys">Big Red is online. Talk or type — I'll reply in Steve's voice.</div>
</div>
<form class="composer" id="chatForm">
<input id="chatInput" type="text" placeholder="Say something to Big Red…" autocomplete="off"/>
<button class="btn ghost" type="button" id="micBtn" title="Hold to speak">🎙</button>
<button class="btn" type="submit">Send</button>
</form>
</div>
<div class="tab-body" id="tab-phone">
<div class="phone-pane">
<div class="pair-card" id="pairCard">
<div style="font-weight:700; margin-bottom:10px;">Pair your phone</div>
<img id="qrImg" alt="QR pairing code"/>
<div class="url" id="pairUrl">…</div>
<div style="margin-top:10px; font-size:12px; color:rgba(255,255,255,.55)">Open this on your phone — same Wi-Fi.</div>
</div>
<div class="video-card" id="videoCard">
<span class="label">PHONE CAM</span>
<video id="phoneVideo" autoplay playsinline muted></video>
<div class="empty" id="videoEmpty">Waiting for phone…</div>
</div>
<div class="analyze-row">
<input id="analyzePrompt" type="text" placeholder="What should I look at? (default: describe scene)"/>
<button class="btn" id="analyzeBtn" disabled>Analyze frame</button>
</div>
<div class="analyze-result" id="analyzeResult">No analysis yet.</div>
<div class="analyze-row" style="margin-top:8px">
<button class="btn" id="captureAvatarBtn" disabled style="background:#a02320;flex:1">📸 Make Me the Avatar</button>
<button class="btn" id="resetAvatarBtn" style="background:#444;flex:0 0 auto">↺ Reset</button>
</div>
<div class="analyze-result" id="avatarStatus" style="font-size:11px">Pair phone, then click "Make Me the Avatar" to capture your face.</div>
</div>
</div>
</aside>
</div>
<canvas id="captureCanvas" style="display:none"></canvas>
<audio id="ttsAudio" style="display:none"></audio>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const chatLog = $('chatLog');
const chatForm = $('chatForm');
const chatInput = $('chatInput');
const ttsAudio = $('ttsAudio');
const captionEl = $('caption');
const phoneVideo = $('phoneVideo');
const videoEmpty = $('videoEmpty');
const analyzeBtn = $('analyzeBtn');
const analyzeResult = $('analyzeResult');
const captureCanvas = $('captureCanvas');
const dotVoice = $('dotVoice');
const dotPhone = $('dotPhone');
// -------- tabs --------
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-body').forEach(x => x.classList.remove('active'));
t.classList.add('active');
$('tab-' + t.dataset.tab).classList.add('active');
});
});
// -------- avatar lip-sync --------
let audioCtx = null;
let analyser = null;
let speakRaf = null;
function setSpeak(level) {
document.documentElement.style.setProperty('--speak', String(Math.max(0, Math.min(1, level))));
}
function attachAnalyser(audioEl) {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const src = audioCtx.createMediaElementSource(audioEl);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
src.connect(analyser);
analyser.connect(audioCtx.destination);
const data = new Uint8Array(analyser.frequencyBinCount);
let sineFallbackUsed = 0;
function tick() {
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / data.length);
// Fallback: if analyser RMS is silent (some browsers/CORS edge cases) but
// audio is playing, drive the mouth with a sine wave so it still animates.
if (rms < 0.005 && !audioEl.paused && !audioEl.ended && audioEl.currentTime > 0.05) {
sineFallbackUsed++;
const t = audioEl.currentTime;
// Layered sines + smooth random envelope = natural-looking talk shape
const env = 0.55 + 0.4 * Math.abs(Math.sin(t * 7.3) * Math.cos(t * 3.1) + Math.sin(t * 11.7) * 0.4);
setSpeak(env);
} else {
setSpeak(rms * 4);
}
speakRaf = requestAnimationFrame(tick);
}
if (speakRaf) cancelAnimationFrame(speakRaf);
speakRaf = requestAnimationFrame(tick);
}
let analyserAttached = false;
ttsAudio.addEventListener('play', () => {
if (!analyserAttached) { attachAnalyser(ttsAudio); analyserAttached = true; }
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
});
ttsAudio.addEventListener('ended', () => {
setSpeak(0);
if (speakRaf) { cancelAnimationFrame(speakRaf); speakRaf = null; }
captionEl.classList.remove('show');
});
ttsAudio.addEventListener('pause', () => setSpeak(0));
function showCaption(text) {
captionEl.textContent = text;
captionEl.classList.add('show');
}
async function speak(text) {
if (!text) return;
showCaption(text);
try {
const r = await fetch('/api/tts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text })
});
if (!r.ok) {
dotVoice.classList.remove('ok');
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
ttsAudio.src = url;
try { await ttsAudio.play(); dotVoice.classList.add('ok'); } catch { dotVoice.classList.remove('ok'); }
} catch {
dotVoice.classList.remove('ok');
}
}
// -------- chat --------
const history = [];
function addMsg(role, content) {
const div = document.createElement('div');
div.className = 'msg ' + (role === 'user' ? 'user' : role === 'sys' ? 'sys' : 'bot');
div.textContent = content;
chatLog.appendChild(div);
chatLog.scrollTop = chatLog.scrollHeight;
return div;
}
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
chatInput.value = '';
addMsg('user', text);
history.push({ role: 'user', content: text });
const thinking = addMsg('bot', '');
thinking.innerHTML = '<span class="typing">Big Red is thinking</span>';
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: text, history })
});
const j = await r.json();
const reply = j.reply || j.error || '(no reply)';
thinking.textContent = reply;
history.push({ role: 'assistant', content: reply });
speak(reply);
} catch (err) {
thinking.textContent = 'Error: ' + err.message;
}
});
// -------- voice input (Web Speech API) --------
const micBtn = $('micBtn');
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
const rec = new SR();
rec.continuous = false;
rec.interimResults = true;
rec.lang = 'en-US';
let listening = false;
micBtn.addEventListener('click', () => {
if (listening) { rec.stop(); return; }
try { rec.start(); listening = true; micBtn.style.background = 'var(--red-glow)'; } catch {}
});
rec.addEventListener('result', (e) => {
let txt = '';
for (const r of e.results) txt += r[0].transcript;
chatInput.value = txt;
});
rec.addEventListener('end', () => {
listening = false;
micBtn.style.background = '';
if (chatInput.value.trim()) chatForm.dispatchEvent(new Event('submit'));
});
} else {
micBtn.style.display = 'none';
}
// -------- pairing + WebRTC --------
let pc = null;
let ws = null;
let pairCode = null;
let phoneStream = null;
async function bootPairing() {
const r = await fetch('/api/pair/new');
const j = await r.json();
pairCode = j.code;
$('qrImg').src = j.qrDataUrl;
$('pairUrl').textContent = j.phoneUrl;
connectWs();
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'hello', role: 'desktop', code: pairCode }));
});
ws.addEventListener('message', async (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'peer-joined' && msg.role === 'phone') {
await ensurePc();
}
if (msg.type === 'offer') {
await ensurePc();
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
ws.send(JSON.stringify({ type: 'answer', sdp: pc.localDescription }));
}
if (msg.type === 'ice' && msg.candidate) {
try { await pc.addIceCandidate(msg.candidate); } catch {}
}
if (msg.type === 'peer-gone') {
teardownPc();
}
});
ws.addEventListener('close', () => {
setTimeout(() => bootPairing(), 1500);
});
}
async function ensurePc() {
if (pc) return;
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.addEventListener('icecandidate', (e) => {
if (e.candidate) ws.send(JSON.stringify({ type: 'ice', candidate: e.candidate }));
});
pc.addEventListener('track', (e) => {
phoneStream = e.streams[0];
phoneVideo.srcObject = phoneStream;
videoEmpty.style.display = 'none';
analyzeBtn.disabled = false;
if (typeof captureAvatarBtn !== 'undefined' && captureAvatarBtn) captureAvatarBtn.disabled = false;
dotPhone.classList.add('ok');
});
pc.addEventListener('connectionstatechange', () => {
if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
teardownPc();
}
});
}
function teardownPc() {
try { pc && pc.close(); } catch {}
pc = null;
phoneStream = null;
phoneVideo.srcObject = null;
videoEmpty.style.display = 'flex';
analyzeBtn.disabled = true;
if (typeof captureAvatarBtn !== 'undefined' && captureAvatarBtn) captureAvatarBtn.disabled = true;
dotPhone.classList.remove('ok');
}
// -------- avatar capture (use phone-cam frame as Steve's face) --------
const photoAvatar = $('photoAvatar');
const photoMouthOverlay = $('photoMouthOverlay');
const svgAvatar = $('svgAvatar');
const captureAvatarBtn = $('captureAvatarBtn');
const resetAvatarBtn = $('resetAvatarBtn');
const avatarStatus = $('avatarStatus');
function showPhotoAvatar(url) {
photoAvatar.src = url;
photoAvatar.style.display = '';
photoMouthOverlay.style.display = '';
svgAvatar.style.display = 'none';
}
function showSvgAvatar() {
photoAvatar.style.display = 'none';
photoMouthOverlay.style.display = 'none';
svgAvatar.style.display = '';
}
// Load any existing avatar on boot
fetch('/api/avatar').then(r => r.json()).then(j => {
if (j.exists) { showPhotoAvatar(j.url); avatarStatus.textContent = 'Photo avatar active. Click Reset to go back to red-haired Steve.'; }
}).catch(() => {});
// Enable the capture button only when phone is paired and streaming
function refreshAvatarBtn() {
captureAvatarBtn.disabled = !phoneStream;
}
captureAvatarBtn.addEventListener('click', async () => {
if (!phoneStream) { avatarStatus.textContent = 'Pair your phone first.'; return; }
const v = phoneVideo;
captureCanvas.width = v.videoWidth || 1280;
captureCanvas.height = v.videoHeight || 720;
const ctx = captureCanvas.getContext('2d');
ctx.drawImage(v, 0, 0, captureCanvas.width, captureCanvas.height);
const dataUrl = captureCanvas.toDataURL('image/jpeg', 0.92);
captureAvatarBtn.disabled = true;
avatarStatus.textContent = 'Saving your face as the avatar…';
try {
const r = await fetch('/api/avatar/capture', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ dataUrl })
});
const j = await r.json();
if (j.ok) {
showPhotoAvatar(j.url);
avatarStatus.textContent = 'Avatar set. Mouth will animate when speaking.';
} else {
avatarStatus.textContent = 'Failed: ' + (j.error || 'unknown');
}
} catch (e) {
avatarStatus.textContent = 'Error: ' + e.message;
} finally {
captureAvatarBtn.disabled = !phoneStream;
}
});
resetAvatarBtn.addEventListener('click', async () => {
await fetch('/api/avatar', { method: 'DELETE' });
showSvgAvatar();
avatarStatus.textContent = 'Reset — using default red-haired SVG.';
});
// -------- frame analysis --------
analyzeBtn.addEventListener('click', async () => {
if (!phoneStream) return;
const v = phoneVideo;
captureCanvas.width = v.videoWidth || 1280;
captureCanvas.height = v.videoHeight || 720;
const ctx = captureCanvas.getContext('2d');
ctx.drawImage(v, 0, 0, captureCanvas.width, captureCanvas.height);
const dataUrl = captureCanvas.toDataURL('image/jpeg', 0.85);
analyzeBtn.disabled = true;
analyzeResult.innerHTML = '<span class="typing">Looking</span>';
try {
const promptText = $('analyzePrompt').value.trim();
const r = await fetch('/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ dataUrl, prompt: promptText || undefined })
});
const j = await r.json();
const reply = j.reply || j.error || '(no reply)';
analyzeResult.textContent = reply;
addMsg('sys', '📷 ' + (promptText || 'analyze frame'));
addMsg('bot', reply);
history.push({ role: 'assistant', content: reply });
speak(reply);
} catch (e) {
analyzeResult.textContent = 'Error: ' + e.message;
} finally {
analyzeBtn.disabled = !phoneStream;
}
});
// boot
bootPairing();
})();
</script>
</body>
</html>