2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00
2026-02-12 10:50:49 +01:00

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_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:

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):

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 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://<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:

  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://<host-ip>:5000.

Run

docker compose up -d --build

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_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):

$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.
Description
No description provided
Readme 144 KiB
Languages
Python 56.7%
JavaScript 22.3%
HTML 19.6%
Dockerfile 1.4%