# 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/` ## Docker (production-ready) This repo includes a `Dockerfile` and `docker-compose.yml` suitable for LAN deployments. ### Docker networking for LAN + UDP triggers SyncPlayer listens on **UDP ports configured per Event**. Docker has two workable strategies: 1) **Publish a UDP port range** (works on Docker Desktop + Linux) - The default `syncplayer` service publishes `7000-7999/udp`. - Keep your Event UDP ports within that range. 2) **Host networking** (Linux only) - Use `docker compose --profile hostnet up -d --build` - Dynamic UDP ports work without pre-declaring a range. - Discovery/casting protocols (mDNS/SSDP/etc.) also work best here. In both cases HTTP + Socket.IO is reachable at `http://:5000`. ### Run ```bash docker compose up -d --build ``` ## Publish (git tag + docker push) This repo includes a small helper script to publish a version: ```bash python publish.py 1.2.3 ``` It will: 1) `git push origin ` 2) Create and push git tag `1.2.3` 3) Build and push Docker images to: - `git.alphen.cloud/bramval/SyncPlayer:1.2.3` - `git.alphen.cloud/bramval/SyncPlayer:latest` Dry-run: ```bash python publish.py 1.2.3 --dry-run ``` Data is persisted in `./data/` on the host: - `./data/syncplayer.db` - `./data/media/` (uploaded videos + idle image) - `./data/logs/system.log` Then open: - Admin: `http://:5000/admin/` - Displays: `http://:5000/display/` ### Notes - Default Gunicorn configuration runs **1 worker** to avoid starting multiple UDP listeners. - Change `SECRET_KEY` in `docker-compose.yml` for real deployments. ## 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.