Initial commit
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
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.
|
||||
66
app/__init__.py
Normal file
66
app/__init__.py
Normal 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
31
config.py
Normal 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
63
event_manager.py
Normal 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
BIN
media/idle_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
87
models.py
Normal file
87
models.py
Normal 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
141
package-lock.json
generated
Normal 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
16
package.json
Normal 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
12
requirements.txt
Normal 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
498
routes.py
Normal 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
68
run.py
Normal 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
205
sockets.py
Normal 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
33
static/admin.js
Normal 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
536
static/display.js
Normal 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
7
static/vendor/socket.io.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
85
sync.py
Normal file
85
sync.py
Normal 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
37
templates/admin/base.html
Normal 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>
|
||||
74
templates/admin/dashboard.html
Normal file
74
templates/admin/dashboard.html
Normal 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 %}
|
||||
17
templates/admin/display_form.html
Normal file
17
templates/admin/display_form.html
Normal 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/<public_id></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 %}
|
||||
28
templates/admin/displays.html
Normal file
28
templates/admin/displays.html
Normal 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 %}
|
||||
53
templates/admin/event_form.html
Normal file
53
templates/admin/event_form.html
Normal 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 %}
|
||||
17
templates/admin/event_logs.html
Normal file
17
templates/admin/event_logs.html
Normal 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 %}
|
||||
45
templates/admin/events.html
Normal file
45
templates/admin/events.html
Normal 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/<event_id></code> or <code>{{ request.host_url }}trigger_by_name/<event_name></code>
|
||||
(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 %}
|
||||
42
templates/admin/idle_image.html
Normal file
42
templates/admin/idle_image.html
Normal 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 %}
|
||||
9
templates/admin/system_logs.html
Normal file
9
templates/admin/system_logs.html
Normal 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 %}
|
||||
26
templates/admin/videos.html
Normal file
26
templates/admin/videos.html
Normal 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 %}
|
||||
12
templates/admin/videos_upload.html
Normal file
12
templates/admin/videos_upload.html
Normal 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
38
templates/display.html
Normal 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
135
udp_listener.py
Normal 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
|
||||
Reference in New Issue
Block a user