Initial commit
This commit is contained in:
169
README.md
Normal file
169
README.md
Normal file
@@ -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://<server-ip>:5000/admin/`
|
||||
|
||||
Open display pages:
|
||||
|
||||
- `http://<server-ip>:5000/display/<public_id>`
|
||||
|
||||
## 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://<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.
|
||||
Reference in New Issue
Block a user