Files
videoplayer/templates/controller.html
2026-05-27 23:08:12 +02:00

556 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Videoplayer Steuerung</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
<script>
(() => {
const storedTheme = window.localStorage.getItem('keyadmin-theme');
const theme = storedTheme === 'dark' || storedTheme === 'light' ? storedTheme : 'dark';
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<style>
:root {
--ccm-bg: #f4f6f8;
--ccm-text: #212121;
--ccm-primary: #da002d;
--ccm-primary-hover: #b00024;
--ccm-header: #2b2f36;
--ccm-surface: #ffffff;
--ccm-border: #d9dee3;
--ccm-muted: #6b7280;
--tblr-primary: var(--ccm-primary);
--tblr-primary-rgb: 218, 0, 45;
--tblr-success: var(--ccm-primary);
--tblr-success-rgb: 218, 0, 45;
}
body {
background-color: var(--ccm-bg);
color: var(--ccm-text);
font-family: "Segoe UI", Arial, sans-serif;
}
.page { min-height: 100vh; }
.navbar-brand img { display: block; }
.navbar-brand-logo {
height: 1.3rem;
width: auto;
display: block;
flex: 0 0 auto;
margin-top: 0;
margin-bottom: 0.06rem;
}
.navbar-brand-wordmark {
display: flex;
flex-direction: column;
line-height: 1.05;
font-size: 0.82rem;
color: #ffffff;
}
.navbar-brand-wordmark strong {
letter-spacing: 0.04em;
text-transform: uppercase;
color: #ffffff;
}
.navbar-brand-wordmark span {
color: rgba(255, 255, 255, 0.78);
font-size: 0.72rem;
}
.navbar {
background: var(--ccm-header);
box-shadow: none;
}
.topbar {
background: var(--ccm-header);
border-bottom: 0;
}
.topbar-main {
padding-top: .55rem;
padding-bottom: .55rem;
}
.topbar-metric {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: .45rem .85rem;
display: inline-flex;
align-items: center;
gap: .45rem;
color: #ffffff;
font-size: .92rem;
font-weight: 600;
white-space: nowrap;
}
.theme-toggle {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-height: 2.4rem;
padding: .45rem .85rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
border-radius: 8px;
}
.theme-toggle:hover,
.theme-toggle:focus {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.18);
}
[data-bs-theme="dark"] .theme-toggle {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
[data-bs-theme="light"] .theme-toggle {
background: #ffffff;
border-color: var(--ccm-border);
color: var(--ccm-text);
}
[data-bs-theme="light"] .theme-toggle:hover,
[data-bs-theme="light"] .theme-toggle:focus {
background: #ffffff;
border-color: var(--ccm-border);
}
.card {
border-color: var(--ccm-border);
border-radius: 1rem;
background: var(--ccm-surface);
box-shadow: 0 12px 24px rgba(43, 47, 54, 0.08);
}
.card-header {
background: transparent;
border-bottom-color: var(--ccm-border);
padding-top: 1rem;
padding-bottom: 1rem;
}
[data-bs-theme="dark"] .card {
background-color: var(--ccm-bg);
border-color: var(--ccm-border);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
}
[data-bs-theme="dark"] .card-header {
border-bottom-color: var(--ccm-border);
}
[data-bs-theme="dark"] {
--ccm-bg: #15181d;
--ccm-text: #e5e7eb;
--ccm-header: #0f1720;
--ccm-surface: #15181d;
--ccm-border: #2b3440;
--ccm-muted: #9aa4b2;
}
[data-bs-theme="dark"] body {
background-color: var(--ccm-bg);
color: var(--ccm-text);
}
.media-list .list-group-item.active {
background: #e9ecef;
border-color: #d9dee3;
}
[data-bs-theme="dark"] .media-list .list-group-item.active {
background: #2b3440;
border-color: #3a4555;
}
.upload-dropzone {
border: 1.5px dashed var(--ccm-border);
border-radius: 0.85rem;
background: var(--ccm-surface);
padding: 0.95rem;
text-align: center;
color: var(--ccm-muted);
cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
user-select: none;
}
.upload-dropzone.dragover {
border-color: var(--ccm-primary);
background: rgba(218, 0, 45, 0.06);
color: var(--ccm-primary);
}
[data-bs-theme="dark"] .upload-dropzone {
background: #11161d;
}
.btn-primary {
background-color: var(--ccm-primary);
border-color: var(--ccm-primary);
}
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--ccm-primary-hover);
border-color: var(--ccm-primary-hover);
}
.btn-outline-primary {
color: var(--ccm-primary);
border-color: var(--ccm-primary);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus {
background-color: var(--ccm-primary);
border-color: var(--ccm-primary);
color: #fff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus {
color: #fff;
}
.status-line { color: var(--ccm-muted); }
#now-playing {
word-break: break-all;
}
.control-btn {
min-width: 3.2rem;
}
.app-footer {
margin-top: 1rem;
padding: 0.65rem 1.5rem 0.95rem;
color: var(--ccm-muted);
font-size: 0.82rem;
background: transparent;
}
.app-footer-inner {
width: 100%;
max-width: 1320px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
justify-content: flex-start;
align-items: center;
text-align: left;
}
.app-footer-separator {
color: #b0b7c3;
}
@media (max-width: 575.98px) {
.control-btn { min-width: 2.8rem; font-size: 0.85rem; }
.control-btn i { font-size: 1.1rem; }
.app-footer-inner { gap: 0.1rem 0.5rem; font-size: 0.75rem; }
}
</style>
</head>
<body>
<div class="page">
<header class="navbar sticky-top topbar brand-surface">
<div class="container-fluid d-flex align-items-center gap-3 topbar-main flex-wrap">
<a class="navbar-brand d-flex align-items-center gap-2 m-0" href="#">
<img class="navbar-brand-logo" src="{{ url_for('static', filename='cancom.svg') }}" alt="CANCOM">
<span class="navbar-brand-wordmark"><strong>Videoplayer</strong><span>Media Kiosk</span></span>
</a>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
<button type="button" class="topbar-metric theme-toggle" id="theme-toggle" aria-label="Theme wechseln"><i class="ti ti-moon-stars"></i></button>
<div class="topbar-metric" id="online-indicator"><i class="ti ti-circle-check-filled"></i><span id="online-label">Online</span></div>
<div class="topbar-metric"><i class="ti ti-device-tv"></i><span id="host-label">{{ request.host }}</span></div>
<div class="topbar-metric"><i class="ti ti-clock-hour-4"></i><span id="clock-label">--:--</span></div>
</div>
</div>
</header>
<div class="container-fluid py-3 py-lg-4">
<div class="row g-3">
<div class="col-12">
<!-- Now Playing -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-sm" style="background: var(--tblr-primary); color: #fff;"><i class="ti ti-screen-share"></i></span>
<h3 class="card-title mb-0">Aktuelle Wiedergabe</h3>
</div>
</div>
<div class="card-body">
<div id="now-playing" class="fs-5 fw-medium mb-1"></div>
<div id="play-status" class="status-line">Bereit.</div>
</div>
</div>
<!-- Controls -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-sm" style="background: var(--tblr-primary); color: #fff;"><i class="ti ti-player-play"></i></span>
<h3 class="card-title mb-0">Steuerung</h3>
</div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2 justify-content-center">
<button type="button" class="btn btn-outline-secondary control-btn" onclick="apiPrev()"><i class="ti ti-player-track-prev"></i></button>
<button type="button" class="btn btn-outline-secondary control-btn" onclick="apiSeek(-10)"><i class="ti ti-rewind-backward-10"></i></button>
<button type="button" class="btn btn-primary control-btn" onclick="apiPlay()"><i class="ti ti-player-play"></i></button>
<button type="button" class="btn btn-outline-secondary control-btn" onclick="apiStop()"><i class="ti ti-player-stop"></i></button>
<button type="button" class="btn btn-outline-secondary control-btn" onclick="apiSeek(10)"><i class="ti ti-rewind-forward-10"></i></button>
<button type="button" class="btn btn-outline-primary control-btn" onclick="apiNext()"><i class="ti ti-player-track-next"></i></button>
</div>
<div class="d-flex align-items-center gap-2 mt-3">
<i class="ti ti-volume"></i>
<input type="range" id="volume-slider" class="form-range" min="0" max="100" value="100">
<span id="volume-label" class="fw-medium text-nowrap" style="min-width:2.8rem">100%</span>
</div>
</div>
</div>
<!-- Media Library -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-sm" style="background: var(--tblr-primary); color: #fff;"><i class="ti ti-photo-scan"></i></span>
<h3 class="card-title mb-0">Medienbibliothek</h3>
</div>
</div>
<div class="card-body">
<div class="mb-2 fw-medium">Dateien</div>
<div class="list-group media-list" id="media-list">
{% for item in items %}
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" data-name="{{ item.name }}" data-kind="{{ item.kind }}">
<span class="text-truncate" style="min-width:0">{{ item.name }}</span>
<span class="ms-auto d-flex align-items-center gap-1 flex-shrink-0">
<span class="badge bg-secondary-lt text-secondary">{{ item.kind }}</span>
<i class="ti ti-trash text-danger" style="cursor:pointer;font-size:1.05rem" onclick="event.stopPropagation(); apiDelete('{{ item.name }}')" title="Löschen"></i>
</span>
</button>
{% endfor %}
</div>
</div>
</div>
<!-- Upload -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<span class="avatar avatar-sm" style="background: var(--tblr-primary); color: #fff;"><i class="ti ti-cloud-upload"></i></span>
<h3 class="card-title mb-0">Dateien hochladen</h3>
</div>
</div>
<div class="card-body">
<form id="upload-form" action="/upload" method="post" enctype="multipart/form-data">
<input id="file-input" class="d-none" type="file" name="files" multiple accept="video/*,audio/*,image/*">
<div id="dropzone" class="upload-dropzone mb-2">
<div class="mb-1"><i class="ti ti-cloud-upload fs-2"></i></div>
<div>Dateien hier ablegen oder antippen</div>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" id="choose-files"><i class="ti ti-folder-open me-1"></i>Dateien wählen</button>
<button type="submit" class="btn btn-primary"><i class="ti ti-upload me-1"></i>Hochladen</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="app-footer">
<div class="app-footer-inner">
<span>Videoplayer Media Kiosk</span>
<span class="app-footer-separator">|</span>
<span>&copy; {{ app_year }} CANCOM GmbH</span>
<span class="app-footer-separator">|</span>
<span>Version {{ app_version }}</span>
<span class="app-footer-separator">|</span>
<span>Host {{ app_host }}</span>
</div>
</footer>
<script>
const clockLabel = document.getElementById('clock-label');
const themeToggle = document.getElementById('theme-toggle');
const nowPlaying = document.getElementById('now-playing');
const playStatus = document.getElementById('play-status');
const onlineIndicator = document.getElementById('online-indicator');
const onlineLabel = document.getElementById('online-label');
const volumeSlider = document.getElementById('volume-slider');
const volumeLabel = document.getElementById('volume-label');
const fileInput = document.getElementById('file-input');
const uploadForm = document.getElementById('upload-form');
const dropzone = document.getElementById('dropzone');
const chooseFiles = document.getElementById('choose-files');
let selectedName = null;
// ── API calls ──────────────────────────────────────────────────────
async function apiCall(method, body = {}) {
const res = await fetch(method, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
function apiPlay(name) {
if (name) {
apiCall('/api/play', { name }).then(updateFromState);
} else {
apiCall('/api/play', {}).then(updateFromState);
}
}
function apiStop() {
apiCall('/api/stop').then(updateFromState);
}
function apiNext() {
apiCall('/api/next').then(updateFromState);
}
function apiPrev() {
apiCall('/api/prev').then(updateFromState);
}
function apiSeek(seconds) {
apiCall('/api/seek', { seconds }).then(updateFromState);
}
function apiDelete(name) {
if (!window.confirm(`Wirklich ${name} löschen?`)) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = `/delete/${encodeURIComponent(name)}`;
document.body.appendChild(form);
form.submit();
}
// ── State handling ─────────────────────────────────────────────────
function updateFromState(state) {
if (state.current) {
nowPlaying.textContent = state.current.name;
playStatus.textContent = state.playing ? 'Wird wiedergegeben' : 'Angehalten';
highlightMedia(state.current.name);
} else {
nowPlaying.textContent = '';
playStatus.textContent = 'Bereit.';
clearHighlight();
}
updateVolume(state.volume);
updateOnline(state.display_online);
}
function updateOnline(online) {
if (online) {
onlineLabel.textContent = 'Online';
onlineIndicator.style.opacity = '1';
} else {
onlineLabel.textContent = 'Offline';
onlineIndicator.style.opacity = '0.5';
}
}
async function pollState() {
try {
const res = await fetch('/api/state');
const state = await res.json();
updateFromState(state);
} catch (_) {}
}
function highlightMedia(name) {
document.querySelectorAll('#media-list .list-group-item').forEach(el => {
el.classList.toggle('active', el.dataset.name === name);
});
}
function clearHighlight() {
document.querySelectorAll('#media-list .list-group-item').forEach(el => {
el.classList.remove('active');
});
}
// ── Media list click ──────────────────────────────────────────────
document.getElementById('media-list').addEventListener('click', (e) => {
const btn = e.target.closest('.list-group-item');
if (!btn) return;
const name = btn.dataset.name;
selectedName = name;
apiPlay(name);
});
// ── Upload ─────────────────────────────────────────────────────────
function syncFileInput(files) {
const dt = new DataTransfer();
Array.from(files).forEach(file => dt.items.add(file));
fileInput.files = dt.files;
dropzone.textContent = `${dt.files.length} Datei(en) bereit zum Hochladen`;
}
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
dropzone.textContent = `${fileInput.files.length} Datei(en) ausgewählt`;
}
});
chooseFiles.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
syncFileInput(e.dataTransfer.files);
}
});
uploadForm.addEventListener('submit', (e) => {
if (!fileInput.files.length) {
e.preventDefault();
}
});
// ── Volume ─────────────────────────────────────────────────────────
volumeSlider.addEventListener('input', () => {
const vol = parseInt(volumeSlider.value) / 100;
volumeLabel.textContent = `${parseInt(volumeSlider.value)}%`;
apiCall('/api/volume', { volume: vol });
});
function updateVolume(vol) {
const pct = Math.round((vol ?? 1.0) * 100);
volumeSlider.value = pct;
volumeLabel.textContent = `${pct}%`;
}
// ── Theme ──────────────────────────────────────────────────────────
function updateThemeToggle() {
const theme = document.documentElement.getAttribute('data-bs-theme');
themeToggle.innerHTML = theme === 'dark'
? '<i class="ti ti-sun-high"></i>'
: '<i class="ti ti-moon-stars"></i>';
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-bs-theme', next);
window.localStorage.setItem('keyadmin-theme', next);
updateThemeToggle();
}
themeToggle.addEventListener('click', toggleTheme);
// ── Clock ──────────────────────────────────────────────────────────
function updateClock() {
clockLabel.textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
setInterval(updateClock, 30000);
// ── Init ───────────────────────────────────────────────────────────
window.addEventListener('load', () => {
updateClock();
updateThemeToggle();
pollState();
setInterval(pollState, 3000);
});
</script>
</body>
</html>