Files
2025-11-21 16:31:38 +03:00

656 lines
22 KiB
HTML
Raw Permalink 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 (HLS)</title>
<!-- HLS.js for H.264 HLS decoding -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></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 {
width: 100%;
height: auto;
display: block;
}
#videoPlayer {
width: 100%;
height: auto;
display: block;
background: #000;
min-height: 480px;
}
.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="video-container">
<!-- HTML5 video element for HLS playback -->
<video id="videoPlayer" controls autoplay muted playsinline></video>
<!-- Image for SSE/JPEG streaming (fallback) -->
<img id="videoFrame" class="hidden" alt="Video stream">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Ожидание HLS потока...</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: 'hls' or 'sse'
let streamMode = 'hls';
// SSE variables (fallback)
let eventSource = null;
let sseFrameCount = 0;
let sseLastFrameTime = Date.now();
let sseTotalBytes = 0;
let sseLastBitrateCalc = Date.now();
// HLS variables
let hls = null;
let video = null;
let hlsFrameCount = 0;
let hlsLastFrameTime = 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 connectHLS() {
document.getElementById('streamMethod').textContent = 'HLS (H.264)';
document.getElementById('videoPlayer').classList.remove('hidden');
document.getElementById('videoFrame').classList.add('hidden');
console.log('Connecting to HLS video stream...');
video = document.getElementById('videoPlayer');
const hlsUrl = `${window.location.protocol}//${window.location.host}/hls/playlist.m3u8`;
if (Hls.isSupported()) {
hls = new Hls({
// Low latency configuration with increased buffer
lowLatencyMode: true,
backBufferLength: 90,
maxBufferLength: 10, // Increased from 3 to 10 seconds
maxBufferSize: 3 * 1024 * 1024, // Increased from 1MB to 3MB
liveSyncDurationCount: 3, // Keep 3 segments in sync
liveMaxLatencyDurationCount: 5, // Max 5 segments latency before catchup
maxMaxBufferLength: 30, // Maximum buffer length
enableWorker: true,
debug: false
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
console.log('HLS manifest parsed, starting playback...');
video.play().catch(e => {
console.warn('Autoplay prevented, waiting for user interaction:', e);
});
updateConnectionStatus(true);
hideError();
});
hls.on(Hls.Events.ERROR, function(event, data) {
console.error('HLS error:', data.type, data.details);
if (data.fatal) {
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('Network error, attempting to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting to recover...');
hls.recoverMediaError();
break;
default:
console.error('Fatal error, cannot recover');
updateConnectionStatus(false);
showError('Ошибка HLS потока: ' + data.details);
break;
}
}
});
// Hide loading overlay when playback starts
video.addEventListener('playing', () => {
console.log('Video playback started');
document.getElementById('loadingOverlay').classList.add('hidden');
updateConnectionStatus(true);
});
// Track video stats
video.addEventListener('timeupdate', () => {
const now = Date.now();
const timeDiff = (now - hlsLastFrameTime) / 1000;
if (timeDiff > 0.5) { // Update every 500ms
// Estimate FPS from video framerate
if (video.videoWidth && video.videoHeight) {
document.getElementById('resolution').textContent =
`${video.videoWidth}×${video.videoHeight}`;
}
// Calculate approximate FPS
fps = hls.media ? Math.round(1 / timeDiff * 10) : 0;
document.getElementById('fpsCounter').textContent = fps;
hlsFrameCount++;
document.getElementById('frameCounter').textContent = hlsFrameCount;
hlsLastFrameTime = now;
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
console.log('Using native HLS support');
video.src = hlsUrl;
video.addEventListener('loadedmetadata', function() {
console.log('HLS metadata loaded');
video.play();
});
video.addEventListener('playing', () => {
document.getElementById('loadingOverlay').classList.add('hidden');
updateConnectionStatus(true);
});
} else {
showError('HLS не поддерживается в этом браузере');
}
// Start memory monitoring
if (!statsInterval) {
statsInterval = setInterval(() => {
updateMemoryUsage();
}, 2000);
}
}
function disconnectHLS() {
if (hls) {
hls.destroy();
hls = null;
}
if (video) {
video.pause();
video.src = '';
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
function connectSSE() {
document.getElementById('streamMethod').textContent = 'SSE (JPEG)';
document.getElementById('videoPlayer').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 === 'hls') {
disconnectHLS();
connectHLS();
} else {
disconnectSSE();
connectSSE();
}
}
function takeScreenshot() {
let dataUrl;
if (streamMode === 'hls') {
// Capture current frame from video element
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
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 HLS mode
if (streamMode === 'hls') {
connectHLS();
} else {
connectSSE();
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
disconnectHLS();
disconnectSSE();
});
</script>
</body>
</html>