5.7 KiB
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:
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:
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:
start_time_ms = server_time_ms() + EVENT_LEAD_TIME_MS (default 5000)
Server sends each participating display:
video_urlstart_time_msevent_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:
expected = (serverNowMs - start_time_ms)/1000
drift = video.currentTime - expected
- if
|drift| > 15ms: adjustplaybackRateslightly (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):
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
2) Initialize database
python run.py init-db
3) Run server
python run.py --host 0.0.0.0 --port 5000
Notes:
- On Windows / Python 3.14+, this runs in
threadingasync mode by default (good enough for LAN testing). For highest scale/throughput, run on Linux and installeventletorgevent. - You can force an async mode with:
set ASYNC_MODE=threading(Windows) orexport ASYNC_MODE=eventlet|gevent|threading(Linux).
Open admin UI:
http://<server-ip>:5000/admin/
Open display pages:
http://<server-ip>:5000/display/<public_id>
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:
-
Publish a UDP port range (works on Docker Desktop + Linux)
- The default
syncplayerservice publishes7000-7999/udp. - Keep your Event UDP ports within that range.
- The default
-
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.
- Use
In both cases HTTP + Socket.IO is reachable at http://<host-ip>:5000.
Run
docker compose up -d --build
Publish (git tag + docker push)
This repo includes a small helper script to publish a version:
python publish.py 1.2.3
It will:
git push origin <current-branch>- Create and push git tag
1.2.3 - Build and push Docker images to:
git.alphen.cloud/bramval/SyncPlayer:1.2.3git.alphen.cloud/bramval/SyncPlayer:latest
Dry-run:
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://<host-ip>:5000/admin/ - Displays:
http://<host-ip>:5000/display/<public_id>
Notes
- Default Gunicorn configuration runs 1 worker to avoid starting multiple UDP listeners.
- Change
SECRET_KEYindocker-compose.ymlfor 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):
$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://<server-ip>:5000/trigger/<event_id> - By name:
http://<server-ip>:5000/trigger_by_name/<event_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://<server-ip>: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.