Initial commit
This commit is contained in:
commit
3dc3bc75ca
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
283
app.py
Normal file
283
app.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
try:
|
||||||
|
import tomli as tomllib
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
print("tomli/tomllib not found. Install with: pip install tomli")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomli_w
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
tomli_w = None
|
||||||
|
|
||||||
|
from flask import Flask, render_template, jsonify, Response, request
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
|
||||||
|
|
||||||
|
with open(CONFIG_PATH, "rb") as f:
|
||||||
|
config = tomllib.load(f)
|
||||||
|
|
||||||
|
PORT = 50743
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
scores = {"left": 0, "right": 0}
|
||||||
|
|
||||||
|
subscribers = []
|
||||||
|
sub_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_ip():
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("10.255.255.255", 1))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except:
|
||||||
|
ip = "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def udp_beacon():
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = json.dumps({
|
||||||
|
"display_name": config.get("display_name", ""),
|
||||||
|
"ip": get_local_ip(),
|
||||||
|
"port": PORT,
|
||||||
|
})
|
||||||
|
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=udp_beacon, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast(data):
|
||||||
|
dead = []
|
||||||
|
with sub_lock:
|
||||||
|
for q in subscribers:
|
||||||
|
try:
|
||||||
|
q.put_nowait(json.dumps(data))
|
||||||
|
except:
|
||||||
|
dead.append(q)
|
||||||
|
for q in dead:
|
||||||
|
subscribers.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
def cors(resp):
|
||||||
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def save_config():
|
||||||
|
if tomli_w is None:
|
||||||
|
return
|
||||||
|
with open(CONFIG_PATH, "wb") as f:
|
||||||
|
tomli_w.dump(config, f)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
display_id=config["display_id"],
|
||||||
|
header=config.get("header", ""),
|
||||||
|
header_font_size=config.get("header_font_size", 0),
|
||||||
|
content_padding_top=config.get("content_padding_top", 0),
|
||||||
|
content_gap=config.get("content_gap", 1),
|
||||||
|
left_name=config["left"]["name"],
|
||||||
|
left_color=config["left"]["color"],
|
||||||
|
left_subtitle=config["left"].get("subtitle", ""),
|
||||||
|
left_name_font_size=config["left"].get("name_font_size", 0),
|
||||||
|
left_subtitle_font_size=config["left"].get("subtitle_font_size", 0),
|
||||||
|
left_score_font_size=config["left"].get("score_font_size", 0),
|
||||||
|
right_name=config["right"]["name"],
|
||||||
|
right_color=config["right"]["color"],
|
||||||
|
right_subtitle=config["right"].get("subtitle", ""),
|
||||||
|
right_name_font_size=config["right"].get("name_font_size", 0),
|
||||||
|
right_subtitle_font_size=config["right"].get("subtitle_font_size", 0),
|
||||||
|
right_score_font_size=config["right"].get("score_font_size", 0),
|
||||||
|
left_score=scores["left"],
|
||||||
|
right_score=scores["right"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/events")
|
||||||
|
def sse():
|
||||||
|
def stream():
|
||||||
|
q = queue.Queue()
|
||||||
|
with sub_lock:
|
||||||
|
subscribers.append(q)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = q.get()
|
||||||
|
yield f"data: {data}\n\n"
|
||||||
|
except GeneratorExit:
|
||||||
|
with sub_lock:
|
||||||
|
if q in subscribers:
|
||||||
|
subscribers.remove(q)
|
||||||
|
|
||||||
|
return Response(stream(), mimetype="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/status")
|
||||||
|
def api_status():
|
||||||
|
return cors(jsonify({
|
||||||
|
"display_id": config["display_id"],
|
||||||
|
"display_name": config.get("display_name", ""),
|
||||||
|
"header": config.get("header", ""),
|
||||||
|
"header_font_size": config.get("header_font_size", 0),
|
||||||
|
"content_padding_top": config.get("content_padding_top", 0),
|
||||||
|
"content_gap": config.get("content_gap", 1),
|
||||||
|
"ip": get_local_ip(),
|
||||||
|
"port": PORT,
|
||||||
|
"scores": dict(scores),
|
||||||
|
"config": {
|
||||||
|
"left": {
|
||||||
|
"name": config["left"]["name"],
|
||||||
|
"subtitle": config["left"].get("subtitle", ""),
|
||||||
|
"color": config["left"]["color"],
|
||||||
|
"name_font_size": config["left"].get("name_font_size", 0),
|
||||||
|
"subtitle_font_size": config["left"].get("subtitle_font_size", 0),
|
||||||
|
"score_font_size": config["left"].get("score_font_size", 0),
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"name": config["right"]["name"],
|
||||||
|
"subtitle": config["right"].get("subtitle", ""),
|
||||||
|
"color": config["right"]["color"],
|
||||||
|
"name_font_size": config["right"].get("name_font_size", 0),
|
||||||
|
"subtitle_font_size": config["right"].get("subtitle_font_size", 0),
|
||||||
|
"score_font_size": config["right"].get("score_font_size", 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/score/<side>", methods=["PUT", "OPTIONS"])
|
||||||
|
def api_set_score(side):
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
if side not in scores:
|
||||||
|
return cors((jsonify({"error": "invalid side"}), 400))
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
value = data.get("value")
|
||||||
|
if value is None or not isinstance(value, int) or value < 0:
|
||||||
|
return cors((jsonify({"error": "value must be a non-negative integer"}), 400))
|
||||||
|
scores[side] = value
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return cors(jsonify(scores))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/score/<side>/reset", methods=["POST", "OPTIONS"])
|
||||||
|
def api_reset_side(side):
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
if side in scores:
|
||||||
|
scores[side] = 0
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return cors(jsonify(scores))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/score/reset", methods=["POST", "OPTIONS"])
|
||||||
|
def api_reset_all():
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
scores["left"] = 0
|
||||||
|
scores["right"] = 0
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": None})
|
||||||
|
return cors(jsonify(scores))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/score/<side>/increment", methods=["POST", "OPTIONS"])
|
||||||
|
def api_increment(side):
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
if side in scores:
|
||||||
|
scores[side] += 1
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return cors(jsonify(scores))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/score/<side>/decrement", methods=["POST", "OPTIONS"])
|
||||||
|
def api_decrement(side):
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
if side in scores and scores[side] > 0:
|
||||||
|
scores[side] -= 1
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return cors(jsonify(scores))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/config", methods=["PUT", "OPTIONS"])
|
||||||
|
def api_update_config():
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return cors(jsonify({}))
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if "display_name" in data:
|
||||||
|
config["display_name"] = data["display_name"]
|
||||||
|
if "header" in data:
|
||||||
|
config["header"] = data["header"]
|
||||||
|
if "header_font_size" in data:
|
||||||
|
config["header_font_size"] = data["header_font_size"]
|
||||||
|
if "content_padding_top" in data:
|
||||||
|
config["content_padding_top"] = data["content_padding_top"]
|
||||||
|
if "content_gap" in data:
|
||||||
|
config["content_gap"] = data["content_gap"]
|
||||||
|
for side in ("left", "right"):
|
||||||
|
if side in data:
|
||||||
|
for key in ("name", "subtitle", "color", "name_font_size", "subtitle_font_size", "score_font_size"):
|
||||||
|
if key in data[side]:
|
||||||
|
config[side][key] = data[side][key]
|
||||||
|
save_config()
|
||||||
|
broadcast({"type": "config", "config": data})
|
||||||
|
return cors(jsonify({"status": "ok"}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/increment/<side>", methods=["GET", "POST"])
|
||||||
|
def increment(side):
|
||||||
|
if side in scores:
|
||||||
|
scores[side] += 1
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return jsonify(scores)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reset", methods=["GET", "POST"])
|
||||||
|
def reset():
|
||||||
|
scores["left"] = 0
|
||||||
|
scores["right"] = 0
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": None})
|
||||||
|
return jsonify(scores)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reset/<side>", methods=["GET", "POST"])
|
||||||
|
def reset_side(side):
|
||||||
|
if side in scores:
|
||||||
|
scores[side] = 0
|
||||||
|
broadcast({"type": "score", "scores": dict(scores), "side": side})
|
||||||
|
return jsonify(scores)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f" CuteBoard Display #{config['display_id']}")
|
||||||
|
print(f" {config['left']['name']} ({config['left']['color']}) vs {config['right']['name']} ({config['right']['color']})")
|
||||||
|
print(f" URL: http://0.0.0.0:{PORT}")
|
||||||
|
app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)
|
||||||
22
config.toml
Normal file
22
config.toml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
display_id = 1
|
||||||
|
display_name = "Test"
|
||||||
|
header = "Grand Finals"
|
||||||
|
header_font_size = 4
|
||||||
|
content_padding_top = 4
|
||||||
|
content_gap = 1
|
||||||
|
|
||||||
|
[left]
|
||||||
|
name = "Tim"
|
||||||
|
subtitle = "Test 456"
|
||||||
|
color = "#dc2626"
|
||||||
|
name_font_size = 3.75
|
||||||
|
subtitle_font_size = 1.25
|
||||||
|
score_font_size = 30
|
||||||
|
|
||||||
|
[right]
|
||||||
|
name = "Nick"
|
||||||
|
subtitle = "Test 123"
|
||||||
|
color = "#1a5fb4"
|
||||||
|
name_font_size = 3.75
|
||||||
|
subtitle_font_size = 1.25
|
||||||
|
score_font_size = 30
|
||||||
228
controller/app.py
Normal file
228
controller/app.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import concurrent.futures
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
|
CTRL_PORT = 50742
|
||||||
|
DISCOVERY_PORT = 50744
|
||||||
|
BOARD_PORT = 50743
|
||||||
|
SCAN_TIMEOUT = 1.5
|
||||||
|
POLL_INTERVAL = 3
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
boards = OrderedDict()
|
||||||
|
boards_lock = threading.Lock()
|
||||||
|
offline_threshold = POLL_INTERVAL * 3
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_ip():
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("10.255.255.255", 1))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except:
|
||||||
|
ip = "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def get_subnet():
|
||||||
|
ip = get_local_ip()
|
||||||
|
parts = ip.split(".")
|
||||||
|
return ".".join(parts[:3])
|
||||||
|
|
||||||
|
|
||||||
|
def check_port(ip, port, timeout=SCAN_TIMEOUT):
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
s.connect((ip, port))
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def udp_listener():
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.settimeout(1)
|
||||||
|
sock.bind(("0.0.0.0", DISCOVERY_PORT))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data, addr = sock.recvfrom(2048)
|
||||||
|
msg = json.loads(data)
|
||||||
|
ip = msg.get("ip", addr[0])
|
||||||
|
port = msg.get("port", BOARD_PORT)
|
||||||
|
with boards_lock:
|
||||||
|
if ip not in boards:
|
||||||
|
print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}")
|
||||||
|
boards[ip] = {
|
||||||
|
"display_name": msg.get("display_name", ""),
|
||||||
|
"ip": ip,
|
||||||
|
"port": port,
|
||||||
|
"last_seen": time.time(),
|
||||||
|
"online": True,
|
||||||
|
"status": boards.get(ip, {}).get("status"),
|
||||||
|
}
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def scan_subnet():
|
||||||
|
subnet = get_subnet()
|
||||||
|
found = []
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
|
||||||
|
futures = {pool.submit(check_port, f"{subnet}.{i}", BOARD_PORT): i for i in range(1, 255)}
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
ip = future.result()
|
||||||
|
if ip:
|
||||||
|
found.append(ip)
|
||||||
|
with boards_lock:
|
||||||
|
for ip in found:
|
||||||
|
if ip not in boards:
|
||||||
|
boards[ip] = {
|
||||||
|
"display_name": f"Board ({ip})",
|
||||||
|
"ip": ip,
|
||||||
|
"port": BOARD_PORT,
|
||||||
|
"last_seen": time.time(),
|
||||||
|
"online": True,
|
||||||
|
"status": None,
|
||||||
|
}
|
||||||
|
print(f" Scanned board: {ip}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_board_cache(ip, port):
|
||||||
|
try:
|
||||||
|
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
with boards_lock:
|
||||||
|
if ip in boards:
|
||||||
|
boards[ip]["status"] = data
|
||||||
|
boards[ip]["online"] = True
|
||||||
|
boards[ip]["last_seen"] = time.time()
|
||||||
|
if data.get("display_name"):
|
||||||
|
boards[ip]["display_name"] = data["display_name"]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def poll_boards():
|
||||||
|
while True:
|
||||||
|
with boards_lock:
|
||||||
|
snapshot = list(boards.items())
|
||||||
|
for ip, board in snapshot:
|
||||||
|
try:
|
||||||
|
r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
with boards_lock:
|
||||||
|
if ip in boards:
|
||||||
|
boards[ip]["status"] = data
|
||||||
|
boards[ip]["online"] = True
|
||||||
|
boards[ip]["last_seen"] = time.time()
|
||||||
|
if data.get("display_name"):
|
||||||
|
boards[ip]["display_name"] = data["display_name"]
|
||||||
|
else:
|
||||||
|
with boards_lock:
|
||||||
|
if ip in boards:
|
||||||
|
boards[ip]["online"] = False
|
||||||
|
except:
|
||||||
|
with boards_lock:
|
||||||
|
if ip in boards:
|
||||||
|
boards[ip]["online"] = False
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=udp_listener, daemon=True).start()
|
||||||
|
threading.Thread(target=poll_boards, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/boards")
|
||||||
|
def api_boards():
|
||||||
|
now = time.time()
|
||||||
|
with boards_lock:
|
||||||
|
result = []
|
||||||
|
for ip, board in list(boards.items()):
|
||||||
|
stale = (now - board["last_seen"]) > offline_threshold
|
||||||
|
status = board.get("status") or {}
|
||||||
|
scores = status.get("scores") or {}
|
||||||
|
config_data = status.get("config") or {}
|
||||||
|
result.append({
|
||||||
|
"display_name": board["display_name"],
|
||||||
|
"ip": ip,
|
||||||
|
"port": board["port"],
|
||||||
|
"online": board["online"] and not stale,
|
||||||
|
"scores": scores,
|
||||||
|
"config": config_data,
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/scan", methods=["POST"])
|
||||||
|
def api_scan():
|
||||||
|
def do_scan():
|
||||||
|
found = scan_subnet()
|
||||||
|
print(f" Scan complete: {len(found)} boards found")
|
||||||
|
threading.Thread(target=do_scan, daemon=True).start()
|
||||||
|
return jsonify({"status": "scanning"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/boards/<ip>/<path:endpoint>", methods=["GET", "POST", "PUT", "OPTIONS"])
|
||||||
|
def api_proxy(ip, endpoint):
|
||||||
|
port = BOARD_PORT
|
||||||
|
with boards_lock:
|
||||||
|
if ip in boards:
|
||||||
|
port = boards[ip]["port"]
|
||||||
|
url = f"http://{ip}:{port}/{endpoint}"
|
||||||
|
method = request.method
|
||||||
|
headers = {"Content-Type": "application/json"} if request.is_json else {}
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
r = requests.get(url, timeout=5, headers=headers)
|
||||||
|
elif method == "POST":
|
||||||
|
r = requests.post(url, timeout=5, json=request.get_json(silent=True), headers=headers)
|
||||||
|
elif method == "PUT":
|
||||||
|
r = requests.put(url, timeout=5, json=request.get_json(silent=True), headers=headers)
|
||||||
|
else:
|
||||||
|
r = requests.options(url, timeout=5, headers=headers)
|
||||||
|
resp = app.make_response((r.content, r.status_code, dict(r.headers)))
|
||||||
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
# Immediately refresh cached status after any score-modifying action
|
||||||
|
if method in ("POST", "PUT"):
|
||||||
|
threading.Thread(target=refresh_board_cache, args=(ip, port), daemon=True).start()
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_cors(resp):
|
||||||
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f" CuteBoard Controller")
|
||||||
|
print(f" URL: http://0.0.0.0:{CTRL_PORT}")
|
||||||
|
app.run(host="0.0.0.0", port=CTRL_PORT, debug=False, threaded=True)
|
||||||
2
controller/requirements.txt
Normal file
2
controller/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.31
|
||||||
271
controller/static/js/main.js
Normal file
271
controller/static/js/main.js
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var grid = document.getElementById('board-grid');
|
||||||
|
var emptyMsg = document.getElementById('empty-msg');
|
||||||
|
var modalOverlay = document.getElementById('config-modal');
|
||||||
|
var configForm = document.getElementById('config-form');
|
||||||
|
var editingBoard = null;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (typeof s !== 'string') return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(s));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fetch boards ----
|
||||||
|
function fetchBoards() {
|
||||||
|
fetch('/api/boards')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (boards) { renderBoards(boards); })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Render board cards ----
|
||||||
|
function renderBoards(boards) {
|
||||||
|
if (boards.length === 0) {
|
||||||
|
emptyMsg.style.display = 'block';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
grid.appendChild(emptyMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
boards.forEach(function (board) {
|
||||||
|
var statusClass = board.online ? 'online' : 'offline';
|
||||||
|
var badgeClass = board.online ? 'badge-online' : 'badge-offline';
|
||||||
|
var left = board.config && board.config.left || {};
|
||||||
|
var right = board.config && board.config.right || {};
|
||||||
|
var scores = board.scores || {};
|
||||||
|
var leftScore = scores.left != null ? scores.left : '-';
|
||||||
|
var rightScore = scores.right != null ? scores.right : '-';
|
||||||
|
var leftColor = left.color || '#dc2626';
|
||||||
|
var rightColor = right.color || '#2563eb';
|
||||||
|
|
||||||
|
html +=
|
||||||
|
'<div class="card ' + statusClass + ' p-4" data-board="' + board.ip + '">' +
|
||||||
|
'<div class="flex items-center justify-between mb-3">' +
|
||||||
|
'<div class="flex items-center gap-2">' +
|
||||||
|
'<span class="' + badgeClass + '"></span>' +
|
||||||
|
'<span class="font-semibold text-base">' + esc(board.display_name || board.ip) + '</span>' +
|
||||||
|
'<span class="text-xs text-slate-600">' + board.ip + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="flex gap-4">' +
|
||||||
|
// Left team
|
||||||
|
'<div class="flex-1 bg-black/20 rounded-lg p-3 text-center team-column"' +
|
||||||
|
' style="border-left: 3px solid ' + leftColor + ';" data-side="left">' +
|
||||||
|
'<div class="team-name text-sm mb-1" style="color:' + leftColor + ';">' + esc(left.name || '???') + '</div>' +
|
||||||
|
'<div class="score text-3xl font-black mb-2" style="color:' + leftColor + ';">' + leftScore + '</div>' +
|
||||||
|
'<div class="flex gap-1 justify-center">' +
|
||||||
|
'<button class="btn btn-dec" data-side="left">−1</button>' +
|
||||||
|
'<button class="btn btn-inc" data-side="left">+1</button>' +
|
||||||
|
'<button class="btn btn-reset" data-side="left">↺</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
// Right team
|
||||||
|
'<div class="flex-1 bg-black/20 rounded-lg p-3 text-center team-column"' +
|
||||||
|
' style="border-right: 3px solid ' + rightColor + ';" data-side="right">' +
|
||||||
|
'<div class="team-name text-sm mb-1" style="color:' + rightColor + ';">' + esc(right.name || '???') + '</div>' +
|
||||||
|
'<div class="score text-3xl font-black mb-2" style="color:' + rightColor + ';">' + rightScore + '</div>' +
|
||||||
|
'<div class="flex gap-1 justify-center">' +
|
||||||
|
'<button class="btn btn-dec" data-side="right">−1</button>' +
|
||||||
|
'<button class="btn btn-inc" data-side="right">+1</button>' +
|
||||||
|
'<button class="btn btn-reset" data-side="right">↺</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="flex gap-2 mt-3">' +
|
||||||
|
'<button class="btn btn-edit flex-1" onclick="window.openConfig(\'' + board.ip + '\')">⚙ Edit Config</button>' +
|
||||||
|
'<button class="btn btn-all-reset flex-1">⟳ Reset All</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Optimistic score updates ----
|
||||||
|
function updateScoreLocal(card, side, delta) {
|
||||||
|
var col = card.querySelector('.team-column[data-side="' + side + '"]');
|
||||||
|
if (!col) return;
|
||||||
|
var el = col.querySelector('.score');
|
||||||
|
if (!el) return;
|
||||||
|
var cur = parseInt(el.textContent);
|
||||||
|
if (isNaN(cur)) return;
|
||||||
|
var next = Math.max(0, cur + delta);
|
||||||
|
el.textContent = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScoreLocal(card, side, value) {
|
||||||
|
var col = card.querySelector('.team-column[data-side="' + side + '"]');
|
||||||
|
if (!col) return;
|
||||||
|
var el = col.querySelector('.score');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Re-poll single board directly (bypasses controller cache) ----
|
||||||
|
function refetchBoard(ip) {
|
||||||
|
fetch('/api/boards/' + ip + '/api/status')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var card = document.querySelector('.card[data-board="' + ip + '"]');
|
||||||
|
if (!card) return;
|
||||||
|
var scores = data.scores || {};
|
||||||
|
var leftEl = card.querySelector('.team-column[data-side="left"] .score');
|
||||||
|
var rightEl = card.querySelector('.team-column[data-side="right"] .score');
|
||||||
|
if (leftEl) leftEl.textContent = scores.left != null ? scores.left : '-';
|
||||||
|
if (rightEl) rightEl.textContent = scores.right != null ? scores.right : '-';
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions (optimistic + re-poll board directly) ----
|
||||||
|
function increment(ip, side, card) {
|
||||||
|
updateScoreLocal(card, side, 1);
|
||||||
|
fetch('/api/boards/' + ip + '/api/score/' + side + '/increment', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement(ip, side, card) {
|
||||||
|
updateScoreLocal(card, side, -1);
|
||||||
|
fetch('/api/boards/' + ip + '/api/score/' + side + '/decrement', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSide(ip, side, card) {
|
||||||
|
setScoreLocal(card, side, 0);
|
||||||
|
fetch('/api/boards/' + ip + '/api/score/' + side + '/reset', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll(ip, card) {
|
||||||
|
setScoreLocal(card, 'left', 0);
|
||||||
|
setScoreLocal(card, 'right', 0);
|
||||||
|
fetch('/api/boards/' + ip + '/api/score/reset', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanNetwork() {
|
||||||
|
fetch('/api/scan', { method: 'POST' }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Event delegation ----
|
||||||
|
grid.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var card = btn.closest('.card');
|
||||||
|
if (!card) return;
|
||||||
|
var ip = card.dataset.board;
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
var side = btn.dataset.side;
|
||||||
|
|
||||||
|
if (btn.classList.contains('btn-inc') && side) {
|
||||||
|
increment(ip, side, card);
|
||||||
|
} else if (btn.classList.contains('btn-dec') && side) {
|
||||||
|
decrement(ip, side, card);
|
||||||
|
} else if (btn.classList.contains('btn-reset') && side) {
|
||||||
|
resetSide(ip, side, card);
|
||||||
|
} else if (btn.classList.contains('btn-all-reset')) {
|
||||||
|
resetAll(ip, card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Edit Config Modal ----
|
||||||
|
window.openConfig = function (ip) {
|
||||||
|
editingBoard = ip;
|
||||||
|
document.getElementById('modal-title').textContent = 'Edit Config — ' + ip;
|
||||||
|
|
||||||
|
fetch('/api/boards/' + ip + '/api/status')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var cfg = data.config || {};
|
||||||
|
document.getElementById('edit-display-name').value = data.display_name || '';
|
||||||
|
document.getElementById('edit-header').value = data.header || '';
|
||||||
|
document.getElementById('edit-header-font-size').value = data.header_font_size || '';
|
||||||
|
document.getElementById('edit-content-padding-top').value = data.content_padding_top || '';
|
||||||
|
document.getElementById('edit-content-gap').value = data.content_gap || '';
|
||||||
|
document.getElementById('edit-left-name').value = (cfg.left && cfg.left.name) || '';
|
||||||
|
document.getElementById('edit-left-subtitle').value = (cfg.left && cfg.left.subtitle) || '';
|
||||||
|
document.getElementById('edit-left-color').value = (cfg.left && cfg.left.color) || '#dc2626';
|
||||||
|
document.getElementById('edit-left-font-size').value = (cfg.left && cfg.left.name_font_size) || '';
|
||||||
|
document.getElementById('edit-left-subtitle-font-size').value = (cfg.left && cfg.left.subtitle_font_size) || '';
|
||||||
|
document.getElementById('edit-left-score-font-size').value = (cfg.left && cfg.left.score_font_size) || '';
|
||||||
|
document.getElementById('edit-right-name').value = (cfg.right && cfg.right.name) || '';
|
||||||
|
document.getElementById('edit-right-subtitle').value = (cfg.right && cfg.right.subtitle) || '';
|
||||||
|
document.getElementById('edit-right-color').value = (cfg.right && cfg.right.color) || '#2563eb';
|
||||||
|
document.getElementById('edit-right-font-size').value = (cfg.right && cfg.right.name_font_size) || '';
|
||||||
|
document.getElementById('edit-right-subtitle-font-size').value = (cfg.right && cfg.right.subtitle_font_size) || '';
|
||||||
|
document.getElementById('edit-right-score-font-size').value = (cfg.right && cfg.right.score_font_size) || '';
|
||||||
|
modalOverlay.classList.add('open');
|
||||||
|
})
|
||||||
|
.catch(function () { alert('Could not fetch board config'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOverlay.classList.remove('open');
|
||||||
|
editingBoard = null;
|
||||||
|
}
|
||||||
|
window.closeModal = closeModal;
|
||||||
|
|
||||||
|
configForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingBoard) return;
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
display_name: document.getElementById('edit-display-name').value,
|
||||||
|
header: document.getElementById('edit-header').value,
|
||||||
|
header_font_size: parseFloat(document.getElementById('edit-header-font-size').value) || 0,
|
||||||
|
content_padding_top: parseFloat(document.getElementById('edit-content-padding-top').value) || 0,
|
||||||
|
content_gap: parseFloat(document.getElementById('edit-content-gap').value) || 1,
|
||||||
|
left: {
|
||||||
|
name: document.getElementById('edit-left-name').value,
|
||||||
|
subtitle: document.getElementById('edit-left-subtitle').value,
|
||||||
|
color: document.getElementById('edit-left-color').value,
|
||||||
|
name_font_size: parseFloat(document.getElementById('edit-left-font-size').value) || 0,
|
||||||
|
subtitle_font_size: parseFloat(document.getElementById('edit-left-subtitle-font-size').value) || 0,
|
||||||
|
score_font_size: parseFloat(document.getElementById('edit-left-score-font-size').value) || 0,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
name: document.getElementById('edit-right-name').value,
|
||||||
|
subtitle: document.getElementById('edit-right-subtitle').value,
|
||||||
|
color: document.getElementById('edit-right-color').value,
|
||||||
|
name_font_size: parseFloat(document.getElementById('edit-right-font-size').value) || 0,
|
||||||
|
subtitle_font_size: parseFloat(document.getElementById('edit-right-subtitle-font-size').value) || 0,
|
||||||
|
score_font_size: parseFloat(document.getElementById('edit-right-score-font-size').value) || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var saveBtn = configForm.querySelector('.btn-save');
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/api/boards/' + editingBoard + '/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) closeModal();
|
||||||
|
else alert('Failed to save config');
|
||||||
|
})
|
||||||
|
.catch(function () { alert('Failed to save config'); })
|
||||||
|
.finally(function () {
|
||||||
|
saveBtn.textContent = 'Save Config';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modalOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === modalOverlay) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Polling ----
|
||||||
|
fetchBoards();
|
||||||
|
setInterval(fetchBoards, 3000);
|
||||||
|
|
||||||
|
window.scanNetwork = scanNetwork;
|
||||||
|
|
||||||
|
})();
|
||||||
129
controller/templates/index.html
Normal file
129
controller/templates/index.html
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CuteBoard Controller</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
* { -webkit-user-select: none; user-select: none; }
|
||||||
|
body { background: #0a0a0f; color: #e2e8f0; font-family: system-ui, sans-serif; }
|
||||||
|
.team-name { font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||||
|
.score { font-weight: 900; font-size: 1.75rem; }
|
||||||
|
.card { background: #13131f; border: 1px solid #1e1e30; border-radius: 1rem; transition: border-color 0.3s; }
|
||||||
|
.card.online { border-color: #2d2d50; }
|
||||||
|
.card.offline { opacity: 0.5; }
|
||||||
|
.badge-online { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: #22c55e; }
|
||||||
|
.badge-offline { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: #ef4444; }
|
||||||
|
.btn { padding: 0.4rem 0.8rem; border-radius: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; border: none; transition: filter 0.15s; }
|
||||||
|
.btn:hover { filter: brightness(1.2); }
|
||||||
|
.btn:active { filter: brightness(0.9); }
|
||||||
|
.btn-inc { background: #1e40af; color: white; }
|
||||||
|
.btn-dec { background: #1e40af; color: white; }
|
||||||
|
.btn-reset { background: #7f1d1d; color: white; }
|
||||||
|
.btn-edit { background: #713f12; color: white; }
|
||||||
|
.btn-scan { background: #14532d; color: white; font-size: 1rem; padding: 0.6rem 1.5rem; }
|
||||||
|
.btn-all-reset { background: #3b0764; color: white; }
|
||||||
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: #13131f; border: 1px solid #2d2d50; border-radius: 1rem; padding: 2rem; width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal h2 { font-size: 1.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.modal label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.3rem; margin-top: 1rem; text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
.modal input, .modal select { width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.5rem; background: #1e1e30; border: 1px solid #2d2d50; color: white; font-size: 0.95rem; outline: none; }
|
||||||
|
.modal input:focus { border-color: #6366f1; }
|
||||||
|
.modal .btn-save { background: #22c55e; color: white; padding: 0.6rem 2rem; font-size: 1rem; margin-top: 1.5rem; }
|
||||||
|
input[type="color"] { height: 2.5rem; padding: 0.25rem; cursor: pointer; }
|
||||||
|
.tech-details { border-top: 1px solid #1e1e30; padding-top: 0.25rem; }
|
||||||
|
.tech-details summary { outline: none; }
|
||||||
|
.tech-details summary::-webkit-details-marker { color: #94a3b8; }
|
||||||
|
.tech-details[open] { padding-bottom: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-wider">CuteBoard Controller</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Discovered boards are updated automatically</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="btn btn-scan" onclick="scanNetwork()">⟳ Scan Network</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="board-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
<div class="col-span-full text-center text-slate-600 py-16" id="empty-msg">
|
||||||
|
Waiting for boards to appear...<br>
|
||||||
|
<span class="text-sm">Make sure CuteBoard is running on your Pis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Config Modal -->
|
||||||
|
<div class="modal-overlay" id="config-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modal-title">Edit Config</h2>
|
||||||
|
<form id="config-form">
|
||||||
|
<label>Header Text</label>
|
||||||
|
<input type="text" id="edit-header" placeholder="Leave blank to hide">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4" style="margin-top: 1rem;">
|
||||||
|
<div>
|
||||||
|
<label>Left Team Name</label>
|
||||||
|
<input type="text" id="edit-left-name">
|
||||||
|
<label>Left Subtitle</label>
|
||||||
|
<input type="text" id="edit-left-subtitle">
|
||||||
|
<label>Left Color</label>
|
||||||
|
<input type="color" id="edit-left-color">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Right Team Name</label>
|
||||||
|
<input type="text" id="edit-right-name">
|
||||||
|
<label>Right Subtitle</label>
|
||||||
|
<input type="text" id="edit-right-subtitle">
|
||||||
|
<label>Right Color</label>
|
||||||
|
<input type="color" id="edit-right-color">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="tech-details" style="margin-top: 1.5rem;">
|
||||||
|
<summary style="cursor:pointer;font-size:0.8rem;color:#94a3b8;text-transform:uppercase;letter-spacing:0.1em;padding:0.5rem 0;user-select:none;">Technical Settings</summary>
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard">
|
||||||
|
<label>Header Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label>Left Name Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-left-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
|
<label>Left Subtitle Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-left-subtitle-font-size" min="0" step="0.25" placeholder="0 = relative">
|
||||||
|
<label>Left Score Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-left-score-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Right Name Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-right-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
|
<label>Right Subtitle Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-right-subtitle-font-size" min="0" step="0.25" placeholder="0 = relative">
|
||||||
|
<label>Right Score Font Size (rem)</label>
|
||||||
|
<input type="number" id="edit-right-score-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Content Padding Top (rem)</label>
|
||||||
|
<input type="number" id="edit-content-padding-top" min="0" step="0.25" placeholder="0 = none">
|
||||||
|
<label>Content Gap (rem)</label>
|
||||||
|
<input type="number" id="edit-content-gap" min="0" step="0.25" placeholder="1 = default">
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end mt-6">
|
||||||
|
<button type="button" class="btn" style="background:#374151;color:white;padding:0.6rem 1.5rem;" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-save" style="margin-top:0">Save Config</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
tomli>=1.1.0
|
||||||
|
tomli-w>=1.0.0
|
||||||
13
start.sh
Executable file
13
start.sh
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
python3 app.py &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
chromium-browser --kiosk http://localhost:50743 &
|
||||||
|
|
||||||
|
wait $APP_PID
|
||||||
47
static/css/fonts.css
Normal file
47
static/css/fonts.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/Cinzel-Regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cinzel';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/Cinzel-Bold.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Orbitron';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/Orbitron-Regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Orbitron';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/Orbitron-Black.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/PlayfairDisplay-Bold.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Playfair Display';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/PlayfairDisplay-BoldItalic.ttf') format('truetype');
|
||||||
|
}
|
||||||
325
static/css/style.css
Normal file
325
static/css/style.css
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
* {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0a0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ SIDES ============ */
|
||||||
|
.side {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side:active {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hold to reset progress bar */
|
||||||
|
.side::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 5px;
|
||||||
|
z-index: 30;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--team-color);
|
||||||
|
box-shadow: 0 0 12px var(--team-color);
|
||||||
|
width: 0;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.holding::after {
|
||||||
|
animation: hold-progress 1.5s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.hold-cancel::after {
|
||||||
|
animation: hold-fade 0.2s ease-out forwards;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hold-progress {
|
||||||
|
0% { width: 0; }
|
||||||
|
100% { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hold-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.side.holding {
|
||||||
|
filter: brightness(0.7) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-glow {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ TEAM NAME ============ */
|
||||||
|
.team-name {
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #c9a84c;
|
||||||
|
text-shadow:
|
||||||
|
0 0 20px rgba(201, 168, 76, 0.3),
|
||||||
|
0 0 40px rgba(201, 168, 76, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ TEAM SUBTITLE ============ */
|
||||||
|
.team-subtitle {
|
||||||
|
font-size: 0.55em;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-top: 0.3em;
|
||||||
|
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ SCORE ============ */
|
||||||
|
.score {
|
||||||
|
position: relative;
|
||||||
|
color: inherit;
|
||||||
|
text-shadow:
|
||||||
|
0 0 0.04em currentColor,
|
||||||
|
0 0 0.1em currentColor,
|
||||||
|
0 0 0.2em color-mix(in srgb, currentColor 40%, transparent);
|
||||||
|
transition: text-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ SCORE POP ============ */
|
||||||
|
@keyframes score-pop {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
25% { transform: scale(1.3); }
|
||||||
|
50% { transform: scale(0.95); }
|
||||||
|
75% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-pop {
|
||||||
|
animation: score-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ SWEEP OVERLAY ============ */
|
||||||
|
.sweep-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
transform: scaleX(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-left .sweep-overlay {
|
||||||
|
left: 0;
|
||||||
|
width: 50vw;
|
||||||
|
transform-origin: right center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-right .sweep-overlay {
|
||||||
|
right: 0;
|
||||||
|
width: 50vw;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sweep expand + hold then fade */
|
||||||
|
.sweep-overlay.sweeping {
|
||||||
|
animation:
|
||||||
|
sweep-expand 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards,
|
||||||
|
sweep-hold 0.15s ease-out 0.45s forwards,
|
||||||
|
sweep-fade 0.4s ease-out 0.6s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sweep-expand {
|
||||||
|
0% { transform: scaleX(0); opacity: 1; }
|
||||||
|
100% { transform: scaleX(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sweep-hold {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sweep-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ SWEEP GRADIENT ============ */
|
||||||
|
.sweep-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-left .sweep-gradient {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--team-color) 0%,
|
||||||
|
var(--team-color) 40%,
|
||||||
|
var(--team-color) 60%,
|
||||||
|
color-mix(in srgb, var(--team-color) 40%, transparent) 85%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-right .sweep-gradient {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, var(--team-color) 40%, transparent) 15%,
|
||||||
|
var(--team-color) 40%,
|
||||||
|
var(--team-color) 60%,
|
||||||
|
var(--team-color) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ LIQUID WAVES ============ */
|
||||||
|
.wave-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave {
|
||||||
|
position: absolute;
|
||||||
|
left: -25%;
|
||||||
|
width: 150%;
|
||||||
|
border-radius: 42%;
|
||||||
|
animation: wave-spin linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-front {
|
||||||
|
height: 160%;
|
||||||
|
top: -15%;
|
||||||
|
background: var(--team-color);
|
||||||
|
opacity: 0.2;
|
||||||
|
animation-duration: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-back {
|
||||||
|
height: 200%;
|
||||||
|
top: -40%;
|
||||||
|
background: var(--team-color);
|
||||||
|
opacity: 0.1;
|
||||||
|
animation-duration: 5s;
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave-spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ HAND WAVE COUNTDOWN ============ */
|
||||||
|
.side.counting {
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px rgba(59, 130, 246, 0.9),
|
||||||
|
0 0 120px rgba(59, 130, 246, 0.5),
|
||||||
|
0 0 200px rgba(59, 130, 246, 0.25),
|
||||||
|
inset 0 0 80px rgba(59, 130, 246, 0.2);
|
||||||
|
animation: counting-pulse 0.6s ease-in-out infinite alternate;
|
||||||
|
outline: 4px solid rgba(59, 130, 246, 0.7);
|
||||||
|
outline-offset: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes counting-pulse {
|
||||||
|
0% { box-shadow: 0 0 50px rgba(59,130,246,0.7), 0 0 100px rgba(59,130,246,0.3), 0 0 160px rgba(59,130,246,0.15), inset 0 0 60px rgba(59,130,246,0.12); outline-color: rgba(59,130,246,0.5); }
|
||||||
|
100% { box-shadow: 0 0 80px rgba(59,130,246,1.0), 0 0 160px rgba(59,130,246,0.6), 0 0 260px rgba(59,130,246,0.35), inset 0 0 120px rgba(59,130,246,0.3); outline-color: rgba(59,130,246,0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hw-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 8px;
|
||||||
|
z-index: 31;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #60a5fa;
|
||||||
|
box-shadow: 0 0 20px #3b82f6, 0 0 40px #3b82f6;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hw-bar.active {
|
||||||
|
animation: hw-progress 5s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hw-progress {
|
||||||
|
0% { width: 0; }
|
||||||
|
100% { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ RESET CONFIRM OVERLAY ============ */
|
||||||
|
#reset-confirm {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(180, 0, 0, 0.85);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-confirm.show {
|
||||||
|
display: flex;
|
||||||
|
animation: confirm-fadein 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confirm-fadein {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-confirm span {
|
||||||
|
color: white;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 40px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-confirm .confirm-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 8px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0 20px white, 0 0 40px white;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reset-confirm.show .confirm-bar {
|
||||||
|
animation: confirm-progress 10s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confirm-progress {
|
||||||
|
0% { width: 0; }
|
||||||
|
100% { width: 100%; }
|
||||||
|
}
|
||||||
BIN
static/fonts/Cinzel-Bold.ttf
Normal file
BIN
static/fonts/Cinzel-Bold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Cinzel-Regular.ttf
Normal file
BIN
static/fonts/Cinzel-Regular.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Orbitron-Black.ttf
Normal file
BIN
static/fonts/Orbitron-Black.ttf
Normal file
Binary file not shown.
BIN
static/fonts/Orbitron-Regular.ttf
Normal file
BIN
static/fonts/Orbitron-Regular.ttf
Normal file
Binary file not shown.
BIN
static/fonts/PlayfairDisplay-Bold.ttf
Normal file
BIN
static/fonts/PlayfairDisplay-Bold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/PlayfairDisplay-BoldItalic.ttf
Normal file
BIN
static/fonts/PlayfairDisplay-BoldItalic.ttf
Normal file
Binary file not shown.
329
static/js/main.js
Normal file
329
static/js/main.js
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const scores = {
|
||||||
|
left: parseInt(document.getElementById('score-left').textContent) || 0,
|
||||||
|
right: parseInt(document.getElementById('score-right').textContent) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let debounce = false;
|
||||||
|
|
||||||
|
// ---- Auto-fit text ----
|
||||||
|
function fitText(el, maxWidth, maxHeight) {
|
||||||
|
el.style.fontSize = '10px';
|
||||||
|
if (el.scrollWidth === 0) return;
|
||||||
|
|
||||||
|
var lo = 8, hi = 600;
|
||||||
|
while (lo < hi) {
|
||||||
|
var mid = Math.ceil((lo + hi) / 2);
|
||||||
|
el.style.fontSize = mid + 'px';
|
||||||
|
if (el.scrollWidth <= maxWidth && el.scrollHeight <= maxHeight) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.style.fontSize = lo + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitTextHeight(el, maxHeight) {
|
||||||
|
el.style.fontSize = '10px';
|
||||||
|
if (el.scrollHeight === 0) return;
|
||||||
|
|
||||||
|
var lo = 8, hi = 600;
|
||||||
|
while (lo < hi) {
|
||||||
|
var mid = Math.ceil((lo + hi) / 2);
|
||||||
|
el.style.fontSize = mid + 'px';
|
||||||
|
if (el.scrollHeight <= maxHeight) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.style.fontSize = lo + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAll() {
|
||||||
|
var headerRow = document.querySelector('.header-row');
|
||||||
|
if (headerRow) {
|
||||||
|
var span = headerRow.querySelector('span');
|
||||||
|
var hfs = parseFloat(headerRow.dataset.headerFontSize);
|
||||||
|
if (hfs > 0) {
|
||||||
|
span.style.fontSize = hfs + 'rem';
|
||||||
|
} else {
|
||||||
|
var w = window.innerWidth * 0.9;
|
||||||
|
fitText(span, w, 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.side').forEach(function (side) {
|
||||||
|
var rect = side.getBoundingClientRect();
|
||||||
|
var w = rect.width * 0.88;
|
||||||
|
var h = rect.height;
|
||||||
|
|
||||||
|
var name = side.querySelector('.team-name');
|
||||||
|
if (name) {
|
||||||
|
var fs = parseFloat(name.dataset.nameFontSize);
|
||||||
|
if (fs > 0) {
|
||||||
|
name.style.fontSize = fs + 'rem';
|
||||||
|
} else {
|
||||||
|
fitText(name, w, h * 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitle = side.querySelector('.team-subtitle');
|
||||||
|
if (subtitle) {
|
||||||
|
var sfs = parseFloat(subtitle.dataset.subtitleFontSize);
|
||||||
|
if (sfs > 0) {
|
||||||
|
subtitle.style.fontSize = sfs + 'rem';
|
||||||
|
} else {
|
||||||
|
subtitle.style.fontSize = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = side.querySelector('.score');
|
||||||
|
if (score) {
|
||||||
|
var sfs = parseFloat(score.dataset.scoreFontSize);
|
||||||
|
if (sfs > 0) {
|
||||||
|
score.style.fontSize = sfs + 'rem';
|
||||||
|
} else {
|
||||||
|
fitTextHeight(score, h * 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var content = side.querySelector('.side-content');
|
||||||
|
if (content) {
|
||||||
|
var cpt = parseFloat(document.querySelector('.scoreboard').dataset.contentPaddingTop);
|
||||||
|
content.style.paddingTop = cpt > 0 ? cpt + 'rem' : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', fitAll);
|
||||||
|
window.addEventListener('resize', fitAll);
|
||||||
|
|
||||||
|
// ---- Tap / Hold handlers ----
|
||||||
|
document.querySelectorAll('.side').forEach(function (side) {
|
||||||
|
var sideName = side.dataset.side;
|
||||||
|
var holdTimer = null;
|
||||||
|
var holdFadeTimer = null;
|
||||||
|
var holdFired = false;
|
||||||
|
|
||||||
|
function onStart(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (debounce) return;
|
||||||
|
holdFired = false;
|
||||||
|
side.classList.remove('hold-cancel');
|
||||||
|
|
||||||
|
holdFadeTimer = setTimeout(function () {
|
||||||
|
void side.offsetWidth;
|
||||||
|
side.classList.add('holding');
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
holdTimer = setTimeout(function () {
|
||||||
|
holdFired = true;
|
||||||
|
side.classList.remove('holding');
|
||||||
|
side.classList.remove('hold-cancel');
|
||||||
|
resetSide(sideName);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnd(e) {
|
||||||
|
if (holdFadeTimer) { clearTimeout(holdFadeTimer); holdFadeTimer = null; }
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
||||||
|
side.classList.remove('holding');
|
||||||
|
if (holdFired) {
|
||||||
|
side.classList.remove('hold-cancel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
side.classList.add('hold-cancel');
|
||||||
|
if (debounce) return;
|
||||||
|
debounce = true;
|
||||||
|
setTimeout(function () { debounce = false; }, 400);
|
||||||
|
selectedSide = sideName;
|
||||||
|
increment(sideName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
if (holdFadeTimer) { clearTimeout(holdFadeTimer); holdFadeTimer = null; }
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
||||||
|
side.classList.remove('holding');
|
||||||
|
side.classList.remove('hold-cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
side.addEventListener('pointerdown', onStart);
|
||||||
|
side.addEventListener('pointerup', onEnd);
|
||||||
|
side.addEventListener('pointercancel', onCancel);
|
||||||
|
side.addEventListener('pointerleave', onCancel);
|
||||||
|
});
|
||||||
|
|
||||||
|
var selectedSide = 'left';
|
||||||
|
var counting = false;
|
||||||
|
var countdownTimer = null;
|
||||||
|
var switchCount = 0;
|
||||||
|
var originalWaveSide = 'left';
|
||||||
|
var confirming = false;
|
||||||
|
var confirmTimer = null;
|
||||||
|
|
||||||
|
function stopCountdown() {
|
||||||
|
if (countdownTimer) { clearTimeout(countdownTimer); countdownTimer = null; }
|
||||||
|
counting = false;
|
||||||
|
document.querySelectorAll('.side').forEach(function (s) {
|
||||||
|
s.classList.remove('counting');
|
||||||
|
var bar = s.querySelector('.hw-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.classList.remove('active');
|
||||||
|
bar.style.width = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(side) {
|
||||||
|
stopCountdown();
|
||||||
|
counting = true;
|
||||||
|
selectedSide = side;
|
||||||
|
var el = document.querySelector('.side-' + side);
|
||||||
|
if (el) el.classList.add('counting');
|
||||||
|
var bar = document.querySelector('.side-' + side + ' .hw-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = '0';
|
||||||
|
void bar.offsetWidth;
|
||||||
|
bar.classList.add('active');
|
||||||
|
}
|
||||||
|
countdownTimer = setTimeout(function () {
|
||||||
|
stopCountdown();
|
||||||
|
switchCount = 0;
|
||||||
|
increment(side);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWave() {
|
||||||
|
if (confirming) {
|
||||||
|
clearTimeout(confirmTimer);
|
||||||
|
confirmTimer = null;
|
||||||
|
confirming = false;
|
||||||
|
document.getElementById('reset-confirm').classList.remove('show');
|
||||||
|
switchCount = 0;
|
||||||
|
fetch('/reset', { method: 'POST' }).catch(function () {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counting) {
|
||||||
|
switchCount++;
|
||||||
|
if (switchCount >= 6) {
|
||||||
|
stopCountdown();
|
||||||
|
switchCount = 0;
|
||||||
|
confirming = true;
|
||||||
|
document.getElementById('reset-confirm').classList.add('show');
|
||||||
|
confirmTimer = setTimeout(function () {
|
||||||
|
confirming = false;
|
||||||
|
document.getElementById('reset-confirm').classList.remove('show');
|
||||||
|
}, 10000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var mode = (switchCount - 1) % 3;
|
||||||
|
if (mode === 0) {
|
||||||
|
// Show opposite side
|
||||||
|
var next = selectedSide === 'left' ? 'right' : 'left';
|
||||||
|
startCountdown(next);
|
||||||
|
} else if (mode === 1) {
|
||||||
|
// Hidden — mistake cancel, timer keeps running silently
|
||||||
|
var next = selectedSide === 'left' ? 'right' : 'left';
|
||||||
|
selectedSide = next;
|
||||||
|
stopCountdown();
|
||||||
|
counting = true;
|
||||||
|
countdownTimer = setTimeout(function () {
|
||||||
|
stopCountdown();
|
||||||
|
switchCount = 0;
|
||||||
|
increment(selectedSide);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
// Show original side again
|
||||||
|
startCountdown(originalWaveSide);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switchCount = 0;
|
||||||
|
originalWaveSide = selectedSide;
|
||||||
|
startCountdown(selectedSide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.code !== 'Space') return;
|
||||||
|
e.preventDefault();
|
||||||
|
handleWave();
|
||||||
|
});
|
||||||
|
|
||||||
|
var eventSource = new EventSource('/events');
|
||||||
|
eventSource.onmessage = function (e) {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'score') {
|
||||||
|
var prevLeft = scores.left;
|
||||||
|
var prevRight = scores.right;
|
||||||
|
scores.left = data.scores.left;
|
||||||
|
scores.right = data.scores.right;
|
||||||
|
|
||||||
|
if (scores.left !== prevLeft) {
|
||||||
|
selectedSide = 'left';
|
||||||
|
updateDisplay('left', scores.left);
|
||||||
|
triggerSweep('left');
|
||||||
|
}
|
||||||
|
if (scores.right !== prevRight) {
|
||||||
|
selectedSide = 'right';
|
||||||
|
updateDisplay('right', scores.right);
|
||||||
|
triggerSweep('right');
|
||||||
|
}
|
||||||
|
debounce = false;
|
||||||
|
} else if (data.type === 'config') {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Increment ----
|
||||||
|
function increment(side) {
|
||||||
|
if (confirming) {
|
||||||
|
clearTimeout(confirmTimer);
|
||||||
|
confirmTimer = null;
|
||||||
|
confirming = false;
|
||||||
|
document.getElementById('reset-confirm').classList.remove('show');
|
||||||
|
switchCount = 0;
|
||||||
|
}
|
||||||
|
fetch('/increment/' + side, { method: 'POST' }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Update display ----
|
||||||
|
function updateDisplay(side, value) {
|
||||||
|
var el = document.getElementById('score-' + side);
|
||||||
|
el.textContent = value;
|
||||||
|
|
||||||
|
var sfs = parseFloat(el.dataset.scoreFontSize);
|
||||||
|
if (sfs > 0) {
|
||||||
|
el.style.fontSize = sfs + 'rem';
|
||||||
|
} else {
|
||||||
|
var sideEl = document.querySelector('.side-' + side);
|
||||||
|
var rect = sideEl.getBoundingClientRect();
|
||||||
|
fitTextHeight(el, rect.height * 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.remove('score-pop');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('score-pop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Trigger sweep animation ----
|
||||||
|
function triggerSweep(side) {
|
||||||
|
var overlay = document.getElementById('sweep-' + side);
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
overlay.classList.remove('sweeping');
|
||||||
|
overlay.style.animation = 'none';
|
||||||
|
void overlay.offsetHeight;
|
||||||
|
overlay.style.animation = '';
|
||||||
|
overlay.classList.add('sweeping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reset single side ----
|
||||||
|
function resetSide(side) {
|
||||||
|
fetch('/reset/' + side, { method: 'POST' }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
83
static/js/tailwind.js
Normal file
83
static/js/tailwind.js
Normal file
File diff suppressed because one or more lines are too long
124
templates/index.html
Normal file
124
templates/index.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=1920, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>CuteBoard — Display {{ display_id }}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/tailwind.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
playfair: ['"Playfair Display"', 'serif'],
|
||||||
|
orbitron: ['Orbitron', 'sans-serif'],
|
||||||
|
cinzel: ['Cinzel', 'serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="scoreboard flex flex-col h-screen w-screen" data-content-padding-top="{{ content_padding_top }}">
|
||||||
|
|
||||||
|
{% if header %}
|
||||||
|
<div class="header-row text-center pt-6 pointer-events-none" style="position: absolute; inset-inline: 0; top: 0; z-index: 20;"
|
||||||
|
data-header-font-size="{{ header_font_size }}">
|
||||||
|
<span class="font-cinzel tracking-widest uppercase whitespace-nowrap" style="color: white;">{{ header }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sides-row flex flex-1">
|
||||||
|
<!-- ====== LEFT SIDE ====== -->
|
||||||
|
<div class="side side-left flex-1 flex flex-col items-center justify-center relative"
|
||||||
|
style="--team-color: {{ left_color }};"
|
||||||
|
data-side="left">
|
||||||
|
|
||||||
|
<div class="hw-bar"></div>
|
||||||
|
|
||||||
|
<div class="side-glow"
|
||||||
|
style="background: linear-gradient(90deg, {{ left_color }}18 0%, transparent 50%);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-content flex flex-col items-center justify-center" style="gap: {{ content_gap }}rem;">
|
||||||
|
<div class="score font-orbitron leading-none font-black whitespace-nowrap"
|
||||||
|
id="score-left"
|
||||||
|
data-score-font-size="{{ left_score_font_size }}"
|
||||||
|
style="color: {{ left_color }};">
|
||||||
|
{{ left_score }}
|
||||||
|
</div>
|
||||||
|
<div class="team-info text-center">
|
||||||
|
<div class="team-name font-playfair font-bold italic whitespace-nowrap"
|
||||||
|
data-name-font-size="{{ left_name_font_size }}">
|
||||||
|
{{ left_name }}
|
||||||
|
</div>
|
||||||
|
{% if left_subtitle %}
|
||||||
|
<div class="team-subtitle font-cinzel whitespace-nowrap" data-subtitle-font-size="{{ left_subtitle_font_size }}">{{ left_subtitle }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sweep-overlay" id="sweep-left">
|
||||||
|
<div class="sweep-gradient"></div>
|
||||||
|
<div class="wave-container">
|
||||||
|
<div class="wave wave-front"></div>
|
||||||
|
<div class="wave wave-back"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== RIGHT SIDE ====== -->
|
||||||
|
<div class="side side-right flex-1 flex flex-col items-center justify-center relative"
|
||||||
|
style="--team-color: {{ right_color }};"
|
||||||
|
data-side="right">
|
||||||
|
|
||||||
|
<div class="hw-bar"></div>
|
||||||
|
|
||||||
|
<div class="side-glow"
|
||||||
|
style="background: linear-gradient(270deg, {{ right_color }}18 0%, transparent 50%);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-content flex flex-col items-center justify-center" style="gap: {{ content_gap }}rem;">
|
||||||
|
<div class="score font-orbitron leading-none font-black whitespace-nowrap"
|
||||||
|
id="score-right"
|
||||||
|
data-score-font-size="{{ right_score_font_size }}"
|
||||||
|
style="color: {{ right_color }};">
|
||||||
|
{{ right_score }}
|
||||||
|
</div>
|
||||||
|
<div class="team-info text-center">
|
||||||
|
<div class="team-name font-playfair font-bold italic whitespace-nowrap"
|
||||||
|
data-name-font-size="{{ right_name_font_size }}">
|
||||||
|
{{ right_name }}
|
||||||
|
</div>
|
||||||
|
{% if right_subtitle %}
|
||||||
|
<div class="team-subtitle font-cinzel whitespace-nowrap" data-subtitle-font-size="{{ right_subtitle_font_size }}">{{ right_subtitle }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sweep-overlay" id="sweep-right">
|
||||||
|
<div class="sweep-gradient"></div>
|
||||||
|
<div class="wave-container">
|
||||||
|
<div class="wave wave-front"></div>
|
||||||
|
<div class="wave wave-back"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> <!-- /sides-row -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reset-confirm">
|
||||||
|
<span>Reset Scores?</span>
|
||||||
|
<div class="confirm-bar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user