docs: Add 42 files
All checks were successful
Test and Publish Templates / test-and-publish (push) Successful in 59s

This commit is contained in:
Your Name
2025-09-01 14:04:58 +12:00
parent 014104df35
commit 11ddc264eb
21 changed files with 1284 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# DO NOT EDIT THIS FILE FOR YOUR SERVICE!
# This file is replaced from the template whenever there is an update.
# Edit the service.env file to make changes.
# Template to use - always required!
TEMPLATE=squashkiwi-streaming
REQUIRES_HOST_ROOT=false
REQUIRES_DOCKER=true
REQUIRES_DOCKER_ROOT=false
# Application settings
MEDIAMTX_PORT=8554
HLS_PORT=8888
WEBRTC_PORT=8889
WEB_PORT=80
# Deployment settings
PROJECT_NAME="squashkiwi-streaming"

View File

@@ -0,0 +1,122 @@
version: '3.8'
services:
# MediaMTX - RTSP/HLS/WebRTC streaming server
mediamtx:
image: bluenviron/mediamtx:latest
container_name: ${PROJECT_NAME}-mediamtx
restart: unless-stopped
network_mode: host
volumes:
- ./mediamtx.yml:/mediamtx.yml
- ${RECORDINGS_PATH}:/recordings
environment:
- MTX_PROTOCOLS=tcp
- CAMERA_USER=${CAMERA_USER}
- CAMERA_PASSWORD=${CAMERA_PASSWORD}
- CAMERA_IP=${CAMERA_IP}
- CAMERA_RTSP_PORT=${CAMERA_RTSP_PORT}
- COURT_ID=${COURT_ID}
- COURT_NAME=${COURT_NAME}
- PUBLISH_PASSWORD=${PUBLISH_PASSWORD:-admin}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9997/v2/paths/list"]
interval: 30s
timeout: 10s
retries: 3
# Score overlay and recording service
overlay-service:
build: ./overlay
image: ${PROJECT_NAME}-overlay:latest
container_name: ${PROJECT_NAME}-overlay
restart: unless-stopped
depends_on:
- mediamtx
volumes:
- ${RECORDINGS_PATH}:/recordings
environment:
- SQUASHKIWI_API=${SQUASHKIWI_API}
- COURT_ID=${COURT_ID}
- MEDIAMTX_API=http://localhost:9997
- RECORDING_PATH=/recordings
- IDLE_TIMEOUT=${IDLE_TIMEOUT}
- RECORDING_RETENTION_DAYS=${RECORDING_RETENTION_DAYS}
healthcheck:
test: ["CMD", "pgrep", "-f", "overlay_service.py"]
interval: 30s
timeout: 10s
retries: 3
# Nginx web server
nginx:
image: nginx:alpine
container_name: ${PROJECT_NAME}-nginx
restart: unless-stopped
ports:
- "${HOST_PORT}:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./web:/usr/share/nginx/html:ro
- ${RECORDINGS_PATH}:/recordings:ro
- nginx-cache:/var/cache/nginx
depends_on:
- mediamtx
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Cloudflare tunnel
cloudflared:
image: cloudflare/cloudflared:latest
container_name: ${PROJECT_NAME}-tunnel
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
profiles:
- tunnel
# Optional: Prometheus monitoring
prometheus:
image: prom/prometheus:latest
container_name: ${PROJECT_NAME}-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
profiles:
- monitoring
# Optional: Grafana
grafana:
image: grafana/grafana:latest
container_name: ${PROJECT_NAME}-grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
depends_on:
- prometheus
profiles:
- monitoring
volumes:
nginx-cache:
prometheus-data:
grafana-data:
networks:
default:
name: ${PROJECT_NAME}
driver: bridge

View File

@@ -0,0 +1,66 @@
# MediaMTX Configuration for SquashKiwi Streaming
# General Settings
logLevel: info
logDestinations: [stdout]
# API Configuration
api: yes
apiAddress: :9997
# Metrics
metrics: yes
metricsAddress: :9998
# RTSP Server
rtsp: yes
rtspAddress: :8554
protocols: [tcp, udp]
readTimeout: 10s
writeTimeout: 10s
# HLS Server
hls: yes
hlsAddress: :8888
hlsAllowOrigin: '*'
hlsSegmentCount: 10
hlsSegmentDuration: 2s
hlsPartDuration: 200ms
# WebRTC Server
webrtc: yes
webrtcAddress: :8889
webrtcAllowOrigin: '*'
# Path Configuration
paths:
# Main court camera stream
court_main:
source: rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=0
sourceProtocol: tcp
sourceOnDemand: no
# Authentication
publishUser: admin
publishPass: ${PUBLISH_PASSWORD}
# Recording
record: yes
recordPath: /recordings/${COURT_ID}_%Y%m%d_%H%M%S.mp4
recordFormat: mp4
recordSegmentDuration: 1h
recordDeleteAfter: 720h
# Generate HLS
sourceGenerateHLS: yes
# Generate WebRTC
sourceGenerateWebRTC: yes
# Sub-stream for lower bandwidth
court_sub:
source: rtsp://${CAMERA_USER}:${CAMERA_PASSWORD}@${CAMERA_IP}:${CAMERA_RTSP_PORT}/cam/realmonitor?channel=1&subtype=1
sourceProtocol: tcp
sourceOnDemand: yes
sourceGenerateHLS: yes
hlsSegmentDuration: 4s

View File

@@ -0,0 +1,102 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
# Cache settings for HLS
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=hls_cache:10m max_size=1g inactive=60m use_temp_path=off;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Health check
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Main web interface
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# HLS streams proxy
location /hls/ {
proxy_pass http://localhost:8888/;
proxy_http_version 1.1;
# CORS headers
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range";
# Cache HLS segments
proxy_cache hls_cache;
proxy_cache_valid 200 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_buffering off;
proxy_request_buffering off;
}
# WebRTC signaling
location /webrtc/ {
proxy_pass http://localhost:8889/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# MediaMTX API
location /api/mediamtx/ {
proxy_pass http://localhost:9997/;
allow 127.0.0.1;
allow 172.16.0.0/12;
deny all;
}
# Recordings
location /recordings/ {
alias /recordings/;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
add_header Accept-Ranges bytes;
add_header Content-Disposition "inline";
}
# API endpoint for recordings list
location /api/recordings {
default_type application/json;
return 200 '{"recordings": []}';
}
# Metrics
location /metrics {
proxy_pass http://localhost:9998/metrics;
allow 127.0.0.1;
allow 172.16.0.0/12;
deny all;
}
}
}

View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
# Install FFmpeg and fonts
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
fonts-dejavu-core \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements and install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY overlay_service.py .
# Create recordings directory
RUN mkdir -p /recordings
# Run as non-root user
RUN useradd -m -s /bin/bash overlay && \
chown -R overlay:overlay /app /recordings
USER overlay
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import sys; sys.exit(0)"
CMD ["python", "-u", "overlay_service.py"]

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
SquashKiwi Score Overlay Service
Fetches scores from SquashKiwi API and manages recordings
"""
import asyncio
import aiohttp
import json
import subprocess
import os
import time
import signal
import sys
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class ScoreOverlayService:
def __init__(self):
# Load configuration from environment
self.api_url = os.getenv('SQUASHKIWI_API', 'https://squash.kiwi/api')
self.court_id = os.getenv('COURT_ID', 'court1')
self.mediamtx_api = os.getenv('MEDIAMTX_API', 'http://localhost:9997')
self.recording_path = os.getenv('RECORDING_PATH', '/recordings')
# State management
self.current_score = {"player1": 0, "player2": 0, "games": "0-0", "serving": None}
self.recording_process = None
self.recording_filename = None
self.last_score_change = time.time()
self.match_start_time = None
self.idle_timeout = int(os.getenv('IDLE_TIMEOUT', '300'))
# Ensure recording directory exists
os.makedirs(self.recording_path, exist_ok=True)
# Setup signal handlers
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
logger.info(f"Score overlay service initialized for court {self.court_id}")
def signal_handler(self, signum, frame):
"""Handle shutdown signals gracefully"""
logger.info(f"Received signal {signum}, shutting down...")
if self.recording_process:
self.stop_recording_sync()
sys.exit(0)
async def fetch_score(self) -> Optional[Dict[str, Any]]:
"""Fetch current score from SquashKiwi API"""
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"{self.api_url}/court/{self.court_id}/score"
async with session.get(url) as resp:
if resp.status == 200:
return await resp.json()
elif resp.status == 404:
logger.debug(f"No active match on court {self.court_id}")
return None
else:
logger.warning(f"API returned status {resp.status}")
return None
except Exception as e:
logger.error(f"Error fetching score: {e}")
return None
async def check_stream_health(self) -> bool:
"""Check if the stream is healthy"""
try:
timeout = aiohttp.ClientTimeout(total=2)
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"{self.mediamtx_api}/v2/paths/list"
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
paths = data.get('items', [])
return any(p.get('name') == 'court_main' for p in paths)
return False
except Exception as e:
logger.error(f"Failed to check stream health: {e}")
return False
def format_score_text(self) -> str:
"""Format the score for overlay display"""
games = self.current_score.get('games', '0-0')
p1_score = self.current_score.get('player1', 0)
p2_score = self.current_score.get('player2', 0)
p1_name = self.current_score.get('player1_name', 'Player 1')[:15]
p2_name = self.current_score.get('player2_name', 'Player 2')[:15]
serving = self.current_score.get('serving')
serve1 = '' if serving == 1 else ''
serve2 = '' if serving == 2 else ''
return f"{games} | {p1_name}: {p1_score}{serve1} - {p2_name}: {p2_score}{serve2}"
async def start_recording(self):
"""Start recording the match"""
if self.recording_process and self.recording_process.poll() is None:
logger.debug("Recording already in progress")
return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
court_folder = os.path.join(self.recording_path, self.court_id)
os.makedirs(court_folder, exist_ok=True)
self.recording_filename = os.path.join(court_folder, f"match_{timestamp}.mp4")
self.match_start_time = datetime.now()
logger.info(f"Starting recording: {self.recording_filename}")
score_text = self.format_score_text()
cmd = [
'ffmpeg',
'-y',
'-i', 'rtsp://localhost:8554/court_main',
'-vf', f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:"
f"text='{score_text}':fontcolor=white:fontsize=30:"
f"box=1:boxcolor=black@0.5:boxborderw=5:x=10:y=10:"
f"reload=1:textfile=/tmp/score.txt",
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
self.recording_filename
]
with open('/tmp/score.txt', 'w') as f:
f.write(score_text)
try:
self.recording_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
logger.info(f"Recording process started with PID {self.recording_process.pid}")
except Exception as e:
logger.error(f"Failed to start recording: {e}")
self.recording_process = None
async def stop_recording(self):
"""Stop recording the match"""
if not self.recording_process or self.recording_process.poll() is not None:
logger.debug("No active recording to stop")
return
logger.info("Stopping recording due to inactivity")
try:
self.recording_process.send_signal(signal.SIGINT)
try:
await asyncio.wait_for(
asyncio.create_subprocess_exec('wait', str(self.recording_process.pid)),
timeout=5.0
)
except asyncio.TimeoutError:
self.recording_process.kill()
logger.warning("Had to force kill recording process")
if self.match_start_time:
duration = datetime.now() - self.match_start_time
logger.info(f"Recording duration: {duration}")
metadata_file = self.recording_filename.replace('.mp4', '.json')
metadata = {
'court_id': self.court_id,
'start_time': self.match_start_time.isoformat(),
'end_time': datetime.now().isoformat(),
'duration': str(duration),
'final_score': self.current_score
}
try:
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
logger.info(f"Metadata saved to {metadata_file}")
except Exception as e:
logger.error(f"Failed to save metadata: {e}")
except Exception as e:
logger.error(f"Error stopping recording: {e}")
finally:
self.recording_process = None
self.recording_filename = None
self.match_start_time = None
def stop_recording_sync(self):
"""Synchronous version of stop_recording for signal handler"""
if self.recording_process and self.recording_process.poll() is None:
logger.info("Stopping recording (sync)")
self.recording_process.terminate()
self.recording_process.wait(timeout=5)
async def update_overlay_text(self):
"""Update the overlay text file"""
score_text = self.format_score_text()
try:
with open('/tmp/score.txt', 'w') as f:
f.write(score_text)
except Exception as e:
logger.error(f"Failed to update overlay text: {e}")
async def cleanup_old_recordings(self):
"""Remove recordings older than retention period"""
retention_days = int(os.getenv('RECORDING_RETENTION_DAYS', '30'))
cutoff_date = datetime.now() - timedelta(days=retention_days)
try:
for root, dirs, files in os.walk(self.recording_path):
for file in files:
if file.endswith('.mp4'):
file_path = os.path.join(root, file)
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_time < cutoff_date:
os.remove(file_path)
logger.info(f"Deleted old recording: {file}")
metadata_file = file_path.replace('.mp4', '.json')
if os.path.exists(metadata_file):
os.remove(metadata_file)
except Exception as e:
logger.error(f"Error during cleanup: {e}")
async def main_loop(self):
"""Main service loop"""
logger.info("Starting main service loop")
await self.cleanup_old_recordings()
last_cleanup = datetime.now()
while True:
try:
stream_healthy = await self.check_stream_health()
if not stream_healthy:
logger.warning("Stream not healthy, waiting...")
await asyncio.sleep(10)
continue
score = await self.fetch_score()
if score:
if score != self.current_score:
self.current_score = score
self.last_score_change = time.time()
logger.info(f"Score updated: {self.format_score_text()}")
await self.update_overlay_text()
if not self.recording_process or self.recording_process.poll() is not None:
await self.start_recording()
idle_time = time.time() - self.last_score_change
if self.recording_process and idle_time > self.idle_timeout:
logger.info(f"Match idle for {idle_time:.0f} seconds")
await self.stop_recording()
else:
if self.recording_process:
logger.info("No active match detected")
await self.stop_recording()
if datetime.now() - last_cleanup > timedelta(days=1):
await self.cleanup_old_recordings()
last_cleanup = datetime.now()
await asyncio.sleep(1)
except Exception as e:
logger.error(f"Error in main loop: {e}")
await asyncio.sleep(5)
async def run(self):
"""Run the service"""
logger.info("SquashKiwi Score Overlay Service starting...")
await self.main_loop()
if __name__ == "__main__":
service = ScoreOverlayService()
try:
asyncio.run(service.run())
except KeyboardInterrupt:
logger.info("Service stopped by user")
except Exception as e:
logger.error(f"Service crashed: {e}")
sys.exit(1)

View File

@@ -0,0 +1,3 @@
aiohttp==3.9.1
python-dateutil==2.8.2
tenacity==8.2.3

View File

@@ -0,0 +1,35 @@
# SquashKiwi Streaming Configuration
# Edit this file to configure your streaming service
LOCAL_DATA_FOLDER="/home/dropshell/example-squashkiwi-streaming"
# Camera Configuration
CAMERA_IP=192.168.1.100
CAMERA_USER=admin
CAMERA_PASSWORD=changeme
CAMERA_RTSP_PORT=554
# Court Configuration
COURT_ID=court1
COURT_NAME=Court 1
# SquashKiwi API
SQUASHKIWI_API=https://squash.kiwi/api
# Recording Settings
RECORDINGS_PATH="/home/dropshell/example-squashkiwi-streaming/recordings"
RECORDING_RETENTION_DAYS=30
RECORDING_QUALITY=high
IDLE_TIMEOUT=300
# Network Settings
HOST_PORT=8080
PUBLIC_IP=
# Optional: Cloudflare Tunnel
CLOUDFLARE_TUNNEL_TOKEN=
# Optional: Monitoring
ENABLE_MONITORING=false
GRAFANA_PASSWORD=admin

View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SquashKiwi Court Stream</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: white;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.video-section {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.video-container {
position: relative;
padding-bottom: 56.25%;
height: 0;
background: black;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.video-controls {
padding: 15px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.status-badge.live {
background: #10b981;
animation: pulse 2s infinite;
}
.status-badge.offline {
background: #ef4444;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.quality-selector {
display: flex;
gap: 5px;
}
.quality-btn {
padding: 5px 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.quality-btn.active {
background: rgba(255, 255, 255, 0.3);
}
.info-section {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.recordings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin-top: 20px;
}
.recording-card {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
transition: all 0.3s;
cursor: pointer;
}
.recording-card:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
<div class="container">
<header>
<h1>🎾 SquashKiwi Court Stream</h1>
</header>
<div class="video-section">
<div class="video-container">
<video id="video" controls autoplay muted></video>
</div>
<div class="video-controls">
<div class="status-badge offline" id="status">
<span>Connecting...</span>
</div>
<div class="quality-selector">
<button class="quality-btn active" data-quality="auto">Auto</button>
<button class="quality-btn" data-quality="high">HD</button>
<button class="quality-btn" data-quality="low">SD</button>
</div>
<div>
<button class="btn" onclick="toggleFullscreen()">📺 Fullscreen</button>
<button class="btn" onclick="refreshStream()">🔄 Refresh</button>
</div>
</div>
</div>
<div class="info-section">
<h2>Recent Recordings</h2>
<div class="recordings-grid" id="recordings">
<div class="recording-card">
<div>Loading recordings...</div>
</div>
</div>
</div>
</div>
<script>
const config = {
courtId: new URLSearchParams(window.location.search).get('court') || 'court_main',
hlsUrl: '/hls/'
};
let hls = null;
function initPlayer() {
const video = document.getElementById('video');
const streamUrl = `${config.hlsUrl}${config.courtId}/index.m3u8`;
if (Hls.isSupported()) {
hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
enableWorker: true,
lowLatencyMode: true
});
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
updateStatus(true);
});
hls.on(Hls.Events.ERROR, function(event, data) {
console.error('HLS error:', data);
if (data.fatal) {
updateStatus(false);
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
setTimeout(() => hls.startLoad(), 3000);
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
setTimeout(initPlayer, 5000);
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
video.addEventListener('loadedmetadata', function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
updateStatus(true);
});
video.addEventListener('error', function() {
updateStatus(false);
setTimeout(initPlayer, 5000);
});
}
}
function updateStatus(live) {
const statusEl = document.getElementById('status');
if (live) {
statusEl.className = 'status-badge live';
statusEl.innerHTML = '<span>🔴 LIVE</span>';
} else {
statusEl.className = 'status-badge offline';
statusEl.innerHTML = '<span>⚫ OFFLINE</span>';
}
}
document.querySelector('.quality-selector').addEventListener('click', (e) => {
if (e.target.classList.contains('quality-btn')) {
document.querySelectorAll('.quality-btn').forEach(btn => {
btn.classList.remove('active');
});
e.target.classList.add('active');
const quality = e.target.dataset.quality;
if (hls) {
switch(quality) {
case 'auto':
hls.currentLevel = -1;
break;
case 'high':
hls.currentLevel = hls.levels.length - 1;
break;
case 'low':
hls.currentLevel = 0;
break;
}
}
}
});
function toggleFullscreen() {
const video = document.getElementById('video');
if (!document.fullscreenElement) {
video.requestFullscreen().catch(err => {
console.error('Fullscreen error:', err);
});
} else {
document.exitFullscreen();
}
}
function refreshStream() {
if (hls) {
hls.destroy();
}
initPlayer();
}
async function loadRecordings() {
try {
const response = await fetch(`/api/recordings?court=${config.courtId}`);
const data = await response.json();
const container = document.getElementById('recordings');
if (!data.recordings || data.recordings.length === 0) {
container.innerHTML = '<div class="recording-card"><div>No recordings available</div></div>';
return;
}
container.innerHTML = data.recordings.map(rec => `
<div class="recording-card" onclick="playRecording('${rec.file}')">
<div>${rec.date}</div>
<div style="margin-top: 10px; font-size: 0.85rem;">
${rec.size} • Click to play
</div>
</div>
`).join('');
} catch (e) {
console.error('Failed to load recordings:', e);
}
}
function playRecording(file) {
const video = document.getElementById('video');
if (hls) {
hls.destroy();
}
video.src = `/recordings/${config.courtId}/${file}`;
video.play();
updateStatus(false);
}
document.addEventListener('DOMContentLoaded', () => {
initPlayer();
loadRecordings();
setInterval(loadRecordings, 30000);
});
window.addEventListener('beforeunload', () => {
if (hls) {
hls.destroy();
}
});
</script>
</body>
</html>