Initial commit

This commit is contained in:
2026-02-12 10:50:49 +01:00
commit 3a0bb1cd37
30 changed files with 2571 additions and 0 deletions

21
.gitignore vendored Normal file
View File

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

169
README.md Normal file
View 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.

66
app/__init__.py Normal file
View File

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

31
config.py Normal file
View File

@@ -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", "*")

63
event_manager.py Normal file
View File

@@ -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

BIN
media/idle_image.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

87
models.py Normal file
View File

@@ -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"<Display {self.public_id} online={self.is_online}>"
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"<Video {self.filename}>"
class Event(db.Model):
__tablename__ = "events"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
udp_port = db.Column(db.Integer, nullable=True)
udp_payload = db.Column(db.String(255), nullable=True)
cooldown_seconds = db.Column(db.Integer, nullable=False, default=2)
last_triggered = db.Column(db.DateTime, nullable=True)
# Optional scheduled time could be added later (left out of DB intentionally unless required).
maps = db.relationship("EventDisplayMap", backref="event", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<Event {self.name}>"
class EventDisplayMap(db.Model):
__tablename__ = "event_display_maps"
__table_args__ = (
UniqueConstraint("event_id", "display_id", name="uq_event_display"),
)
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=False)
display_id = db.Column(db.Integer, db.ForeignKey("displays.id"), nullable=False)
video_id = db.Column(db.Integer, db.ForeignKey("videos.id"), nullable=False)
display = db.relationship("Display")
video = db.relationship("Video")
class EventLog(db.Model):
__tablename__ = "event_logs"
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=False)
triggered_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
trigger_source = db.Column(db.String(32), nullable=False) # manual|udp
source_ip = db.Column(db.String(64), nullable=True)
event = db.relationship("Event")
class AppSetting(db.Model):
__tablename__ = "app_settings"
# Small key/value store for global settings (SQLite-friendly)
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.String(255), nullable=False)

141
package-lock.json generated Normal file
View File

@@ -0,0 +1,141 @@
{
"name": "syncplayer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "syncplayer",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"socket.io-client": "4.7.5"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
"integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"engines": {
"node": ">=0.4.0"
}
}
}
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "syncplayer",
"version": "1.0.0",
"description": "Flask + Flask-SocketIO system for near frame-level synchronized playback across many browser-capable SoC displays on a local LAN.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"socket.io-client": "4.7.5"
}
}

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
Flask==3.0.2
Flask-SocketIO==5.3.6
# Async backends (optional). On Windows/Python 3.14+ these often have no wheels / compatibility issues.
eventlet==0.35.2; platform_system != "Windows" and python_version < "3.14"
gevent==24.2.1; platform_system != "Windows" and python_version < "3.14"
gevent-websocket==0.10.1; platform_system != "Windows" and python_version < "3.14"
Flask-SQLAlchemy==3.1.1
# SQLAlchemy 2.0.27 breaks on Python 3.14 due to typing changes; require a newer 2.0.x.
SQLAlchemy>=2.0.38
Flask-Cors==4.0.0
Flask-Compress==1.14
python-dotenv==1.0.1

498
routes.py Normal file
View File

@@ -0,0 +1,498 @@
from __future__ import annotations
import logging
import os
import subprocess
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from flask import (
Blueprint,
Response,
current_app,
flash,
redirect,
render_template,
request,
send_from_directory,
url_for,
)
from models import AppSetting, Display, Event, EventDisplayMap, EventLog, Video, db
from sockets import socketio
from event_manager import activate_trigger_event
from sync import get_current_event, server_time_ms
logger = logging.getLogger(__name__)
bp = Blueprint("main", __name__)
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def _get_setting(key: str) -> Optional[str]:
row = db.session.get(AppSetting, key)
return row.value if row else None
def _set_setting(key: str, value: str) -> None:
row = db.session.get(AppSetting, key)
if row is None:
row = AppSetting(key=key, value=value)
else:
row.value = value
db.session.add(row)
db.session.commit()
def _media_dir() -> str:
return current_app.config["MEDIA_DIR"]
@bp.get("/")
def index():
return redirect(url_for("admin.dashboard"))
@bp.get("/display/<public_id>")
def display_page(public_id: str):
# Minimal player page.
idle_image = _get_setting("idle_image")
idle_image_url = url_for("main.media", filename=idle_image) if idle_image else None
return render_template("display.html", public_id=public_id, idle_image_url=idle_image_url)
@bp.get("/media/<path:filename>")
def media(filename: str):
# Efficient static serving (still recommend nginx for very large deployments).
resp = send_from_directory(_media_dir(), filename, conditional=True)
# Kiosk players often struggle with overly strict headers.
resp.headers.pop("Content-Security-Policy", None)
resp.headers["Cache-Control"] = "public, max-age=3600"
return resp
def _probe_duration_seconds(path: str) -> Optional[float]:
"""Try to get media duration via ffprobe if available. Returns None on failure."""
try:
# Requires ffprobe in PATH.
cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
path,
]
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True, timeout=5).strip()
if not out:
return None
return float(out)
except Exception:
return None
def trigger_event(
event_id: int,
trigger_source: str,
source_ip: Optional[str] = None,
*,
force: bool = False,
) -> bool:
"""Trigger an event.
Cooldown is enforced unless force=True.
Returns True if triggered.
"""
ev = db.session.get(Event, event_id)
if not ev:
return False
now = datetime.utcnow()
if (not force) and ev.last_triggered and ev.cooldown_seconds:
if now < ev.last_triggered + timedelta(seconds=ev.cooldown_seconds):
return False
maps = (
db.session.query(EventDisplayMap)
.filter_by(event_id=ev.id)
.join(Display, EventDisplayMap.display_id == Display.id)
.join(Video, EventDisplayMap.video_id == Video.id)
.all()
)
if not maps:
return False
lead_ms = int(current_app.config["EVENT_LEAD_TIME_MS"])
start_ms = server_time_ms() + float(lead_ms)
assignments: Dict[str, str] = {}
max_duration = 0.0
for m in maps:
assignments[m.display.public_id] = url_for("main.media", filename=m.video.filename)
if m.video.duration and m.video.duration > max_duration:
max_duration = float(m.video.duration)
ttl_s = current_app.config["EVENT_FALLBACK_TTL_SECONDS"]
end_ms = start_ms + (max_duration * 1000.0 if max_duration > 0 else ttl_s * 1000.0)
activate_trigger_event(event_id=ev.id, start_ms=start_ms, end_ms=end_ms, name=ev.name)
ev.last_triggered = now
db.session.add(
EventLog(event_id=ev.id, trigger_source=trigger_source, source_ip=source_ip)
)
db.session.commit()
logger.info("Triggered event %s (%s) via %s", ev.id, ev.name, trigger_source)
socketio.emit("admin_event_triggered", {"event_id": ev.id, "event_name": ev.name, "start_time_ms": start_ms}, room="admin")
return True
@admin_bp.get("/")
def dashboard():
displays = db.session.query(Display).order_by(Display.id.asc()).all()
events = db.session.query(Event).order_by(Event.id.desc()).all()
active = get_current_event()
return render_template("admin/dashboard.html", displays=displays, events=events, active=active)
@admin_bp.get("/displays")
def displays_list():
displays = db.session.query(Display).order_by(Display.id.asc()).all()
return render_template("admin/displays.html", displays=displays)
@admin_bp.route("/displays/new", methods=["GET", "POST"])
def display_new():
if request.method == "POST":
name = request.form.get("name", "").strip()
public_id = request.form.get("public_id", "").strip()
if not name or not public_id:
flash("Name and public_id are required", "danger")
return render_template("admin/display_form.html", display=None)
if db.session.query(Display).filter_by(public_id=public_id).first():
flash("public_id already exists", "danger")
return render_template("admin/display_form.html", display=None)
d = Display(name=name, public_id=public_id, is_online=False)
db.session.add(d)
db.session.commit()
return redirect(url_for("admin.displays_list"))
return render_template("admin/display_form.html", display=None)
@admin_bp.route("/displays/<int:display_id>/edit", methods=["GET", "POST"])
def display_edit(display_id: int):
d = db.session.get(Display, display_id)
if not d:
return redirect(url_for("admin.displays_list"))
if request.method == "POST":
d.name = request.form.get("name", d.name).strip()
public_id = request.form.get("public_id", d.public_id).strip()
if public_id != d.public_id and db.session.query(Display).filter_by(public_id=public_id).first():
flash("public_id already exists", "danger")
return render_template("admin/display_form.html", display=d)
d.public_id = public_id
db.session.commit()
return redirect(url_for("admin.displays_list"))
return render_template("admin/display_form.html", display=d)
@admin_bp.post("/displays/<int:display_id>/delete")
def display_delete(display_id: int):
d = db.session.get(Display, display_id)
if d:
db.session.delete(d)
db.session.commit()
return redirect(url_for("admin.displays_list"))
@admin_bp.get("/videos")
def videos_list():
videos = db.session.query(Video).order_by(Video.uploaded_at.desc()).all()
return render_template("admin/videos.html", videos=videos)
@admin_bp.route("/videos/upload", methods=["GET", "POST"])
def videos_upload():
if request.method == "POST":
f = request.files.get("file")
if not f or not f.filename:
flash("No file selected", "danger")
return redirect(url_for("admin.videos_upload"))
filename = os.path.basename(f.filename)
# Basic format check
if not (filename.lower().endswith(".mp4") or filename.lower().endswith(".webm")):
flash("Only MP4/WebM supported", "danger")
return redirect(url_for("admin.videos_upload"))
os.makedirs(_media_dir(), exist_ok=True)
dst = os.path.join(_media_dir(), filename)
if os.path.exists(dst):
flash("A file with that name already exists", "danger")
return redirect(url_for("admin.videos_upload"))
f.save(dst)
dur = _probe_duration_seconds(dst)
v = Video(filename=filename, duration=dur)
db.session.add(v)
db.session.commit()
flash(f"Uploaded {filename}", "success")
return redirect(url_for("admin.videos_list"))
return render_template("admin/videos_upload.html")
@admin_bp.get("/idle-image")
def idle_image_page():
current = _get_setting("idle_image")
current_url = url_for("main.media", filename=current) if current else None
return render_template("admin/idle_image.html", current=current, current_url=current_url)
@admin_bp.post("/idle-image/upload")
def idle_image_upload():
f = request.files.get("file")
if not f or not f.filename:
flash("No file selected", "danger")
return redirect(url_for("admin.idle_image_page"))
filename = os.path.basename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in (".png", ".jpg", ".jpeg", ".webp"):
flash("Only PNG/JPG/WebP supported", "danger")
return redirect(url_for("admin.idle_image_page"))
os.makedirs(_media_dir(), exist_ok=True)
# Store as a stable name to avoid needing more DB fields.
dst_name = f"idle_image{ext}"
# If we're changing extension, clean up the old file.
prev = _get_setting("idle_image")
if prev and prev != dst_name:
try:
os.remove(os.path.join(_media_dir(), prev))
except OSError:
pass
dst = os.path.join(_media_dir(), dst_name)
try:
f.save(dst)
except Exception:
flash("Failed to save file", "danger")
return redirect(url_for("admin.idle_image_page"))
_set_setting("idle_image", dst_name)
flash(f"Uploaded idle image ({dst_name})", "success")
return redirect(url_for("admin.idle_image_page"))
@admin_bp.post("/idle-image/clear")
def idle_image_clear():
cur = _get_setting("idle_image")
if cur:
# Delete DB setting
row = db.session.get(AppSetting, "idle_image")
if row:
db.session.delete(row)
db.session.commit()
# Best-effort delete file
try:
os.remove(os.path.join(_media_dir(), cur))
except OSError:
pass
flash("Idle image cleared", "success")
return redirect(url_for("admin.idle_image_page"))
@admin_bp.post("/videos/<int:video_id>/delete")
def videos_delete(video_id: int):
v = db.session.get(Video, video_id)
if v:
try:
os.remove(os.path.join(_media_dir(), v.filename))
except OSError:
pass
db.session.delete(v)
db.session.commit()
return redirect(url_for("admin.videos_list"))
@admin_bp.get("/events")
def events_list():
events = db.session.query(Event).order_by(Event.id.desc()).all()
return render_template("admin/events.html", events=events)
@admin_bp.route("/events/new", methods=["GET", "POST"])
def event_new():
displays = db.session.query(Display).order_by(Display.id.asc()).all()
videos = db.session.query(Video).order_by(Video.filename.asc()).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
udp_port = request.form.get("udp_port", "").strip() or None
udp_payload = request.form.get("udp_payload", "").strip() or None
cooldown = int(request.form.get("cooldown_seconds", "2") or 2)
if not name:
flash("Name required", "danger")
return render_template("admin/event_form.html", event=None, displays=displays, videos=videos)
ev = Event(
name=name,
udp_port=int(udp_port) if udp_port else None,
udp_payload=udp_payload,
cooldown_seconds=cooldown,
)
db.session.add(ev)
db.session.flush()
# Mappings: form fields map_<display_id>
for d in displays:
vid = request.form.get(f"map_{d.id}")
if not vid:
continue
db.session.add(EventDisplayMap(event_id=ev.id, display_id=d.id, video_id=int(vid)))
db.session.commit()
return redirect(url_for("admin.events_list"))
return render_template("admin/event_form.html", event=None, displays=displays, videos=videos)
@admin_bp.route("/events/<int:event_id>/edit", methods=["GET", "POST"])
def event_edit(event_id: int):
ev = db.session.get(Event, event_id)
if not ev:
return redirect(url_for("admin.events_list"))
displays = db.session.query(Display).order_by(Display.id.asc()).all()
videos = db.session.query(Video).order_by(Video.filename.asc()).all()
existing = {m.display_id: m.video_id for m in db.session.query(EventDisplayMap).filter_by(event_id=ev.id).all()}
if request.method == "POST":
ev.name = request.form.get("name", ev.name).strip()
udp_port = request.form.get("udp_port", "").strip() or None
ev.udp_port = int(udp_port) if udp_port else None
ev.udp_payload = request.form.get("udp_payload", "").strip() or None
ev.cooldown_seconds = int(request.form.get("cooldown_seconds", str(ev.cooldown_seconds)) or ev.cooldown_seconds)
# Replace mappings
db.session.query(EventDisplayMap).filter_by(event_id=ev.id).delete()
for d in displays:
vid = request.form.get(f"map_{d.id}")
if not vid:
continue
db.session.add(EventDisplayMap(event_id=ev.id, display_id=d.id, video_id=int(vid)))
db.session.commit()
return redirect(url_for("admin.events_list"))
return render_template(
"admin/event_form.html",
event=ev,
displays=displays,
videos=videos,
existing=existing,
)
@admin_bp.post("/events/<int:event_id>/delete")
def event_delete(event_id: int):
ev = db.session.get(Event, event_id)
if ev:
db.session.delete(ev)
db.session.commit()
return redirect(url_for("admin.events_list"))
@admin_bp.post("/events/<int:event_id>/trigger")
def event_trigger(event_id: int):
ok = trigger_event(event_id=event_id, trigger_source="manual", source_ip=request.remote_addr)
if not ok:
flash("Event not triggered (cooldown or missing mappings)", "warning")
else:
flash("Event triggered", "success")
return redirect(url_for("admin.dashboard"))
@bp.get("/trigger/<int:event_id>")
def http_trigger(event_id: int):
"""Simple URL trigger endpoint for LAN integrations.
Example:
http://server:5000/trigger/1
If you want a tiny bit more safety, run behind a LAN firewall or add a shared secret.
"""
force = request.args.get("force") in ("1", "true", "yes")
ev = db.session.get(Event, event_id)
if not ev:
return {"ok": False, "event_id": event_id, "reason": "not_found", "server_time_ms": server_time_ms()}, 404
# Provide a useful reason if not triggered.
if (not force) and ev.last_triggered and ev.cooldown_seconds:
next_allowed = ev.last_triggered + timedelta(seconds=ev.cooldown_seconds)
if datetime.utcnow() < next_allowed:
return {
"ok": False,
"event_id": event_id,
"reason": "cooldown",
"cooldown_seconds": ev.cooldown_seconds,
"server_time_ms": server_time_ms(),
}
ok = trigger_event(event_id=event_id, trigger_source="manual", source_ip=request.remote_addr, force=force)
return {
"ok": bool(ok),
"event_id": event_id,
"forced": bool(force),
"server_time_ms": server_time_ms(),
}
@bp.get("/trigger_by_name/<name>")
def http_trigger_by_name(name: str):
"""Trigger an event by its name. Intended for very simple LAN automation."""
force = request.args.get("force") in ("1", "true", "yes")
ev = db.session.query(Event).filter(Event.name == name).one_or_none()
if not ev:
return {"ok": False, "reason": "not_found", "name": name, "server_time_ms": server_time_ms()}, 404
ok = trigger_event(event_id=ev.id, trigger_source="manual", source_ip=request.remote_addr, force=force)
return {"ok": bool(ok), "event_id": ev.id, "name": ev.name, "forced": bool(force), "server_time_ms": server_time_ms()}
@bp.get("/api/videos")
def api_videos():
"""List all videos for display-side pre-caching."""
vids = db.session.query(Video).order_by(Video.filename.asc()).all()
return {
"videos": [
{
"id": v.id,
"filename": v.filename,
"url": url_for("main.media", filename=v.filename),
"duration": v.duration,
}
for v in vids
]
}
@admin_bp.get("/logs/events")
def event_logs():
logs = db.session.query(EventLog).order_by(EventLog.triggered_at.desc()).limit(200).all()
return render_template("admin/event_logs.html", logs=logs)
@admin_bp.get("/logs/system")
def system_logs():
# Simple log viewer for logs/system.log
log_path = os.path.abspath(os.path.join(current_app.root_path, "..", "logs", "system.log"))
lines: List[str] = []
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()[-400:]
except OSError:
lines = ["(no logs yet)"]
return render_template("admin/system_logs.html", log_path=log_path, lines=lines)

68
run.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import argparse
import os
from app import create_app
from models import db
from sockets import socketio, start_event_monitor
from udp_listener import start_udp_listener
def _ensure_sqlite_schema(app) -> None:
"""Very small schema migration helper for SQLite dev deployments."""
uri: str = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if not uri.startswith("sqlite:"):
return
import sqlite3
path = uri.split("///", 1)[-1]
try:
conn = sqlite3.connect(path)
# Global settings table (used for e.g. idle fallback image)
conn.execute(
"CREATE TABLE IF NOT EXISTS app_settings ("
"key VARCHAR(64) PRIMARY KEY, "
"value VARCHAR(255) NOT NULL"
")"
)
conn.commit()
finally:
try:
conn.close()
except Exception:
pass
def main() -> None:
parser = argparse.ArgumentParser(description="SyncPlayer server")
parser.add_argument("command", nargs="?", default="run", choices=["run", "init-db"], help="Command")
parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "5000")))
args = parser.parse_args()
app = create_app()
with app.app_context():
db.create_all()
_ensure_sqlite_schema(app)
if args.command == "init-db":
with app.app_context():
db.create_all()
_ensure_sqlite_schema(app)
print("Database initialized")
return
# Start UDP listener in background
start_udp_listener(app)
# Monitor trigger expiry (clears in-memory state when end_time passes)
start_event_monitor(app)
# Prefer gevent on Windows/Python 3.14+. (eventlet is not compatible there)
socketio.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()

205
sockets.py Normal file
View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import logging
import os
import sys
from datetime import datetime
from typing import Any, Dict, Optional
from flask import request
from flask_socketio import SocketIO, emit, join_room
from models import Display, db
from sync import get_current_event, get_trigger_event, mark_trigger_display_ended, server_time_ms
logger = logging.getLogger(__name__)
_env_async_mode = os.environ.get("ASYNC_MODE") # e.g. gevent | eventlet | threading | None(auto)
# IMPORTANT:
# - On Windows + Python 3.14+, eventlet is currently incompatible and may crash if auto-selected.
# - If ASYNC_MODE is not provided, default to 'threading' on Windows/Py3.14+.
if _env_async_mode:
_async_mode = _env_async_mode
else:
if os.name == "nt" or sys.version_info >= (3, 14):
_async_mode = "threading"
else:
_async_mode = None # let Socket.IO auto-select (eventlet/gevent if installed)
socketio = SocketIO(
async_mode=_async_mode or None,
cors_allowed_origins="*",
logger=False,
engineio_logger=False,
ping_interval=5,
ping_timeout=10,
)
_monitor_started = False
def start_event_monitor(app) -> None:
"""Background task that detects when a trigger event expires by time."""
global _monitor_started
if _monitor_started:
return
_monitor_started = True
def _run():
while True:
try:
with app.app_context():
trig = get_trigger_event()
except Exception:
logger.exception("event monitor failed")
socketio.sleep(0.5)
socketio.start_background_task(_run)
# In-memory live stats (for admin dashboard). Keyed by display public_id.
_live: Dict[str, Dict[str, Any]] = {}
def _set_live(public_id: str, **kwargs: Any) -> None:
d = _live.setdefault(public_id, {})
d.update(kwargs)
def get_live_snapshot() -> Dict[str, Dict[str, Any]]:
return {k: dict(v) for k, v in _live.items()}
@socketio.on("connect")
def on_connect():
# Client will send hello with public_id
emit("connected", {"server_time_ms": server_time_ms()})
@socketio.on("hello")
def on_hello(data: Dict[str, Any]):
public_id = (data or {}).get("public_id")
if not public_id:
emit("error", {"error": "missing public_id"})
return
join_room(f"display:{public_id}")
disp = db.session.query(Display).filter_by(public_id=public_id).one_or_none()
if disp is None:
# Auto-create to be resilient on LAN; admin can rename later.
disp = Display(name=f"Display {public_id}", public_id=public_id)
db.session.add(disp)
disp.is_online = True
disp.last_seen = datetime.utcnow()
db.session.commit()
_set_live(public_id, sid=request.sid, user_agent=request.headers.get("User-Agent", ""), last_seen=disp.last_seen.isoformat())
emit("hello_ack", {"server_time_ms": server_time_ms()})
socketio.emit("admin_display_update", {"public_id": public_id, "is_online": True, "last_seen": disp.last_seen.isoformat()}, room="admin")
@socketio.on("heartbeat")
def on_heartbeat(data: Dict[str, Any]):
public_id = (data or {}).get("public_id")
if not public_id:
return
latency_ms = (data or {}).get("latency_ms")
offset_ms = (data or {}).get("offset_ms")
ready = bool((data or {}).get("ready"))
_set_live(public_id, latency_ms=latency_ms, offset_ms=offset_ms, ready=ready, last_seen=datetime.utcnow().isoformat())
disp = db.session.query(Display).filter_by(public_id=public_id).one_or_none()
if disp:
disp.last_seen = datetime.utcnow()
disp.is_online = True
db.session.commit()
@socketio.on("time_sync")
def on_time_sync(data: Dict[str, Any]):
# NTP-style time sync:
# - client sends t1 (client send time)
# - server records t2 (server receive time)
# - server replies with t1, t2, t3 (server send time)
# Client will record t4 on receive.
t1 = float((data or {}).get("t1_ms", 0.0))
t2 = server_time_ms()
# Keep a distinct send timestamp (avoid t2==t3 due to same clock read).
t3 = server_time_ms()
emit("time_sync", {"t1_ms": t1, "t2_ms": t2, "t3_ms": t3})
@socketio.on("request_state")
def on_request_state(data: Dict[str, Any]):
public_id = (data or {}).get("public_id")
if not public_id:
return
st = get_current_event()
if not st:
emit("event_state", {"active": False, "server_time_ms": server_time_ms()})
return
video_url = st.assignments.get(public_id)
if not video_url:
emit("event_state", {"active": False, "server_time_ms": server_time_ms()})
return
emit(
"event_state",
{
"active": True,
"event_id": st.event_id,
"event_name": st.name,
"video_url": video_url,
"start_time_ms": st.start_time_ms,
"server_time_ms": server_time_ms(),
},
)
@socketio.on("playback_ended")
def on_playback_ended(data: Dict[str, Any]):
public_id = (data or {}).get("public_id")
event_id = (data or {}).get("event_id")
if not public_id or not event_id:
return
try:
ended_all = mark_trigger_display_ended(int(event_id), str(public_id))
except Exception:
return
if ended_all:
# Trigger event fully ended; displays will fall back to idle image.
return
@socketio.on("admin_join")
def on_admin_join():
join_room("admin")
emit("admin_snapshot", {"server_time_ms": server_time_ms(), "live": get_live_snapshot()})
@socketio.on("disconnect")
def on_disconnect():
# We don't know which display disconnected unless tracked in _live
sid = request.sid
public_id: Optional[str] = None
for pid, info in list(_live.items()):
if info.get("sid") == sid:
public_id = pid
break
if not public_id:
return
try:
disp = db.session.query(Display).filter_by(public_id=public_id).one_or_none()
if disp:
disp.is_online = False
disp.last_seen = datetime.utcnow()
db.session.commit()
except Exception:
logger.exception("Failed to mark display offline")
_set_live(public_id, is_online=False)
socketio.emit("admin_display_update", {"public_id": public_id, "is_online": False, "last_seen": datetime.utcnow().isoformat()}, room="admin")

33
static/admin.js Normal file
View File

@@ -0,0 +1,33 @@
(() => {
// Optional live updates on dashboard
if (!document.getElementById('tbl-displays')) return;
const socket = io({ transports: ['websocket'], upgrade: false });
socket.on('connect', () => {
socket.emit('admin_join');
});
function updateRow(publicId, patch) {
const row = document.querySelector(`tr[data-public-id="${publicId}"]`);
if (!row) return;
if (patch.is_online !== undefined) row.querySelector('.st').textContent = patch.is_online ? 'online' : 'offline';
if (patch.last_seen) row.querySelector('.ls').textContent = patch.last_seen;
if (patch.latency_ms !== undefined) row.querySelector('.lat').textContent = patch.latency_ms ? `${patch.latency_ms.toFixed(1)}ms` : '';
if (patch.offset_ms !== undefined) row.querySelector('.off').textContent = patch.offset_ms ? `${patch.offset_ms.toFixed(1)}ms` : '';
}
socket.on('admin_snapshot', (msg) => {
const live = msg.live || {};
Object.keys(live).forEach(pid => updateRow(pid, { ...live[pid], is_online: true }));
});
socket.on('admin_display_update', (msg) => {
updateRow(msg.public_id, msg);
});
socket.on('admin_event_triggered', (msg) => {
const el = document.getElementById('active-event');
if (!el) return;
el.innerHTML = `<div><strong>${msg.event_name}</strong> (#${msg.event_id})</div><div>Start: <code>${msg.start_time_ms.toFixed(3)}</code></div>`;
});
})();

536
static/display.js Normal file
View File

@@ -0,0 +1,536 @@
(() => {
const cfgEl = document.getElementById('syncplayer-config');
const cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {};
const publicId = cfg.public_id;
const video = document.getElementById('v');
const dbg = document.getElementById('dbg');
const idleImg = document.getElementById('idle');
const idleUrl = cfg.idle_image_url;
let socket;
let offsetMs = 0; // server_time = client_time + offsetMs
let lastRttMs = 0;
let bestRttMs = Infinity;
let bestOffsetMs = 0;
let syncBurstInProgress = false;
let debugVisible = false;
let lastSocketState = 'init';
let lastError = '';
let lastEventStart = null;
let caching = {
enabled: cfg.cache_enabled !== false,
mode: cfg.cache_mode || 'range', // 'head' | 'range'
rangeBytes: (typeof cfg.cache_range_bytes === 'number') ? cfg.cache_range_bytes : 1048576,
maxConcurrent: 2,
active: 0,
queue: [],
paused: false,
started: false,
done: 0,
total: 0,
lastCacheErr: '',
};
if (!publicId) {
lastError = 'missing public_id (check /display/<public_id> URL)';
}
window.addEventListener('error', (e) => {
// Capture JS errors that would otherwise halt connect() and leave the overlay stuck.
const msg = (e && e.message) ? e.message : String(e || 'window_error');
lastError = `js_error: ${msg}`;
});
window.addEventListener('unhandledrejection', (e) => {
const msg = (e && e.reason && e.reason.message) ? e.reason.message : String((e && e.reason) || 'unhandledrejection');
lastError = `promise_rejection: ${msg}`;
});
// Active event state on client
let active = {
eventId: null,
startTimeMs: null,
videoUrl: null,
isReady: false,
hasStarted: false,
};
function clearActive() {
active.eventId = null;
active.startTimeMs = null;
active.videoUrl = null;
active.isReady = false;
active.hasStarted = false;
}
function showIdleImage(show) {
if (!idleImg) return;
if (!idleUrl) {
// If not configured, keep it hidden.
idleImg.style.display = 'none';
return;
}
if (!idleImg.src) idleImg.src = idleUrl;
idleImg.style.display = show ? 'block' : 'none';
// When idle is visible, keep video element visually hidden (some platforms show last frame otherwise)
if (video) video.style.visibility = show ? 'hidden' : 'visible';
}
function isVideoActivelyPlaying() {
if (!video) return false;
if (video.readyState < 2) return false;
if (video.ended) return false;
if (video.paused) return false;
return true;
}
function nowMs() {
// performance.timeOrigin + performance.now gives a monotonic, high-res clock
const origin = (typeof performance.timeOrigin === 'number') ? performance.timeOrigin : (Date.now() - performance.now());
return origin + performance.now();
}
function serverNowMs() {
return nowMs() + offsetMs;
}
function setOffsetSample(newOffset, rtt) {
// Prefer the lowest-RTT sample (least asymmetric delay).
// Also apply a bit of smoothing to avoid wild jumps.
// rtt can briefly be negative due to clock resolution or scheduling jitter; ignore those.
if (isFinite(rtt) && rtt > 0 && rtt < bestRttMs) {
bestRttMs = rtt;
bestOffsetMs = newOffset;
}
// During a sync burst (connect/event), snap quickly to best sample.
// Otherwise, slowly converge.
const alpha = syncBurstInProgress ? 0.65 : 0.15;
offsetMs = offsetMs * (1 - alpha) + bestOffsetMs * alpha;
lastRttMs = rtt;
}
function setDebug(text) {
if (!debugVisible) return;
dbg.textContent = text;
}
function updateDebugOverlay() {
if (!debugVisible) return;
const conn = socket && socket.connected;
const now = serverNowMs();
const ev = lastEventStart;
const readyState = video ? video.readyState : -1;
const paused = video ? video.paused : true;
const ct = video ? video.currentTime : 0;
setDebug(
`id=${publicId}\n` +
`socket=${conn ? 'connected' : 'disconnected'} state=${lastSocketState}\n` +
`offsetMs=${offsetMs.toFixed(2)} rttMs=${lastRttMs.toFixed(2)} bestRttMs=${isFinite(bestRttMs) ? bestRttMs.toFixed(2) : 'inf'}\n` +
`serverNowMs=${now.toFixed(1)}\n` +
`video: readyState=${readyState} paused=${paused} ct=${ct.toFixed(3)} rate=${(video.playbackRate||1).toFixed(3)}\n` +
(ev ? `eventId=${ev.event_id} start=${ev.start_time_ms.toFixed(1)}\nurl=${ev.video_url}\n` : 'eventId=(none)\n') +
(caching.enabled ? `cache: ${caching.done}/${caching.total} active=${caching.active} paused=${caching.paused}\n` : '') +
(caching.lastCacheErr ? `cacheErr=${caching.lastCacheErr}\n` : '') +
(lastError ? `err=${lastError}\n` : '')
);
}
async function fetchJson(url) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`http ${res.status}`);
return await res.json();
}
async function warmOne(url) {
// Best-effort cache warming. Range requests are widely supported and cheap.
try {
let headers = {};
let method = 'GET';
if (caching.mode === 'head') {
method = 'HEAD';
} else {
headers['Range'] = `bytes=0-${Math.max(0, caching.rangeBytes - 1)}`;
}
const res = await fetch(url, { method, headers, cache: 'force-cache' });
// If the server ignores Range and returns 200, do NOT download the full video.
// Treat it as "warmed enough" (the request itself may prime TCP + DNS).
if (method === 'GET') {
if (res.status === 206) {
try { await res.arrayBuffer(); } catch (e) {}
}
}
if (!res.ok && res.status !== 206 && res.status !== 200) {
throw new Error(`warm status=${res.status}`);
}
} catch (e) {
caching.lastCacheErr = String(e && e.message ? e.message : e);
}
}
function cachePump() {
if (!caching.enabled || caching.paused) return;
while (caching.active < caching.maxConcurrent && caching.queue.length) {
const url = caching.queue.shift();
caching.active++;
warmOne(url).finally(() => {
caching.active--;
caching.done++;
cachePump();
});
}
}
async function startCacheWarmup() {
if (!caching.enabled || caching.started) return;
caching.started = true;
try {
const data = await fetchJson('/api/videos');
const vids = (data && data.videos) ? data.videos : [];
caching.queue = vids.map(v => v.url);
caching.total = caching.queue.length;
cachePump();
} catch (e) {
caching.lastCacheErr = String(e && e.message ? e.message : e);
}
}
function connect() {
if (typeof io !== 'function') {
lastSocketState = 'no_io';
lastError = 'Socket.IO client not loaded (check /socket.io/socket.io.js)';
return;
}
// Prefer websocket, but allow polling fallback (important for some environments / server async modes).
socket = io({ transports: ['websocket', 'polling'], upgrade: true, reconnection: true, reconnectionDelayMax: 1000 });
socket.on('connect_error', (err) => {
lastSocketState = 'connect_error';
lastError = (err && err.message) ? err.message : String(err || 'connect_error');
console.warn('socket connect_error', err);
});
socket.on('error', (err) => {
lastSocketState = 'socket_error';
lastError = (err && err.error) ? err.error : String(err || 'socket_error');
console.warn('socket error', err);
});
socket.on('connect', () => {
lastSocketState = 'connected';
lastError = '';
socket.emit('hello', { public_id: publicId });
startTimeSyncLoop();
startHeartbeatLoop();
// Quickly converge on a good offset right after connecting.
// This reduces the chance we schedule the next event using a bad clock estimate.
timeSyncBurst(8, 120);
socket.emit('request_state', { public_id: publicId });
});
socket.on('hello_ack', () => {
lastSocketState = 'hello_ack';
});
socket.on('disconnect', () => {
lastSocketState = 'disconnected';
active.isReady = false;
// If we lose state, prefer showing idle image over a black frame.
showIdleImage(true);
});
socket.on('time_sync', (msg) => {
// msg: {t1_ms, t2_ms, t3_ms}; we receive at t4
// NTP formulas:
// rtt = (t4 - t1) - (t3 - t2)
// offset = ((t2 - t1) + (t3 - t4)) / 2
const t4 = nowMs();
const t1 = msg.t1_ms;
const t2 = msg.t2_ms;
const t3 = msg.t3_ms;
if (!isFinite(t1) || !isFinite(t2) || !isFinite(t3)) return;
const rtt = (t4 - t1) - (t3 - t2);
const newOffset = ((t2 - t1) + (t3 - t4)) / 2.0;
setOffsetSample(newOffset, rtt);
});
socket.on('event_start', async (msg) => {
caching.paused = true; // don't contend bandwidth at synchronized start
lastEventStart = msg;
// msg: {event_id, video_url, start_time_ms}
active.eventId = msg.event_id;
active.startTimeMs = msg.start_time_ms;
active.videoUrl = msg.video_url;
active.isReady = false;
active.hasStarted = false;
// Any incoming event means something will play; hide idle image.
showIdleImage(false);
// A trigger may arrive while our offset is still converging.
// Do a quick burst to get a good offset before scheduling playback.
await timeSyncBurst(8, 120);
await prepareVideo(msg.video_url);
scheduleStart();
});
socket.on('event_state', async (msg) => {
if (!msg.active) return;
caching.paused = true;
lastEventStart = msg;
active.eventId = msg.event_id;
active.startTimeMs = msg.start_time_ms;
active.videoUrl = msg.video_url;
active.isReady = false;
active.hasStarted = false;
showIdleImage(false);
await prepareVideo(msg.video_url);
// Late join: seek to expected position then play.
const expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0);
try { video.currentTime = expectedSec; } catch (e) {}
// Start immediately (but still apply drift correction)
try { await video.play(); } catch (e) {}
active.hasStarted = true;
});
}
async function prepareVideo(url) {
return new Promise((resolve) => {
// Force new load
video.pause();
video.playbackRate = 1.0;
video.src = url;
video.load();
// We expect video to start soon; make sure it's visible
if (video) video.style.visibility = 'visible';
video.onerror = () => {
const err = video.error;
lastError = err ? `media_error code=${err.code}` : 'media_error';
console.warn('video error', err);
};
const cleanup = () => {
video.removeEventListener('canplaythrough', onReady);
video.removeEventListener('canplay', onReady);
video.removeEventListener('loadeddata', onReady);
};
const onReady = () => {
cleanup();
// Pre-roll: ensure at t=0 and paused
try { video.currentTime = 0; } catch (e) {}
video.pause();
active.isReady = true;
resolve();
};
// Some embedded browsers never fire canplaythrough reliably. Accept earlier readiness signals.
video.addEventListener('canplaythrough', onReady);
video.addEventListener('canplay', onReady);
video.addEventListener('loadeddata', onReady);
// Safety timeout
setTimeout(() => {
if (!active.isReady) onReady();
}, 2000);
});
}
function scheduleStart() {
if (!active.startTimeMs || !active.isReady || active.hasStarted) return;
const tick = async () => {
const delta = active.startTimeMs - serverNowMs();
if (delta <= 0) {
try {
// Always align to the expected timeline position before starting.
// This avoids the “starts at 0 then fixes later” effect.
let expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0);
try { video.currentTime = expectedSec; } catch (e) {}
await video.play();
active.hasStarted = true;
} catch (e) {
// autoplay policies: should work because muted
// retry quickly
setTimeout(tick, 50);
return;
}
return;
}
// Use rAF for sub-frame precision as we approach start
if (delta < 50) {
requestAnimationFrame(tick);
} else {
setTimeout(tick, Math.min(250, delta - 25));
}
};
tick();
}
function startTimeSyncLoop() {
// perform sync every 10 seconds
const doSync = () => {
if (!socket || !socket.connected) return;
const t1 = nowMs();
socket.emit('time_sync', { t1_ms: t1 });
};
doSync();
setInterval(doSync, 10000);
}
async function timeSyncBurst(samples = 6, spacingMs = 120) {
if (!socket || !socket.connected) return;
if (syncBurstInProgress) return;
syncBurstInProgress = true;
bestRttMs = Infinity;
// Fire a small burst of NTP-like pings and use the best RTT sample.
for (let i = 0; i < samples; i++) {
const t1 = nowMs();
socket.emit('time_sync', { t1_ms: t1 });
// eslint-disable-next-line no-await-in-loop
await new Promise(r => setTimeout(r, spacingMs));
}
// Let the last replies land.
await new Promise(r => setTimeout(r, spacingMs));
syncBurstInProgress = false;
}
function startHeartbeatLoop() {
setInterval(() => {
if (!socket || !socket.connected) return;
socket.emit('heartbeat', {
public_id: publicId,
// NTP RTT estimate (ms)
latency_ms: lastRttMs,
offset_ms: offsetMs,
ready: !!active.isReady,
});
}, 2000);
}
function startDriftCorrection() {
setInterval(() => {
if (!active.hasStarted || !active.startTimeMs) return;
if (video.readyState < 2) return;
if (video.paused) return;
let expectedSec = (serverNowMs() - active.startTimeMs) / 1000.0;
const actualSec = video.currentTime;
const driftSec = actualSec - expectedSec; // + means video ahead
const driftMs = driftSec * 1000.0;
// Apply correction
if (Math.abs(driftMs) > 100) {
// Hard correction
try { video.currentTime = Math.max(0, expectedSec); } catch (e) {}
video.playbackRate = 1.0;
} else if (Math.abs(driftMs) > 15) {
// Soft correction via playbackRate
if (driftMs > 0) {
// ahead -> slow down
video.playbackRate = 0.99;
} else {
// behind -> speed up
video.playbackRate = 1.01;
}
} else {
// close enough
video.playbackRate = 1.0;
}
setDebug(
`id=${publicId}\n` +
`serverNowMs=${serverNowMs().toFixed(3)}\n` +
`offsetMs=${offsetMs.toFixed(3)} rttMs=${lastRttMs.toFixed(3)}\n` +
`eventId=${active.eventId} start=${active.startTimeMs}\n` +
`expected=${expectedSec.toFixed(3)} actual=${actualSec.toFixed(3)} driftMs=${driftMs.toFixed(2)}\n` +
`rate=${video.playbackRate.toFixed(3)} state=${video.readyState}`
);
}, 1000);
}
video.addEventListener('ended', () => {
if (socket && socket.connected && active.eventId) {
socket.emit('playback_ended', { public_id: publicId, event_id: active.eventId });
}
// After a trigger ends, show idle until we receive a new event.
clearActive();
showIdleImage(true);
});
// If we ever have no active playback, show idle image.
video.addEventListener('pause', () => {
// If video isn't actively playing and we don't expect immediate play, show idle.
// Don't show idle during pre-roll scheduling (active.videoUrl set but not started yet).
if (!isVideoActivelyPlaying() && !active.hasStarted) return;
if (!isVideoActivelyPlaying()) showIdleImage(true);
});
video.addEventListener('playing', () => {
showIdleImage(false);
});
video.addEventListener('error', () => {
// Show idle on fatal errors to avoid black screen.
showIdleImage(true);
});
// Debug overlay toggle: Ctrl+D
function toggleDebug() {
debugVisible = !debugVisible;
dbg.style.display = debugVisible ? 'block' : 'none';
if (debugVisible) {
setDebug(`id=${publicId}\n(debug enabled)\nconnecting...`);
}
}
setInterval(updateDebugOverlay, 500);
// Many Chromium-based browsers reserve Ctrl+D (bookmark). Use F2 or Ctrl+Shift+D.
window.addEventListener('keydown', (e) => {
if (e.key === 'F2') {
e.preventDefault();
toggleDebug();
return;
}
if (e.ctrlKey && e.shiftKey && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
toggleDebug();
return;
}
});
// Fallback for kiosk remotes: double click anywhere toggles.
window.addEventListener('dblclick', (e) => {
e.preventDefault();
toggleDebug();
});
// Prevent context menu / accidental UI
window.addEventListener('contextmenu', (e) => e.preventDefault());
connect();
startDriftCorrection();
// Start cache warming shortly after page load.
setTimeout(startCacheWarmup, 500);
// Initial state: show idle image until we get an event_state/event_start.
showIdleImage(true);
})();

7
static/vendor/socket.io.min.js generated vendored Normal file
View File

File diff suppressed because one or more lines are too long

85
sync.py Normal file
View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Set
def server_time_ms() -> float:
"""High-resolution server time in milliseconds."""
return time.time_ns() / 1_000_000.0
@dataclass
class ActiveEventState:
event_id: int
name: str
start_time_ms: float
end_time_ms: Optional[float]
# public_id -> video_url
assignments: Dict[str, str]
_lock = threading.Lock()
_trigger: Optional[ActiveEventState] = None
_trigger_ended: Set[str] = set()
def set_trigger_event(state: Optional[ActiveEventState]) -> None:
global _trigger, _trigger_ended
with _lock:
_trigger = state
_trigger_ended = set()
def get_current_event(now_ms: Optional[float] = None) -> Optional[ActiveEventState]:
"""Returns current active trigger event if any."""
global _trigger
with _lock:
if now_ms is None:
now_ms = server_time_ms()
if _trigger is not None and _trigger.end_time_ms is not None and now_ms > _trigger.end_time_ms:
_trigger = None
_trigger_ended.clear()
return _trigger
def get_trigger_event(now_ms: Optional[float] = None) -> Optional[ActiveEventState]:
global _trigger
with _lock:
if _trigger is None:
return None
if now_ms is None:
now_ms = server_time_ms()
if _trigger.end_time_ms is not None and now_ms > _trigger.end_time_ms:
_trigger = None
_trigger_ended.clear()
return None
return _trigger
def mark_trigger_display_ended(event_id: int, public_id: str) -> bool:
"""Marks a display as ended for the current trigger event.
Returns True if this completes the event (all displays ended) and the trigger is cleared.
"""
global _trigger, _trigger_ended
with _lock:
if _trigger is None or _trigger.event_id != event_id:
return False
_trigger_ended.add(public_id)
if len(_trigger_ended) >= len(_trigger.assignments):
_trigger = None
_trigger_ended = set()
return True
return False
def clear_trigger_event() -> None:
global _trigger, _trigger_ended
with _lock:
_trigger = None
_trigger_ended = set()

37
templates/admin/base.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "SyncPlayer Admin" }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">SyncPlayer</a>
<div class="navbar-nav">
<a class="nav-link" href="{{ url_for('admin.displays_list') }}">Displays</a>
<a class="nav-link" href="{{ url_for('admin.videos_list') }}">Videos</a>
<a class="nav-link" href="{{ url_for('admin.idle_image_page') }}">Idle Image</a>
<a class="nav-link" href="{{ url_for('admin.events_list') }}">Events</a>
<a class="nav-link" href="{{ url_for('admin.event_logs') }}">Event Logs</a>
<a class="nav-link" href="{{ url_for('admin.system_logs') }}">System Logs</a>
</div>
</div>
</nav>
<main class="container-fluid p-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="/static/vendor/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,74 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Connected Displays</span>
<small class="text-muted">live</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="tbl-displays">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Public URL</th>
<th>Status</th>
<th>Last seen</th>
<th>Latency</th>
<th>Offset</th>
</tr>
</thead>
<tbody>
{% for d in displays %}
<tr data-public-id="{{ d.public_id }}">
<td>{{ d.id }}</td>
<td>{{ d.name }}</td>
<td><a href="{{ url_for('main.display_page', public_id=d.public_id) }}" target="_blank">/display/{{ d.public_id }}</a></td>
<td class="st">{{ 'online' if d.is_online else 'offline' }}</td>
<td class="ls">{{ d.last_seen or '' }}</td>
<td class="lat"></td>
<td class="off"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">Active Event</div>
<div class="card-body">
<div id="active-event">
{% if active %}
<div>
<strong>{{ active.name }}</strong> (#{{ active.event_id }})
</div>
<div>Start: <code>{{ active.start_time_ms }}</code></div>
{% else %}
<div class="text-muted">No active event</div>
{% endif %}
</div>
</div>
</div>
<div class="card">
<div class="card-header">Manual Trigger</div>
<div class="card-body">
{% for e in events %}
<form method="post" action="{{ url_for('admin.event_trigger', event_id=e.id) }}" class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>{{ e.name }}</strong>
<small class="text-muted">(#{{ e.id }})</small>
</div>
<button class="btn btn-primary btn-sm" type="submit">Trigger</button>
</form>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'admin/base.html' %}
{% block content %}
<h4>{{ 'Edit' if display else 'New' }} Display</h4>
<form method="post" class="mt-3" style="max-width: 560px;">
<div class="mb-3">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{ display.name if display else '' }}" required />
</div>
<div class="mb-3">
<label class="form-label">Public ID</label>
<input class="form-control" name="public_id" value="{{ display.public_id if display else '' }}" required />
<div class="form-text">URL will be /display/&lt;public_id&gt;</div>
</div>
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-link" href="{{ url_for('admin.displays_list') }}">Cancel</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="m-0">Displays</h4>
<a class="btn btn-success" href="{{ url_for('admin.display_new') }}">New Display</a>
</div>
<table class="table table-striped table-sm">
<thead><tr><th>ID</th><th>Name</th><th>Public ID</th><th>URL</th><th>Online</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{% for d in displays %}
<tr>
<td>{{ d.id }}</td>
<td>{{ d.name }}</td>
<td><code>{{ d.public_id }}</code></td>
<td><a href="{{ url_for('main.display_page', public_id=d.public_id) }}" target="_blank">/display/{{ d.public_id }}</a></td>
<td>{{ 'yes' if d.is_online else 'no' }}</td>
<td>{{ d.last_seen or '' }}</td>
<td class="text-end">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin.display_edit', display_id=d.id) }}">Edit</a>
<form method="post" action="{{ url_for('admin.display_delete', display_id=d.id) }}" style="display:inline" onsubmit="return confirm('Delete display?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends 'admin/base.html' %}
{% block content %}
<h4>{{ 'Edit' if event else 'New' }} Event</h4>
<form method="post" class="mt-3">
<div class="row g-3">
<div class="col-lg-5">
<div class="mb-3">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="{{ event.name if event else '' }}" required />
</div>
<div class="mb-3">
<label class="form-label">UDP Port (optional)</label>
<input class="form-control" name="udp_port" value="{{ event.udp_port if event and event.udp_port else '' }}" />
</div>
<div class="mb-3">
<label class="form-label">UDP Payload (optional)</label>
<input class="form-control" name="udp_payload" value="{{ event.udp_payload if event and event.udp_payload else '' }}" />
</div>
<div class="mb-3">
<label class="form-label">Cooldown seconds</label>
<input class="form-control" name="cooldown_seconds" value="{{ event.cooldown_seconds if event else 2 }}" />
</div>
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header">Display → Video Mappings</div>
<div class="card-body">
{% set existing = existing or {} %}
{% for d in displays %}
<div class="row align-items-center mb-2">
<div class="col-4"><strong>{{ d.name }}</strong><br /><small class="text-muted"><code>{{ d.public_id }}</code></small></div>
<div class="col-8">
<select class="form-select form-select-sm" name="map_{{ d.id }}">
<option value="">(none)</option>
{% for v in videos %}
<option value="{{ v.id }}" {% if existing.get(d.id) == v.id %}selected{% endif %}>{{ v.filename }}</option>
{% endfor %}
</select>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" type="submit">Save</button>
<a class="btn btn-link" href="{{ url_for('admin.events_list') }}">Cancel</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'admin/base.html' %}
{% block content %}
<h4>Event Logs</h4>
<table class="table table-sm table-striped mt-3">
<thead><tr><th>When</th><th>Event</th><th>Source</th><th>IP</th></tr></thead>
<tbody>
{% for l in logs %}
<tr>
<td>{{ l.triggered_at }}</td>
<td>{{ l.event.name }} (#{{ l.event_id }})</td>
<td>{{ l.trigger_source }}</td>
<td>{{ l.source_ip or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="m-0">Events</h4>
<a class="btn btn-success" href="{{ url_for('admin.event_new') }}">New Event</a>
</div>
<div class="alert alert-info py-2">
<div><strong>HTTP trigger</strong> (LAN): open one of these URLs</div>
<div class="small">
<code>{{ request.host_url }}trigger/&lt;event_id&gt;</code> or <code>{{ request.host_url }}trigger_by_name/&lt;event_name&gt;</code>
&nbsp; (add <code>?force=1</code> to bypass cooldown)
</div>
</div>
<table class="table table-striped table-sm">
<thead><tr><th>ID</th><th>Name</th><th>UDP</th><th>Cooldown</th><th>Last triggered</th><th></th></tr></thead>
<tbody>
{% for e in events %}
<tr>
<td>{{ e.id }}</td>
<td>
{{ e.name }}
</td>
<td>{% if e.udp_port and e.udp_payload %}<code>{{ e.udp_port }}</code> / <code>{{ e.udp_payload }}</code>{% endif %}</td>
<td>{{ e.cooldown_seconds }}s</td>
<td>{{ e.last_triggered or '' }}</td>
<td class="text-end">
<form method="post" action="{{ url_for('admin.event_trigger', event_id=e.id) }}" style="display:inline">
<button class="btn btn-outline-success btn-sm" type="submit">Trigger</button>
</form>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin.event_edit', event_id=e.id) }}">Edit</a>
<form method="post" action="{{ url_for('admin.event_delete', event_id=e.id) }}" style="display:inline" onsubmit="return confirm('Delete event?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
<div class="small text-muted mt-1">
<div><code>/trigger/{{ e.id }}?force=1</code></div>
<div><code>/trigger_by_name/{{ e.name|urlencode }}?force=1</code></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="m-0">Idle Image</h4>
</div>
<div class="row g-3" style="max-width: 900px;">
<div class="col-md-6">
<div class="card">
<div class="card-header">Current</div>
<div class="card-body">
{% if current_url %}
<div class="mb-2"><code>{{ current }}</code></div>
<div class="border" style="background:#111;">
<img src="{{ current_url }}" alt="Idle image" style="display:block; width:100%; height:320px; object-fit:contain; background:#000;" />
</div>
<form method="post" action="{{ url_for('admin.idle_image_clear') }}" class="mt-3" onsubmit="return confirm('Clear idle image?');">
<button class="btn btn-outline-danger" type="submit">Clear</button>
</form>
{% else %}
<div class="text-muted">No idle image configured.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Upload / Replace</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.idle_image_upload') }}" enctype="multipart/form-data">
<div class="mb-3">
<input class="form-control" type="file" name="file" accept="image/png,image/jpeg,image/webp" required />
<div class="form-text">Shown when no video is actively playing. Recommended: 1920x1080 or 3840x2160.</div>
</div>
<button class="btn btn-primary" type="submit">Upload</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h4 class="m-0">System Logs</h4>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('admin.system_logs') }}">Refresh</a>
</div>
<div class="text-muted mt-2"><small>{{ log_path }}</small></div>
<pre class="mt-3 p-3 bg-dark text-light" style="height:70vh; overflow:auto; white-space:pre-wrap;">{% for line in lines %}{{ line }}{% endfor %}</pre>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'admin/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="m-0">Videos</h4>
<a class="btn btn-success" href="{{ url_for('admin.videos_upload') }}">Upload</a>
</div>
<table class="table table-striped table-sm">
<thead><tr><th>ID</th><th>Filename</th><th>Duration</th><th>Uploaded</th><th>URL</th><th></th></tr></thead>
<tbody>
{% for v in videos %}
<tr>
<td>{{ v.id }}</td>
<td>{{ v.filename }}</td>
<td>{{ ('%.3fs'|format(v.duration)) if v.duration else '' }}</td>
<td>{{ v.uploaded_at }}</td>
<td><a href="{{ url_for('main.media', filename=v.filename) }}" target="_blank">/media/{{ v.filename }}</a></td>
<td class="text-end">
<form method="post" action="{{ url_for('admin.videos_delete', video_id=v.id) }}" style="display:inline" onsubmit="return confirm('Delete video?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'admin/base.html' %}
{% block content %}
<h4>Upload Video</h4>
<form method="post" enctype="multipart/form-data" class="mt-3" style="max-width: 640px;">
<div class="mb-3">
<input class="form-control" type="file" name="file" accept="video/mp4,video/webm" required />
<div class="form-text">MP4 (H264 baseline/main) recommended for SoC players.</div>
</div>
<button class="btn btn-primary" type="submit">Upload</button>
<a class="btn btn-link" href="{{ url_for('admin.videos_list') }}">Cancel</a>
</form>
{% endblock %}

38
templates/display.html Normal file
View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Display {{ public_id }}</title>
<style>
html, body { margin:0; padding:0; background:#000; height:100%; overflow:hidden; }
video { width:100%; height:100%; object-fit:contain; background:#000; }
#idle {
position:fixed;
top:0; left:0;
width:100vw; height:100vh;
object-fit:cover;
background:#000;
display:none;
z-index:1;
}
#v { position:relative; z-index:2; }
#dbg { position:fixed; top:0; left:0; background:rgba(0,0,0,0.6); color:#0f0; font:12px/1.4 monospace; padding:8px; z-index:9999; display:none; white-space:pre; }
</style>
</head>
<body>
<div id="dbg"></div>
<img id="idle" alt="Idle" />
<video id="v" playsinline webkit-playsinline muted autoplay preload="auto"></video>
<script src="/static/vendor/socket.io.min.js"></script>
<script id="syncplayer-config" type="application/json">{{ {
"public_id": public_id,
"idle_image_url": idle_image_url,
"cache_enabled": true,
"cache_mode": "range",
"cache_range_bytes": 1048576
}|tojson }}</script>
<script src="/static/display.js"></script>
</body>
</html>

135
udp_listener.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import logging
import select
import socket
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
from flask import Flask
from models import Event, db
from routes import trigger_event
logger = logging.getLogger("udp")
@dataclass
class _PortBinding:
sock: socket.socket
port: int
class UDPListener:
"""Non-blocking UDP listener that can watch multiple ports using select()."""
def __init__(self, app: Flask, refresh_interval: float = 5.0):
self._app = app
self._refresh_interval = refresh_interval
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._bindings: Dict[int, _PortBinding] = {}
# (port, payload) -> event_id
self._payload_map: Dict[Tuple[int, str], int] = {}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._run, name="udp-listener", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
def _ensure_bindings(self) -> None:
"""Refresh configured UDP ports/payloads from DB and (re)bind sockets if needed."""
with self._app.app_context():
events = (
db.session.query(Event)
.filter(Event.udp_port.isnot(None), Event.udp_payload.isnot(None))
.all()
)
desired_ports = sorted({e.udp_port for e in events if e.udp_port})
desired_map = {(e.udp_port, e.udp_payload): e.id for e in events if e.udp_port and e.udp_payload}
# Update payload mapping
self._payload_map = desired_map
# Close removed ports
for port in list(self._bindings.keys()):
if port not in desired_ports:
try:
self._bindings[port].sock.close()
finally:
del self._bindings[port]
logger.info("Stopped listening UDP port %s", port)
# Bind new ports
for port in desired_ports:
if port in self._bindings:
continue
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
# Allow rebinding quickly
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", int(port)))
self._bindings[port] = _PortBinding(sock=sock, port=int(port))
logger.info("Listening UDP port %s", port)
def _run(self) -> None:
next_refresh = 0.0
while not self._stop.is_set():
now = time.time()
if now >= next_refresh:
try:
self._ensure_bindings()
except Exception:
logger.exception("Failed to refresh UDP bindings")
next_refresh = now + self._refresh_interval
socks = [b.sock for b in self._bindings.values()]
if not socks:
time.sleep(0.25)
continue
try:
readable, _, _ = select.select(socks, [], [], 0.25)
except Exception:
logger.exception("UDP select failed")
time.sleep(0.25)
continue
for rsock in readable:
try:
data, addr = rsock.recvfrom(4096)
src_ip, src_port = addr[0], addr[1]
payload = data.decode("utf-8", errors="replace").strip()
port = rsock.getsockname()[1]
logger.info("UDP packet port=%s from=%s:%s payload=%r", port, src_ip, src_port, payload)
event_id = self._payload_map.get((port, payload))
if not event_id:
continue
# Trigger the event (runs cooldown + logging in trigger_event)
with self._app.app_context():
trigger_event(event_id=event_id, trigger_source="udp", source_ip=src_ip)
except Exception:
logger.exception("UDP recv/handle failed")
_listener: Optional[UDPListener] = None
def start_udp_listener(app: Flask) -> UDPListener:
global _listener
if _listener is None:
_listener = UDPListener(app)
_listener.start()
return _listener