Files
satellite/web-client/assist.html
T
sirmrak 8a4686c800 feat: интеграция Lottie анимации AI robot в assist.html
- Заменена CSS сфера на dotlottie-player
- Анимация реагирует на состояния (idle/listening/speaking)
- Скорость анимации зависит от RMS уровня микрофона
- Добавлена обработка ошибок загрузки
- Сохранены все WebSocket команды и события
2026-05-25 18:04:37 +03:00

653 lines
17 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>NOVA Assistant</title>
<!-- Lottie Player -->
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.mjs" type="module"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c);
font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
/* Фоновые частицы */
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
width: 2px;
height: 2px;
background: rgba(0, 255, 255, 0.3);
border-radius: 50%;
animation: float 20s infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-100vh) translateX(50px); opacity: 0; }
}
/* Главный контейнер */
.assistant-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 40px;
max-width: 600px;
width: 100%;
}
/* Статус подключения */
.connection-badge {
position: absolute;
top: 30px;
right: 30px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
padding: 12px 20px;
border-radius: 50px;
border: 1px solid rgba(0, 255, 255, 0.3);
font-size: 14px;
color: #b0e0ff;
font-weight: 500;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ff4d4d;
box-shadow: 0 0 10px #ff4d4d;
transition: all 0.3s;
}
.status-dot.connected {
background: #0ff;
box-shadow: 0 0 20px #0ff;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
}
/* Главная сфера (ORB) - Lottie анимация */
.orb-container {
position: relative;
width: 400px;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.lottie-player {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 40px rgba(0, 255, 255, 0.3));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Эффекты для разных состояний */
.lottie-player.listening {
filter: drop-shadow(0 0 80px rgba(0, 255, 255, 0.8))
drop-shadow(0 0 120px rgba(0, 255, 255, 0.5));
animation: lottie-pulse 1.5s ease-in-out infinite;
}
@keyframes lottie-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.lottie-player.speaking {
filter: drop-shadow(0 0 80px rgba(255, 0, 255, 0.8))
drop-shadow(0 0 120px rgba(255, 0, 255, 0.5));
animation: lottie-speak 0.8s ease-in-out infinite;
}
@keyframes lottie-speak {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.03); }
75% { transform: scale(0.98); }
}
/* Статус текст */
.status-text {
font-size: 24px;
font-weight: 600;
color: #00ffff;
text-transform: uppercase;
letter-spacing: 4px;
text-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
animation: text-glow 2s ease-in-out infinite;
}
@keyframes text-glow {
0%, 100% { text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); }
50% { text-shadow: 0 0 40px rgba(0, 255, 255, 0.8); }
}
/* Контейнер громкости */
.volume-container {
width: 100%;
max-width: 400px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 255, 255, 0.3);
border-radius: 30px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.volume-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #b0e0ff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 2px;
}
.volume-value {
font-size: 24px;
font-weight: 700;
color: #00ffff;
font-family: 'Courier New', monospace;
}
/* Слайдер громкости */
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: rgba(0, 255, 255, 0.2);
border-radius: 10px;
outline: none;
transition: all 0.3s;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
transition: all 0.2s;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 255, 255, 1);
}
.volume-slider::-moz-range-thumb {
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
transition: all 0.2s;
}
.volume-slider::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 255, 255, 1);
}
/* Кнопки быстрой регулировки */
.volume-buttons {
display: flex;
gap: 15px;
justify-content: center;
}
.vol-btn {
background: rgba(0, 255, 255, 0.1);
border: 1px solid rgba(0, 255, 255, 0.5);
color: #00ffff;
padding: 12px 24px;
border-radius: 50px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.vol-btn:hover {
background: rgba(0, 255, 255, 0.3);
border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
transform: translateY(-2px);
}
.vol-btn:active {
transform: translateY(0);
}
/* Последняя фраза */
.transcript-box {
width: 100%;
max-width: 500px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 255, 255, 0.2);
border-radius: 20px;
padding: 20px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 10px;
}
.transcript-label {
font-size: 12px;
color: #7f9fcf;
text-transform: uppercase;
letter-spacing: 2px;
}
.transcript-text {
font-size: 18px;
color: #eef5ff;
font-style: italic;
line-height: 1.6;
}
/* Адаптивность */
@media (max-width: 768px) {
.assistant-container {
padding: 20px;
gap: 30px;
}
.orb-container {
width: 250px;
height: 250px;
}
.orb {
width: 200px;
height: 200px;
}
.status-text {
font-size: 20px;
}
.connection-badge {
top: 20px;
right: 20px;
font-size: 12px;
padding: 10px 16px;
}
}
</style>
</head>
<body>
<!-- Фоновые частицы -->
<div class="particles" id="particles"></div>
<!-- Статус подключения -->
<div class="connection-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusLabel">Отключено</span>
</div>
<!-- Главный контейнер -->
<div class="assistant-container">
<!-- Lottie анимация робота -->
<div class="orb-container">
<dotlottie-player
id="lottiePlayer"
class="lottie-player"
src="AI robot.lottie"
background="transparent"
speed="1"
loop
autoplay
intermission="0">
</dotlottie-player>
</div>
<!-- Статус -->
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
<!-- Управление громкостью -->
<div class="volume-container">
<div class="volume-header">
<span>🔊 Громкость</span>
<span class="volume-value" id="volumeValue">80%</span>
</div>
<input type="range" class="volume-slider" id="volumeSlider"
min="0" max="100" value="80" step="1">
<div class="volume-buttons">
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
</div>
</div>
<!-- Последняя фраза -->
<div class="transcript-box">
<div class="transcript-label">💬 Последняя фраза</div>
<div class="transcript-text" id="transcriptText">—</div>
</div>
</div>
<script>
// ==================== КОНФИГУРАЦИЯ ====================
const WS_URL = 'ws://192.168.200.111:8675';
const NUM_WAVE_BARS = 12;
const NUM_PARTICLES = 30;
// ==================== СОСТОЯНИЕ ====================
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let currentRms = 0.0;
let currentState = 'idle'; // idle, listening, speaking
// ==================== DOM ЭЛЕМЕНТЫ ====================
const statusDot = document.getElementById('statusDot');
const statusLabel = document.getElementById('statusLabel');
const lottiePlayer = document.getElementById('lottiePlayer');
const statusText = document.getElementById('statusText');
const volumeSlider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const volUpBtn = document.getElementById('volUpBtn');
const volDownBtn = document.getElementById('volDownBtn');
const transcriptText = document.getElementById('transcriptText');
// Создать фоновые частицы
function initParticles() {
const particles = document.getElementById('particles');
for (let i = 0; i < NUM_PARTICLES; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 20 + 's';
particle.style.animationDuration = (15 + Math.random() * 10) + 's';
particles.appendChild(particle);
}
}
// ==================== УПРАВЛЕНИЕ LOTTIE ====================
function updateLottieSpeed() {
if (!lottiePlayer) return;
// Изменяем скорость анимации в зависимости от состояния и RMS
let speed = 1.0;
if (currentState === 'listening') {
// При прослушивании скорость зависит от RMS
const rmsScaled = Math.min(1, currentRms * 5);
speed = 0.8 + rmsScaled * 1.2; // 0.8 - 2.0
} else if (currentState === 'speaking') {
speed = 1.5; // Быстрее при говорении
} else {
speed = 0.6; // Медленнее в idle
}
lottiePlayer.setSpeed(speed);
}
// Обновление скорости анимации в реальном времени
function animateLottie() {
updateLottieSpeed();
requestAnimationFrame(animateLottie);
}
// ==================== WEBSOCKET ====================
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) {
return;
}
ws = new WebSocket(WS_URL);
ws.onopen = () => {
isConnected = true;
statusDot.classList.add('connected');
statusLabel.textContent = 'Подключено';
console.log('✅ WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleEvent(data);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
isConnected = false;
statusDot.classList.remove('connected');
statusLabel.textContent = 'Отключено';
currentState = 'idle';
updateOrbState();
console.log('❌ WebSocket disconnected');
// Переподключение через 3 секунды
setTimeout(connectWebSocket, 3000);
};
}
// ==================== ОБРАБОТКА СОБЫТИЙ ====================
function handleEvent(data) {
const eventType = data.type || data.event;
switch (eventType) {
case 'audio-level':
if (data.data && data.data.level !== undefined) {
currentRms = data.data.level;
}
break;
case 'voice-started':
currentState = 'listening';
statusText.textContent = 'СЛУШАЮ';
updateOrbState();
break;
case 'voice-stopped':
currentState = 'idle';
statusText.textContent = 'ОБРАБОТКА';
updateOrbState();
break;
case 'transcript':
if (data.data && data.data.text) {
transcriptText.textContent = `"${data.data.text}"`;
}
break;
case 'synthesize':
if (data.data && data.data.text) {
transcriptText.textContent = `"${data.data.text}"`;
}
break;
case 'tts-start':
case 'audio-start':
currentState = 'speaking';
statusText.textContent = 'ГОВОРЮ';
updateOrbState();
break;
case 'tts-stop':
case 'audio-stop':
case 'played':
// Проверяем что мы действительно были в режиме speaking
if (currentState === 'speaking') {
currentState = 'idle';
statusText.textContent = 'В ОЖИДАНИИ';
updateOrbState();
}
break;
case 'streaming-started':
currentState = 'listening';
statusText.textContent = 'АКТИВЕН';
updateOrbState();
break;
case 'streaming-stopped':
// Только если мы были в listening, переходим в idle
if (currentState === 'listening') {
currentState = 'idle';
statusText.textContent = 'В ОЖИДАНИИ';
updateOrbState();
}
break;
case 'stt-start':
currentState = 'listening';
statusText.textContent = 'СЛУШАЮ';
updateOrbState();
break;
case 'stt-stop':
currentState = 'idle';
statusText.textContent = 'ОБРАБОТКА';
updateOrbState();
break;
}
}
// ==================== ОБНОВЛЕНИЕ СОСТОЯНИЯ АНИМАЦИИ ====================
function updateOrbState() {
if (!lottiePlayer) return;
lottiePlayer.classList.remove('listening', 'speaking');
if (currentState === 'listening') {
lottiePlayer.classList.add('listening');
} else if (currentState === 'speaking') {
lottiePlayer.classList.add('speaking');
}
updateLottieSpeed();
}
// ==================== УПРАВЛЕНИЕ ГРОМКОСТЬЮ ====================
function sendVolumeCommand(level) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected');
return;
}
const msg = JSON.stringify({
command: 'set_volume',
data: { level: level }
});
ws.send(msg);
}
function updateVolume(newVolume) {
currentVolume = Math.max(0, Math.min(1, newVolume));
volumeSlider.value = Math.round(currentVolume * 100);
volumeValue.textContent = Math.round(currentVolume * 100) + '%';
sendVolumeCommand(currentVolume);
}
// ==================== ОБРАБОТЧИКИ СОБЫТИЙ ====================
volumeSlider.addEventListener('input', (e) => {
const value = parseInt(e.target.value) / 100;
updateVolume(value);
});
volUpBtn.addEventListener('click', () => {
updateVolume(currentVolume + 0.1);
});
volDownBtn.addEventListener('click', () => {
updateVolume(currentVolume - 0.1);
});
// ==================== ЗАПУСК ====================
window.addEventListener('load', () => {
console.log('🚀 NOVA Assistant initialized');
initParticles();
animateLottie();
connectWebSocket();
// Дождаться загрузки Lottie
if (lottiePlayer) {
lottiePlayer.addEventListener('ready', () => {
console.log('✅ Lottie animation loaded');
updateLottieSpeed();
});
lottiePlayer.addEventListener('error', (e) => {
console.error('❌ Lottie animation error:', e);
console.log('Trying to load first animation...');
// Попробовать загрузить первую анимацию из файла
lottiePlayer.load('AI robot.lottie');
});
lottiePlayer.addEventListener('load', () => {
console.log('✅ Lottie file loaded successfully');
});
}
});
</script>
</body>
</html>