Version 2.0.0
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
test*
|
||||||
|
*.old
|
||||||
|
.venv/
|
||||||
|
media/
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
281
app.py
Normal file
281
app.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Flask, render_template,
|
||||||
|
send_from_directory, redirect,
|
||||||
|
request, abort
|
||||||
|
)
|
||||||
|
from flask_login import (
|
||||||
|
LoginManager, login_user,
|
||||||
|
login_required, logout_user,
|
||||||
|
UserMixin
|
||||||
|
)
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Grundkonfiguration
|
||||||
|
# -------------------------------------------------
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
MEDIA_DIR = os.path.join(BASE_DIR, "media")
|
||||||
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
||||||
|
|
||||||
|
APP_VERSION = "2.0.0"
|
||||||
|
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "CHANGE_THIS_SECRET"
|
||||||
|
|
||||||
|
login_manager = LoginManager(app)
|
||||||
|
login_manager.login_view = "login"
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Config Helpers
|
||||||
|
# -------------------------------------------------
|
||||||
|
def load_config():
|
||||||
|
if not os.path.exists(CONFIG_FILE):
|
||||||
|
return {"admin": {}, "screens": {}}
|
||||||
|
with open(CONFIG_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def save_config(cfg):
|
||||||
|
with open(CONFIG_FILE, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
print("✅ config.json gespeichert")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Auth / Benutzer
|
||||||
|
# -------------------------------------------------
|
||||||
|
class Admin(UserMixin):
|
||||||
|
id = 1
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return Admin()
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Login / Logout
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if (
|
||||||
|
request.form.get("username") == config["admin"].get("username")
|
||||||
|
and request.form.get("password") == config["admin"].get("password")
|
||||||
|
):
|
||||||
|
login_user(Admin())
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect("/login")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Medien ausliefern
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/media/<screen>/<path:filename>")
|
||||||
|
def media(screen, filename):
|
||||||
|
path = os.path.join(MEDIA_DIR, screen)
|
||||||
|
return send_from_directory(path, filename)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Player
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/player/<screen>")
|
||||||
|
def player(screen):
|
||||||
|
config = load_config()
|
||||||
|
screens_cfg = config.setdefault("screens", {})
|
||||||
|
screen_cfg = screens_cfg.get(screen)
|
||||||
|
|
||||||
|
if not screen_cfg:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
folder = os.path.join(MEDIA_DIR, screen)
|
||||||
|
playlist = screen_cfg.get("playlist", [])
|
||||||
|
|
||||||
|
show_images = screen_cfg.get("show_images", True)
|
||||||
|
show_videos = screen_cfg.get("show_videos", True)
|
||||||
|
|
||||||
|
def allowed_file(name):
|
||||||
|
if name.startswith("._"):
|
||||||
|
return False
|
||||||
|
ext = name.lower()
|
||||||
|
if ext.endswith((".jpg", ".jpeg", ".png")):
|
||||||
|
return show_images
|
||||||
|
if ext.endswith(".mp4"):
|
||||||
|
return show_videos
|
||||||
|
return False
|
||||||
|
|
||||||
|
ordered = []
|
||||||
|
|
||||||
|
# 1. Playlist-Reihenfolge
|
||||||
|
for name in playlist:
|
||||||
|
if os.path.exists(os.path.join(folder, name)) and allowed_file(name):
|
||||||
|
ordered.append(name)
|
||||||
|
|
||||||
|
# 2. Neue Dateien hintendran
|
||||||
|
for f in sorted(os.listdir(folder)):
|
||||||
|
if f not in ordered and allowed_file(f):
|
||||||
|
ordered.append(f)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"player.html",
|
||||||
|
screen=screen,
|
||||||
|
files=ordered,
|
||||||
|
interval=screen_cfg.get("interval", 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Hash für automatisches Player-Reload
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/playlist/<screen>/hash")
|
||||||
|
def playlist_hash(screen):
|
||||||
|
screen_dir = os.path.join(MEDIA_DIR, screen)
|
||||||
|
if not os.path.isdir(screen_dir):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for f in os.listdir(screen_dir):
|
||||||
|
if f.startswith("._"):
|
||||||
|
continue
|
||||||
|
p = os.path.join(screen_dir, f)
|
||||||
|
try:
|
||||||
|
entries.append(f"{f}:{int(os.path.getmtime(p))}")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
h = hashlib.md5("|".join(sorted(entries)).encode()).hexdigest()
|
||||||
|
return h
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Admin Dashboard
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin")
|
||||||
|
@login_required
|
||||||
|
def admin():
|
||||||
|
config = load_config()
|
||||||
|
screens_cfg = config.setdefault("screens", {})
|
||||||
|
|
||||||
|
screens = {}
|
||||||
|
media_files = {}
|
||||||
|
screen_status = {}
|
||||||
|
|
||||||
|
for screen in sorted(os.listdir(MEDIA_DIR)):
|
||||||
|
screen_dir = os.path.join(MEDIA_DIR, screen)
|
||||||
|
if not os.path.isdir(screen_dir):
|
||||||
|
continue
|
||||||
|
|
||||||
|
screens_cfg.setdefault(screen, {
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": True,
|
||||||
|
"show_videos": True,
|
||||||
|
"playlist": []
|
||||||
|
})
|
||||||
|
|
||||||
|
screens[screen] = screens_cfg[screen]
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for f in sorted(os.listdir(screen_dir)):
|
||||||
|
if f.startswith("._"):
|
||||||
|
continue
|
||||||
|
ext = os.path.splitext(f)[1].lower()
|
||||||
|
ftype = "video" if ext == ".mp4" else "image"
|
||||||
|
size = os.path.getsize(os.path.join(screen_dir, f)) // 1024
|
||||||
|
|
||||||
|
files.append({
|
||||||
|
"name": f,
|
||||||
|
"type": ftype,
|
||||||
|
"size": size
|
||||||
|
})
|
||||||
|
|
||||||
|
media_files[screen] = files
|
||||||
|
screen_status[screen] = "active" if files else "empty"
|
||||||
|
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin.html",
|
||||||
|
screens=screens,
|
||||||
|
media_files=media_files,
|
||||||
|
screen_status=screen_status,
|
||||||
|
hostname=socket.gethostname(),
|
||||||
|
version=APP_VERSION,
|
||||||
|
year=datetime.now().year
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Admin: Screen-Einstellungen speichern
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin/update/<screen>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_screen(screen):
|
||||||
|
config = load_config()
|
||||||
|
cfg = config["screens"][screen]
|
||||||
|
|
||||||
|
cfg["interval"] = int(request.form.get("interval", 10))
|
||||||
|
cfg["show_images"] = "show_images" in request.form
|
||||||
|
cfg["show_videos"] = "show_videos" in request.form
|
||||||
|
|
||||||
|
save_config(config)
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Admin: Upload
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin/upload/<screen>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def upload(screen):
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
if ext not in UPLOAD_EXTENSIONS:
|
||||||
|
return "Ungültiger Dateityp", 400
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file.save(os.path.join(MEDIA_DIR, screen, filename))
|
||||||
|
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Admin: Datei löschen
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin/delete/<screen>/<filename>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_file(screen, filename):
|
||||||
|
path = os.path.join(MEDIA_DIR, screen, filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
return redirect("/admin")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Admin: Playlist speichern
|
||||||
|
# -------------------------------------------------
|
||||||
|
@app.route("/admin/playlist/<screen>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_playlist(screen):
|
||||||
|
config = load_config()
|
||||||
|
cfg = config["screens"][screen]
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
cfg["playlist"] = data.get("playlist", [])
|
||||||
|
|
||||||
|
save_config(config)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -------------------------------------------------
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, host="0.0.0.0", port=5005)
|
||||||
32
config.json
Normal file
32
config.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"screens": {
|
||||||
|
"lobby": {
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": true,
|
||||||
|
"show_videos": true,
|
||||||
|
"playlist": [
|
||||||
|
"QRCode.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"casino": {
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": true,
|
||||||
|
"show_videos": true,
|
||||||
|
"playlist": [
|
||||||
|
"HilfeKI.jpg",
|
||||||
|
"IMG_6730.jpeg",
|
||||||
|
"JET-Design.JPG"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Erik": {
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": true,
|
||||||
|
"show_videos": true,
|
||||||
|
"playlist": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "wooper-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
flask-login
|
||||||
|
werkzeug
|
||||||
265
templates/admin.html
Normal file
265
templates/admin.html
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage Admin</title>
|
||||||
|
|
||||||
|
<!-- Tabler CSS -->
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||||
|
|
||||||
|
<!-- Tabler JS -->
|
||||||
|
<script defer
|
||||||
|
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||||
|
|
||||||
|
<!-- SortableJS -->
|
||||||
|
<script defer
|
||||||
|
src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #F4F6F8;
|
||||||
|
color: #212121;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Navigation */
|
||||||
|
.navbar {
|
||||||
|
background-color: #DA002D;
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CANCOM CI – Variante 1 */
|
||||||
|
.card-header {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-left: 6px solid #DA002D;
|
||||||
|
padding-left: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #DA002D;
|
||||||
|
border-color: #DA002D;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #B00024;
|
||||||
|
border-color: #B00024;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: #DA002D;
|
||||||
|
border-color: #DA002D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:focus {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(218, 0, 45, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playlist als Grid (eine Zeile, tabellarisch) */
|
||||||
|
.playlist-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
1fr /* Name */
|
||||||
|
90px /* Typ */
|
||||||
|
80px /* Größe */
|
||||||
|
90px /* Delete */
|
||||||
|
32px; /* Drag */
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-size {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="navbar navbar-expand-md">
|
||||||
|
<div class="container-xl">
|
||||||
|
<span class="navbar-brand">CANCOM Simple Signage Admin</span>
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<a href="/logout" class="nav-link text-white">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-xl mt-4">
|
||||||
|
|
||||||
|
{% for screen, cfg in screens.items() %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Screen: {{ screen }}</span>
|
||||||
|
{% if screen_status[screen] == "active" %}
|
||||||
|
<span class="badge bg-success">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Leer</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<!-- Einstellungen -->
|
||||||
|
<form action="/admin/update/{{ screen }}" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Intervall (Sekunden)</label>
|
||||||
|
<input type="number" class="form-control"
|
||||||
|
name="interval" min="1" value="{{ cfg.interval }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
name="show_images"
|
||||||
|
{% if cfg.show_images %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Bilder anzeigen</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
name="show_videos"
|
||||||
|
{% if cfg.show_videos %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Videos anzeigen</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Einstellungen speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<h5>Medien hochladen</h5>
|
||||||
|
<form action="/admin/upload/{{ screen }}"
|
||||||
|
method="post" enctype="multipart/form-data">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="file" name="file" class="form-control" required>
|
||||||
|
<button class="btn btn-primary" type="submit">Upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Playlist -->
|
||||||
|
<h5>Playlist Reihenfolge</h5>
|
||||||
|
|
||||||
|
<ul class="list-group mb-3" id="playlist-{{ screen }}">
|
||||||
|
{% for file in media_files[screen] %}
|
||||||
|
<li class="list-group-item playlist-row"
|
||||||
|
data-file="{{ file.name }}">
|
||||||
|
|
||||||
|
<span class="playlist-name">{{ file.name }}</span>
|
||||||
|
|
||||||
|
{% if file.type == "image" %}
|
||||||
|
<span class="badge bg-blue-lt">Bild</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-orange-lt">Video</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="playlist-size">{{ file.size }} KB</span>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<form action="/admin/delete/{{ screen }}/{{ file.name }}"
|
||||||
|
method="post">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Datei wirklich löschen?')">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Drag -->
|
||||||
|
<span class="drag-handle" title="Reihenfolge ändern">⇅</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
onclick="savePlaylist('{{ screen }}')">
|
||||||
|
Reihenfolge speichern
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer footer-transparent mt-4">
|
||||||
|
<div class="container-xl text-muted">
|
||||||
|
© {{ year }} CANCOM · Version {{ version }} · {{ hostname }}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
|
function initSortable(screen) {
|
||||||
|
const el = document.getElementById("playlist-" + screen);
|
||||||
|
if (!el || typeof Sortable === "undefined") return;
|
||||||
|
|
||||||
|
new Sortable(el, {
|
||||||
|
animation: 150,
|
||||||
|
handle: ".drag-handle",
|
||||||
|
ghostClass: "bg-light",
|
||||||
|
chosenClass: "bg-light"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlaylist(screen) {
|
||||||
|
const items = document.querySelectorAll(
|
||||||
|
"#playlist-" + screen + " li"
|
||||||
|
);
|
||||||
|
|
||||||
|
fetch("/admin/playlist/" + screen, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
playlist: Array.from(items).map(i => i.dataset.file)
|
||||||
|
})
|
||||||
|
}).then(() => location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.savePlaylist = savePlaylist;
|
||||||
|
|
||||||
|
{% for screen in screens.keys() %}
|
||||||
|
initSortable("{{ screen }}");
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
27
templates/login.html
Normal file
27
templates/login.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Signage Login</title>
|
||||||
|
<link href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="d-flex align-items-center justify-content-center bg-dark" style="height:100vh;">
|
||||||
|
<div class="card card-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="text-center mb-4">Signage Admin</h2>
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Benutzername</label>
|
||||||
|
<input class="form-control" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Passwort</label>
|
||||||
|
<input type="password" class="form-control" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
templates/player.html
Normal file
84
templates/player.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CANCOM Simple Signage Player</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: black;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#image, #video {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit: contain;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<img id="image" hidden>
|
||||||
|
<video id="video" hidden muted autoplay playsinline></video>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const files = {{ files | tojson }};
|
||||||
|
const interval = {{ interval }} * 1000;
|
||||||
|
const screen = "{{ screen }}";
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
let lastHash = null;
|
||||||
|
|
||||||
|
const img = document.getElementById("image");
|
||||||
|
const video = document.getElementById("video");
|
||||||
|
|
||||||
|
function isVideo(name) {
|
||||||
|
return name.toLowerCase().endsWith(".mp4");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNext() {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const file = files[index];
|
||||||
|
index = (index + 1) % files.length;
|
||||||
|
|
||||||
|
if (isVideo(file)) {
|
||||||
|
img.hidden = true;
|
||||||
|
video.hidden = false;
|
||||||
|
video.src = `/media/${screen}/${file}`;
|
||||||
|
video.load();
|
||||||
|
video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
video.hidden = true;
|
||||||
|
img.hidden = false;
|
||||||
|
img.src = `/media/${screen}/${file}`;
|
||||||
|
setTimeout(showNext, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener("ended", showNext);
|
||||||
|
|
||||||
|
async function checkForUpdate() {
|
||||||
|
const r = await fetch(`/playlist/${screen}/hash`, { cache: "no-store" });
|
||||||
|
const hash = await r.text();
|
||||||
|
if (lastHash && hash !== lastHash) location.reload();
|
||||||
|
lastHash = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(checkForUpdate, 5000);
|
||||||
|
showNext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user