Files
radar_frontend/web_viewer/templates/index.html
2025-11-20 15:35:36 +03:00

679 lines
22 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">
<title>Beacon Tracker - Live Stream (FastAPI)</title>
<!-- JSMpeg for H.264/MPEG-TS decoding -->
<script src="https://cdn.jsdelivr.net/npm/jsmpeg@0.1.0/jsmpeg.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.container {
max-width: 1400px;
width: 100%;
}
header {
background: rgba(255, 255, 255, 0.95);
padding: 20px 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
}
h1 {
color: #333;
font-size: 2rem;
font-weight: 700;
}
.status-bar {
display: flex;
gap: 20px;
margin-top: 15px;
flex-wrap: wrap;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #e74c3c;
animation: pulse 2s ease-in-out infinite;
}
.status-indicator.connected {
background: #2ecc71;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
color: #555;
font-size: 0.95rem;
}
.main-content {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.video-container {
position: relative;
background: #000;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
#videoFrame, #videoCanvas {
width: 100%;
height: auto;
display: block;
}
.hidden {
display: none !important;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
z-index: 10;
}
.loading-overlay.hidden {
display: none;
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 4px solid white;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.info-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
border-radius: 10px;
color: white;
text-align: center;
}
.info-label {
font-size: 0.85rem;
opacity: 0.9;
margin-bottom: 8px;
}
.info-value {
font-size: 1.5rem;
font-weight: 700;
}
.controls {
margin-top: 20px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.controls button, .stream-selector button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.controls button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stream-selector {
margin-top: 20px;
padding: 15px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
gap: 10px;
align-items: center;
}
.stream-selector button {
background: white;
color: #333;
border: 2px solid #ddd;
}
.stream-selector button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #667eea;
}
.controls button:hover, .stream-selector button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.error-message {
margin-top: 15px;
padding: 15px;
background: #e74c3c;
color: white;
border-radius: 8px;
display: none;
}
.error-message.visible {
display: block;
}
.perf-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
margin-left: 10px;
}
.perf-badge.sse {
background: #f39c12;
color: white;
}
.perf-badge.websocket {
background: #27ae60;
color: white;
}
.tech-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.75rem;
background: #3498db;
color: white;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎯 Beacon Tracker - Live Stream <span class="tech-badge">FastAPI</span></h1>
<div class="status-bar">
<div class="status-item">
<div class="status-indicator" id="connectionStatus"></div>
<span class="status-text" id="connectionText">Подключение...</span>
</div>
<div class="status-item">
<span class="status-text">FPS: <strong id="fpsCounter">0</strong></span>
</div>
<div class="status-item">
<span class="status-text">Кадр: <strong id="frameCounter">0</strong></span>
</div>
<div class="status-item">
<span class="status-text">Метод: <strong id="streamMethod">-</strong></span>
</div>
</div>
</header>
<div class="main-content">
<div class="stream-selector">
<span style="font-weight: 600;">Выбрать метод передачи:</span>
<button id="btnWebSocket" onclick="switchToWebSocket()" class="active">
WebSocket (H.264) <span class="perf-badge websocket">Быстрее</span>
</button>
<button id="btnSSE" onclick="switchToSSE()">
SSE (JPEG) <span class="perf-badge sse">Совместимость</span>
</button>
</div>
<div class="video-container">
<!-- Canvas for WebSocket/JSMpeg streaming -->
<canvas id="videoCanvas"></canvas>
<!-- Image for SSE/JPEG streaming -->
<img id="videoFrame" class="hidden" alt="Video stream">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Ожидание данных...</p>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<div class="info-label">Разрешение</div>
<div class="info-value" id="resolution">-</div>
</div>
<div class="info-card">
<div class="info-label">Размер/Битрейт</div>
<div class="info-value" id="frameSize">-</div>
</div>
<div class="info-card">
<div class="info-label">Задержка</div>
<div class="info-value" id="latency">-</div>
</div>
<div class="info-card">
<div class="info-label">Потребление памяти</div>
<div class="info-value" id="memoryUsage">-</div>
</div>
</div>
<div class="controls">
<button id="reconnectBtn" onclick="reconnect()">🔄 Переподключиться</button>
<button id="screenshotBtn" onclick="takeScreenshot()">📸 Скриншот</button>
</div>
<div class="error-message" id="errorMessage"></div>
</div>
</div>
<script>
// Stream mode: 'websocket' or 'sse'
let streamMode = 'websocket';
// SSE variables
let eventSource = null;
let sseFrameCount = 0;
let sseLastFrameTime = Date.now();
let sseTotalBytes = 0;
let sseLastBitrateCalc = Date.now();
// WebSocket variables
let websocket = null;
let player = null;
let wsFrameCount = 0;
let wsLastFrameTime = Date.now();
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
// Common variables
let fps = 0;
let statsInterval = null;
function updateMemoryUsage() {
if (performance.memory) {
const memMB = (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(1);
document.getElementById('memoryUsage').textContent = `${memMB} MB`;
} else {
document.getElementById('memoryUsage').textContent = 'N/A';
}
}
function switchToWebSocket() {
if (streamMode === 'websocket') return;
streamMode = 'websocket';
document.getElementById('btnWebSocket').classList.add('active');
document.getElementById('btnSSE').classList.remove('active');
disconnectSSE();
connectWebSocket();
}
function switchToSSE() {
if (streamMode === 'sse') return;
streamMode = 'sse';
document.getElementById('btnSSE').classList.add('active');
document.getElementById('btnWebSocket').classList.remove('active');
disconnectWebSocket();
connectSSE();
}
function connectWebSocket() {
document.getElementById('streamMethod').textContent = 'WebSocket (H.264)';
document.getElementById('videoCanvas').classList.remove('hidden');
document.getElementById('videoFrame').classList.add('hidden');
console.log('Connecting to WebSocket video stream...');
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/video`;
// Connect to native WebSocket
websocket = new WebSocket(wsUrl);
websocket.binaryType = 'arraybuffer';
websocket.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus(true);
hideError();
reconnectAttempts = 0;
// Initialize JSMpeg player
const canvas = document.getElementById('videoCanvas');
player = new JSMpeg.Player(null, {
canvas: canvas,
disableGl: false,
disableWebAssembly: false,
preserveDrawingBuffer: false,
progressive: true,
throttled: true,
chunkSize: 1024 * 32,
onVideoDecode: (decoder, time) => {
// Update FPS counter
const now = Date.now();
const timeDiff = (now - wsLastFrameTime) / 1000;
if (timeDiff > 0.1) { // Update every 100ms
fps = Math.round(1 / timeDiff);
document.getElementById('fpsCounter').textContent = fps;
wsFrameCount++;
document.getElementById('frameCounter').textContent = wsFrameCount;
}
wsLastFrameTime = now;
// Update resolution
if (decoder.width && decoder.height) {
document.getElementById('resolution').textContent =
`${decoder.width}×${decoder.height}`;
}
// Hide loading overlay on first frame
document.getElementById('loadingOverlay').classList.add('hidden');
}
});
};
websocket.onmessage = (event) => {
if (player && event.data instanceof ArrayBuffer) {
const uint8Array = new Uint8Array(event.data);
if (!player.source) {
// Create source buffer if needed
player.source = {
write: function(data) {
if (player.demuxer && player.demuxer.write) {
player.demuxer.write(data);
}
}
};
}
// Feed data to JSMpeg
if (player.demuxer && player.demuxer.write) {
player.demuxer.write(uint8Array);
}
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
showError('Ошибка WebSocket соединения');
};
websocket.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus(false);
// Auto-reconnect
if (streamMode === 'websocket' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
console.log(`Attempting to reconnect (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
setTimeout(connectWebSocket, 2000);
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showError('Потеряно соединение. Нажмите "Переподключиться"');
}
};
// Start memory monitoring
if (!statsInterval) {
statsInterval = setInterval(() => {
updateMemoryUsage();
}, 2000);
}
// Send periodic ping to keep connection alive
const pingInterval = setInterval(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send('ping');
} else {
clearInterval(pingInterval);
}
}, 30000); // Every 30 seconds
}
function disconnectWebSocket() {
if (websocket) {
websocket.close();
websocket = null;
}
if (player) {
player.destroy();
player = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
function connectSSE() {
document.getElementById('streamMethod').textContent = 'SSE (JPEG)';
document.getElementById('videoCanvas').classList.add('hidden');
document.getElementById('videoFrame').classList.remove('hidden');
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/stream');
eventSource.onopen = () => {
updateConnectionStatus(true);
hideError();
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) {
showError(data.error);
return;
}
// Update frame
const img = document.getElementById('videoFrame');
img.src = 'data:image/jpeg;base64,' + data.jpeg;
// Hide loading overlay on first frame
document.getElementById('loadingOverlay').classList.add('hidden');
// Update counters
sseFrameCount++;
document.getElementById('frameCounter').textContent = data.frame_number;
// Calculate FPS
const now = Date.now();
const timeDiff = (now - sseLastFrameTime) / 1000;
if (timeDiff > 0) {
fps = Math.round(1 / timeDiff);
document.getElementById('fpsCounter').textContent = fps;
}
sseLastFrameTime = now;
// Update resolution
document.getElementById('resolution').textContent = `${data.width}×${data.height}`;
// Update frame size
const sizeKB = (data.data_size / 1024).toFixed(1);
document.getElementById('frameSize').textContent = `${sizeKB} KB`;
// Calculate latency
const currentTimeUs = Date.now() * 1000;
const latencyMs = Math.round((currentTimeUs - data.timestamp_us) / 1000);
document.getElementById('latency').textContent = `${latencyMs} ms`;
// Calculate bitrate
sseTotalBytes += data.data_size;
const bitrateTime = (now - sseLastBitrateCalc) / 1000;
if (bitrateTime >= 1.0) {
const bitrate = (sseTotalBytes * 8 / bitrateTime / 1000000).toFixed(2);
document.getElementById('frameSize').textContent = `${bitrate} Mbps`;
sseTotalBytes = 0;
sseLastBitrateCalc = now;
}
updateMemoryUsage();
} catch (error) {
console.error('Error processing frame:', error);
showError('Ошибка обработки кадра: ' + error.message);
}
};
eventSource.onerror = (error) => {
console.error('EventSource error:', error);
updateConnectionStatus(false);
showError('Потеряно соединение с сервером');
};
// Start memory monitoring
if (!statsInterval) {
statsInterval = setInterval(() => {
updateMemoryUsage();
}, 2000);
}
}
function disconnectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
function updateConnectionStatus(connected) {
const indicator = document.getElementById('connectionStatus');
const text = document.getElementById('connectionText');
if (connected) {
indicator.classList.add('connected');
text.textContent = 'Подключено';
} else {
indicator.classList.remove('connected');
text.textContent = 'Отключено';
}
}
function showError(message) {
const errorDiv = document.getElementById('errorMessage');
errorDiv.textContent = message;
errorDiv.classList.add('visible');
}
function hideError() {
const errorDiv = document.getElementById('errorMessage');
errorDiv.classList.remove('visible');
}
function reconnect() {
document.getElementById('loadingOverlay').classList.remove('hidden');
reconnectAttempts = 0;
if (streamMode === 'websocket') {
disconnectWebSocket();
connectWebSocket();
} else {
disconnectSSE();
connectSSE();
}
}
function takeScreenshot() {
let dataUrl;
if (streamMode === 'websocket') {
const canvas = document.getElementById('videoCanvas');
dataUrl = canvas.toDataURL('image/jpeg', 0.95);
} else {
const img = document.getElementById('videoFrame');
dataUrl = img.src;
}
const link = document.createElement('a');
link.download = `beacon_tracker_${Date.now()}.jpg`;
link.href = dataUrl;
link.click();
}
// Connect on page load with default WebSocket mode
if (streamMode === 'websocket') {
connectWebSocket();
} else {
connectSSE();
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
disconnectWebSocket();
disconnectSSE();
});
</script>
</body>
</html>