commit 3a0bb1cd3701ae4c2c3b6dd20c0bc455fe2f7354 Author: bramval Date: Thu Feb 12 10:50:49 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b52adc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.venv/ +node_modules/ +__pycache__/ +*.pyc + +# Logs +logs/ +*.log + +# Local database +syncplayer.db +*.db + +# Media (keep only the default idle image) +media/* +!media/idle_image.png + +# OS / IDE +.DS_Store +Thumbs.db +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a4830c --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# SyncPlayer (LAN Synchronized Video Playback) + +Flask + Flask-SocketIO system for near frame-level synchronized playback across many browser-capable SoC displays on a local LAN. + +## Key synchronization mechanism + +### 1) Custom NTP-style time sync (client does **not** use its wall clock) + +Client keeps a high-resolution monotonic clock using: + +```js +client_ms = performance.timeOrigin + performance.now() +``` + +Every 10s the client sends `t1_ms` and the server replies with `{t1_ms, t2_ms}` where `t2_ms` is server time in ms. + +Client receives the reply at `t3_ms` and calculates: + +```text +rtt = t3 - t1 +offset = t2 - (t1 + t3)/2 +server_time ~= client_time + offset +``` + +Offset is smoothed to reduce jitter. + +### 2) Scheduled absolute start + +When an event triggers, the server schedules a future start time: + +```text +start_time_ms = server_time_ms() + EVENT_LEAD_TIME_MS (default 5000) +``` + +Server sends each participating display: + +- `video_url` +- `start_time_ms` +- `event_id` + +Client preloads, pauses at 0, then uses a `setTimeout` + `requestAnimationFrame` loop to call `video.play()` *as close as possible* to `start_time_ms` using the corrected server clock. + +### 3) Drift correction during playback + +Every 2s client computes: + +```text +expected = (serverNowMs - start_time_ms)/1000 +drift = video.currentTime - expected +``` + +- if `|drift| > 15ms`: adjust `playbackRate` slightly (0.99 / 1.01) +- if `|drift| > 100ms`: hard seek to expected + +This keeps the group synchronized on low-latency LANs. + +## Project structure + +``` +/app +/templates +/static +/media +models.py +routes.py +sockets.py +udp_listener.py +sync.py +config.py +run.py +requirements.txt +``` + +## Setup + +### 1) Create venv + install deps + +Windows (cmd): + +```bat +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +``` + +### 2) Initialize database + +```bat +python run.py init-db +``` + +### 3) Run server + +```bat +python run.py --host 0.0.0.0 --port 5000 +``` + +Notes: +- On Windows / Python 3.14+, this runs in `threading` async mode by default (good enough for LAN testing). For highest scale/throughput, run on Linux and install `eventlet` or `gevent`. +- You can force an async mode with: `set ASYNC_MODE=threading` (Windows) or `export ASYNC_MODE=eventlet|gevent|threading` (Linux). + +Open admin UI: + +- `http://:5000/admin/` + +Open display pages: + +- `http://:5000/display/` + +## UDP trigger + +Configure UDP Port + UDP Payload on an Event in the admin UI. The UDP listener binds ports automatically (refreshes config every 5s). + +Send a UDP packet (example PowerShell): + +```powershell +$udp = new-Object System.Net.Sockets.UdpClient +$bytes = [Text.Encoding]::UTF8.GetBytes("SHOW_START") +$udp.Send($bytes, $bytes.Length, "192.168.1.10", 7777) | Out-Null +$udp.Close() +``` + +### Firewall notes + +- Allow TCP port (default 5000) for HTTP/WebSocket. +- Allow the configured UDP ports for triggers. + +## HTTP URL trigger (LAN) + +You can also trigger an event by opening a URL (useful for simple controllers without UDP): + +- By id: `http://:5000/trigger/` +- By name: `http://:5000/trigger_by_name/` + +Cooldown: +- Cooldown is enforced by default. +- To bypass cooldown (LAN only), add `?force=1`. + +Example: + +``` +http://192.168.1.10:5000/trigger/1?force=1 +``` + +## Idle image (no active event) + +You can upload an **Idle Image** in the admin UI: + +- `http://:5000/admin/idle-image` + +When no triggered event is active, displays show this image fullscreen. + +## Debug overlay + +On display page: + +- Press **F2** or **Ctrl+Shift+D**, or +- **Double click** anywhere + +to toggle a small overlay showing Socket.IO connection state, offset/RTT, event id, drift and playbackRate. + +## Display video cache warming + +When a display page loads, it will fetch `/api/videos` and perform **background cache warming**: + +- Default strategy: request the first **1MB** of each video via HTTP `Range` (`bytes=0-1048575`). +- Purpose: get the container + initial moov atoms into cache to reduce startup stutter. +- It is **best-effort**: browsers may evict cache under pressure, and some platforms ignore caching for large media. +- Cache warming automatically pauses once an event starts to avoid competing bandwidth during synchronized start. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e267923 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +import os +from logging.handlers import RotatingFileHandler + +from flask import Flask +from flask_compress import Compress +from flask_cors import CORS + +from config import Config +from models import db +from routes import admin_bp, bp +from sockets import socketio + + +def create_app() -> Flask: + app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "..", "templates"), static_folder=os.path.join(os.path.dirname(__file__), "..", "static")) + app.config.from_object(Config) + + os.makedirs(app.config["MEDIA_DIR"], exist_ok=True) + + # Extensions + db.init_app(app) + CORS(app, resources={r"/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}) + Compress(app) + + # SocketIO tuning from config + socketio.init_app( + app, + cors_allowed_origins=app.config.get("CORS_ORIGINS", "*"), + ping_interval=app.config["SOCKETIO_PING_INTERVAL"], + ping_timeout=app.config["SOCKETIO_PING_TIMEOUT"], + ) + + # Blueprints + app.register_blueprint(bp) + app.register_blueprint(admin_bp) + + # Logging + _configure_logging(app) + + @app.after_request + def _lan_headers(resp): + # Keep headers permissive for embedded Chromium/Tizen/WebOS. + resp.headers.pop("Content-Security-Policy", None) + resp.headers.pop("Cross-Origin-Opener-Policy", None) + resp.headers.pop("Cross-Origin-Embedder-Policy", None) + # Disable caching for HTML/JS (but media route sets its own cache-control) + if resp.mimetype in ("text/html", "application/javascript"): + resp.headers["Cache-Control"] = "no-store" + return resp + + return app + + +def _configure_logging(app: Flask) -> None: + os.makedirs(os.path.join(app.root_path, "..", "logs"), exist_ok=True) + log_path = os.path.abspath(os.path.join(app.root_path, "..", "logs", "system.log")) + handler = RotatingFileHandler(log_path, maxBytes=2_000_000, backupCount=3) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + handler.setFormatter(fmt) + handler.setLevel(logging.INFO) + app.logger.addHandler(handler) + logging.getLogger().addHandler(handler) + logging.getLogger().setLevel(logging.INFO) diff --git a/config.py b/config.py new file mode 100644 index 0000000..2cfb87b --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +import os + + +class Config: + # LAN / kiosk deployments typically don't need strong cookie security. + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key-change-me") + + BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + DB_PATH = os.path.join(BASE_DIR, "syncplayer.db") + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}") + SQLALCHEMY_TRACK_MODIFICATIONS = False + + MEDIA_DIR = os.environ.get("MEDIA_DIR", os.path.join(BASE_DIR, "media")) + MAX_CONTENT_LENGTH = int(os.environ.get("MAX_CONTENT_LENGTH", str(2 * 1024 * 1024 * 1024))) # 2GB + + # Socket.IO tuning for LAN + SOCKETIO_PING_INTERVAL = float(os.environ.get("SOCKETIO_PING_INTERVAL", "5")) + SOCKETIO_PING_TIMEOUT = float(os.environ.get("SOCKETIO_PING_TIMEOUT", "10")) + + # Client drift correction thresholds (ms) + DRIFT_SOFT_MS = float(os.environ.get("DRIFT_SOFT_MS", "15")) + DRIFT_HARD_MS = float(os.environ.get("DRIFT_HARD_MS", "100")) + + # When an event triggers we schedule playback this many ms into the future. + EVENT_LEAD_TIME_MS = int(os.environ.get("EVENT_LEAD_TIME_MS", "5000")) + + # If video duration isn't known, keep event state alive for this many seconds. + EVENT_FALLBACK_TTL_SECONDS = int(os.environ.get("EVENT_FALLBACK_TTL_SECONDS", "3600")) + + # CORS: allow everything within LAN. Tighten if needed. + CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") diff --git a/event_manager.py b/event_manager.py new file mode 100644 index 0000000..ad38943 --- /dev/null +++ b/event_manager.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import logging +from typing import Dict, Optional + +from flask import url_for + +from models import Display, EventDisplayMap, Video, db +from sync import ActiveEventState, server_time_ms, set_trigger_event + + +logger = logging.getLogger(__name__) + + +def _build_assignments(event_id: int) -> Dict[str, str]: + maps = ( + db.session.query(EventDisplayMap) + .filter_by(event_id=event_id) + .join(Display, EventDisplayMap.display_id == Display.id) + .join(Video, EventDisplayMap.video_id == Video.id) + .all() + ) + assignments: Dict[str, str] = {} + for m in maps: + assignments[m.display.public_id] = url_for("main.media", filename=m.video.filename) + return assignments + + +def broadcast_trigger_state(state: ActiveEventState) -> None: + # Lazy import to avoid circular import (sockets -> event_manager -> sockets) + from sockets import socketio + + for public_id, video_url in state.assignments.items(): + socketio.emit( + "event_start", + { + "event_id": state.event_id, + "event_name": state.name, + "video_url": video_url, + "start_time_ms": state.start_time_ms, + "server_time_ms": server_time_ms(), + }, + room=f"display:{public_id}", + ) + + +def activate_trigger_event(event_id: int, start_ms: float, end_ms: float, name: str) -> Optional[ActiveEventState]: + """Activate a triggered event, store it in-memory, and broadcast it to displays.""" + assignments = _build_assignments(event_id) + if not assignments: + return None + + state = ActiveEventState( + event_id=event_id, + name=name, + start_time_ms=start_ms, + end_time_ms=end_ms, + assignments=assignments, + ) + set_trigger_event(state) + broadcast_trigger_state(state) + logger.info("Activated trigger event %s (%s)", event_id, name) + return state diff --git a/media/idle_image.png b/media/idle_image.png new file mode 100644 index 0000000..9b9105c Binary files /dev/null and b/media/idle_image.png differ diff --git a/models.py b/models.py new file mode 100644 index 0000000..5d6684f --- /dev/null +++ b/models.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import datetime + +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import UniqueConstraint + + +db = SQLAlchemy() + + +class Display(db.Model): + __tablename__ = "displays" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + public_id = db.Column(db.String(64), nullable=False, unique=True, index=True) + last_seen = db.Column(db.DateTime, nullable=True) + is_online = db.Column(db.Boolean, nullable=False, default=False) + + def __repr__(self) -> str: + return f"" + + +class Video(db.Model): + __tablename__ = "videos" + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(255), nullable=False, unique=True) + duration = db.Column(db.Float, nullable=True) # seconds + uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def __repr__(self) -> str: + return f"