Files
smart-home-tablet/components/VoiceOverlay.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

309 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
type VoiceState = 'idle' | 'wake' | 'listening' | 'command' | 'response' | 'error'
type Agent = 'cosmo' | 'lusya'
interface VoiceEvent {
event: VoiceState
agent?: Agent
text?: string
timestamp: string
}
// Per-agent accent pair (inner core / outer halo). Минималистично, без имён.
const AGENT_COLORS: Record<Agent, { core: string; halo: string }> = {
cosmo: { core: '#a5b4fc', halo: '#7c3aed' },
lusya: { core: '#fbcfe8', halo: '#ec4899' },
}
const STATUS_LABEL: Record<Exclude<VoiceState, 'idle'>, string> = {
wake: 'слушаю',
listening: 'жду',
command: '',
response: '',
error: '',
}
export default function VoiceOverlay() {
const [state, setState] = useState<VoiceState>('idle')
const [agent, setAgent] = useState<Agent>('cosmo')
const [text, setText] = useState('')
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const audioUrlRef = useRef<string | null>(null)
const clearDismiss = () => {
if (dismissTimer.current) {
clearTimeout(dismissTimer.current)
dismissTimer.current = null
}
}
const scheduleDismiss = (ms: number) => {
clearDismiss()
dismissTimer.current = setTimeout(() => setState('idle'), ms)
}
const stopAudio = () => {
if (audioRef.current) {
try {
audioRef.current.pause()
audioRef.current.src = ''
} catch {}
audioRef.current = null
}
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current)
audioUrlRef.current = null
}
}
const playTTS = async (textToSpeak: string, agentId: Agent, onEnded?: () => void) => {
stopAudio()
if (!textToSpeak) {
onEnded?.()
return
}
try {
const r = await fetch('/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textToSpeak, agent: agentId }),
})
if (!r.ok) {
onEnded?.()
return
}
const blob = await r.blob()
const url = URL.createObjectURL(blob)
audioUrlRef.current = url
const audio = new Audio(url)
const finish = () => {
if (audioUrlRef.current === url) {
URL.revokeObjectURL(url)
audioUrlRef.current = null
}
onEnded?.()
}
audio.onended = finish
audio.onerror = finish
audioRef.current = audio
try {
await audio.play()
} catch {
finish()
}
} catch {
onEnded?.()
}
}
useEffect(() => {
let es: EventSource | null = null
let retry: ReturnType<typeof setTimeout> | null = null
let closedByUs = false
const connect = () => {
es = new EventSource('/api/voice/stream')
es.onmessage = (e) => {
try {
const evt: VoiceEvent = JSON.parse(e.data)
const currentAgent: Agent = evt.agent ?? agent
if (evt.agent) setAgent(evt.agent)
// Safety: если Python упадёт и не пришлёт idle, через 60с сами закроемся.
const armSafety = () => scheduleDismiss(60_000)
if (evt.event === 'wake') {
// Свежая активация: barge-in аудио, чистим текст.
stopAudio()
setState('wake')
setText('')
armSafety()
} else if (evt.event === 'listening') {
// Follow-up: сохраняем последний текст, орб мягко пульсирует.
setState('listening')
armSafety()
} else if (evt.event === 'command') {
setState('command')
setText(evt.text || '')
armSafety()
} else if (evt.event === 'response') {
setState('response')
setText(evt.text || '')
// Dismiss ТОЛЬКО когда аудио доиграло (не по таймеру!).
// После окончания даём Python шанс прислать listening/wake/command — тогда
// scheduleDismiss перезатрётся. Иначе через 4с автозакрытие.
clearDismiss()
if (evt.text) {
playTTS(evt.text, currentAgent, () => scheduleDismiss(4000))
} else {
scheduleDismiss(4000)
}
} else if (evt.event === 'error') {
setState('error')
setText(evt.text || 'Ошибка')
clearDismiss()
if (evt.text) {
playTTS(evt.text, currentAgent, () => scheduleDismiss(3000))
} else {
scheduleDismiss(3000)
}
} else if (evt.event === 'idle') {
clearDismiss()
stopAudio()
setState('idle')
}
} catch {}
}
es.onerror = () => {
if (closedByUs) return
es?.close()
retry = setTimeout(connect, 3000)
}
}
connect()
return () => {
closedByUs = true
clearDismiss()
stopAudio()
if (retry) clearTimeout(retry)
es?.close()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isActive = state !== 'idle'
const colors = AGENT_COLORS[agent]
const status = state !== 'idle' ? STATUS_LABEL[state] : ''
return (
<AnimatePresence>
{isActive && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35 }}
data-swipe-ignore
style={{
position: 'fixed', inset: 0, zIndex: 300,
background: 'rgba(5, 5, 15, 0.82)',
backdropFilter: 'blur(28px)',
WebkitBackdropFilter: 'blur(28px)' as any,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 30, padding: 40,
pointerEvents: 'none',
}}
>
<SiriOrb core={colors.core} halo={colors.halo} state={state} />
{/* Subtle status (только "слушаю" — для остальных текст сам говорит за себя) */}
{status && (
<motion.div
key={state}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 0.55, y: 0 }}
transition={{ duration: 0.3 }}
style={{
fontSize: 13, color: 'rgba(255,255,255,0.6)',
fontWeight: 600, letterSpacing: '0.15em',
textTransform: 'uppercase',
}}
>
{status}
</motion.div>
)}
{/* Текст — распознанный / ответ */}
{text && (
<motion.div
key={text.slice(0, 40)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
style={{
maxWidth: 760, textAlign: 'center',
fontSize: state === 'command' ? 20 : 24,
fontWeight: 500,
color: state === 'error' ? '#fca5a5' :
state === 'command' ? 'rgba(255,255,255,0.55)' :
'rgba(255,255,255,0.95)',
letterSpacing: '-0.3px', lineHeight: 1.4,
}}
>
{text}
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
)
}
function SiriOrb({ core, halo, state }: { core: string; halo: string; state: VoiceState }) {
const isIntense = state === 'wake'
const isListening = state === 'listening'
const isResponding = state === 'response'
return (
<div style={{ position: 'relative', width: 240, height: 240 }}>
{/* Outer halo — медленное дыхание */}
<motion.div
animate={{
scale: isIntense ? [1, 1.2, 1] : isListening ? [1, 1.14, 1] : [1, 1.08, 1],
opacity: isIntense ? [0.55, 0.2, 0.55] : isListening ? [0.45, 0.18, 0.45] : [0.35, 0.15, 0.35],
}}
transition={{
duration: isIntense ? 1.6 : isListening ? 2.2 : 3.2,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: `radial-gradient(circle, ${halo}55 0%, transparent 72%)`,
filter: 'blur(32px)',
}}
/>
{/* Inner ring — быстрее, с подкрученным blur */}
<motion.div
animate={{
scale: isIntense ? [1, 1.1, 1] : isListening ? [1, 1.06, 1] : isResponding ? [1, 1.04, 1] : 1,
rotate: isIntense ? [0, 10, -8, 0] : 0,
}}
transition={{
duration: 1.3,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
position: 'absolute', inset: 40, borderRadius: '50%',
background: `radial-gradient(circle at 40% 30%, ${core} 0%, ${halo} 60%, transparent 85%)`,
filter: 'blur(16px)',
boxShadow: `0 0 80px ${halo}66, 0 0 40px ${core}55`,
}}
/>
{/* Bright core — тонкий highlight */}
<motion.div
animate={{
scale: isIntense ? [1, 0.85, 1] : 1,
opacity: isIntense ? [0.9, 0.7, 0.9] : 0.85,
}}
transition={{ duration: 0.9, repeat: Infinity, ease: 'easeInOut' }}
style={{
position: 'absolute', inset: 90, borderRadius: '50%',
background: `radial-gradient(circle at 45% 35%, rgba(255,255,255,0.9) 0%, ${core} 50%, transparent 100%)`,
filter: 'blur(8px)',
}}
/>
</div>
)
}