docker.videokonverter/video-konverter/app/templates/tv_admin.html
data 956b7b9ac8 feat: VideoKonverter v5.6 - Player-Overlay, Immersive Fullscreen, Audio-Normalisierung
Tizen TV: Transparenter iframe-Overlay statt opacity:0 - Player-Controls
(Progress-Bar, Buttons, Popup-Menue) jetzt sichtbar ueber dem AVPlay-Video.
CSS-Klasse "vknative-playing" macht Hintergruende transparent, AVPlay-Video
scheint durch den iframe hindurch.

Android App: Immersive Sticky Fullscreen mit WindowInsetsControllerCompat.
Status- und Navigationsleiste komplett versteckt, per Swipe vom Rand
temporaer einblendbar.

Audio-Normalisierung (3 Stufen):
- Server-seitig: EBU R128 loudnorm (I=-14 LUFS) im HLS-Transcoding
- Server-seitig: dynaudnorm (dynamische Szenen-Anpassung) im HLS-Transcoding
- Client-seitig: DynamicsCompressorNode im Browser-Player
Alle Optionen konfigurierbar: loudnorm/dynaudnorm im TV Admin-Center,
Audio-Kompressor pro Client in den Einstellungen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:07:04 +01:00

393 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}TV Admin - VideoKonverter{% endblock %}
{% block content %}
<section class="admin-section">
<h2>TV Admin-Center</h2>
<form hx-post="/htmx/tv-settings" hx-target="#tv-save-result" hx-swap="innerHTML">
<!-- Streaming -->
<fieldset>
<legend>HLS Streaming</legend>
<div class="form-grid">
<div class="form-group">
<label for="hls_segment_duration">Segment-Dauer (Sekunden)</label>
<input type="number" name="hls_segment_duration" id="hls_segment_duration"
value="{{ tv.hls_segment_duration | default(4) }}" min="1" max="30">
<span class="text-muted" style="font-size:0.8rem">Laenge der einzelnen HLS-Segmente</span>
</div>
<div class="form-group">
<label for="hls_init_duration">Erstes Segment (Sekunden)</label>
<input type="number" name="hls_init_duration" id="hls_init_duration"
value="{{ tv.hls_init_duration | default(1) }}" min="1" max="10">
<span class="text-muted" style="font-size:0.8rem">Kuerzeres erstes Segment fuer schnelleren Playback-Start</span>
</div>
<div class="form-group">
<label for="hls_session_timeout_min">Session-Timeout (Minuten)</label>
<input type="number" name="hls_session_timeout_min" id="hls_session_timeout_min"
value="{{ tv.hls_session_timeout_min | default(5) }}" min="1" max="60">
<span class="text-muted" style="font-size:0.8rem">Inaktive Sessions werden nach dieser Zeit beendet</span>
</div>
<div class="form-group">
<label for="hls_max_sessions">Max. gleichzeitige Sessions</label>
<input type="number" name="hls_max_sessions" id="hls_max_sessions"
value="{{ tv.hls_max_sessions | default(5) }}" min="1" max="20">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="pause_batch_on_stream" id="pause_batch_on_stream"
{% if tv.pause_batch_on_stream | default(true) %}checked{% endif %}>
Batch-Konvertierung bei Stream pausieren
</label>
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="force_transcode" id="force_transcode"
{% if tv.force_transcode | default(false) %}checked{% endif %}>
Immer transcodieren (kein Copy-Modus)
</label>
<span class="text-muted" style="font-size:0.8rem">Alle Videos werden zu H.264+AAC transcodiert. Langsamer, aber garantiert kompatibel fuer alle Clients (TV, Handy, Browser)</span>
</div>
</div>
</fieldset>
<!-- Audio-Normalisierung -->
<fieldset>
<legend>Audio-Normalisierung</legend>
<div class="form-grid">
<div class="form-group">
<label>
<input type="checkbox" name="audio_loudnorm" id="audio_loudnorm"
{% if tv.audio_loudnorm | default(false) %}checked{% endif %}>
EBU R128 Loudnorm (Lautstaerke-Normalisierung)
</label>
<span class="text-muted" style="font-size:0.8rem">Normalisiert Audio auf -14 LUFS (Broadcast-Standard). Alle Filme/Serien haben die gleiche Grundlautstaerke. Nur bei HLS-Streaming aktiv.</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="audio_dynaudnorm" id="audio_dynaudnorm"
{% if tv.audio_dynaudnorm | default(false) %}checked{% endif %}>
Dynamische Audio-Normalisierung (dynaudnorm)
</label>
<span class="text-muted" style="font-size:0.8rem">Passt Lautstaerke Szene fuer Szene an. Leise Dialoge werden lauter, Explosionen leiser. Ergaenzt Loudnorm. Nur bei HLS-Streaming aktiv.</span>
</div>
</div>
</fieldset>
<!-- Watch-Status -->
<fieldset>
<legend>Watch-Status</legend>
<div class="form-grid">
<div class="form-group">
<label for="watched_threshold_pct">Gesehen-Schwelle (%)</label>
<input type="number" name="watched_threshold_pct" id="watched_threshold_pct"
value="{{ tv.watched_threshold_pct | default(90) }}" min="50" max="100">
<span class="text-muted" style="font-size:0.8rem">Ab diesem Fortschritt gilt eine Episode als gesehen (Plex Standard: 90%)</span>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
<span id="tv-save-result"></span>
</div>
</form>
</section>
<!-- Aktive HLS-Sessions -->
<section class="admin-section">
<h2>Aktive HLS-Sessions</h2>
<div id="hls-sessions">
<div class="loading-msg">Lade Sessions...</div>
</div>
<button class="btn-secondary" onclick="loadHlsSessions()" style="margin-top:0.5rem">Aktualisieren</button>
</section>
<!-- TV-App / Streaming -->
<section class="admin-section">
<h2>TV-App</h2>
<div style="display:flex;gap:2rem;flex-wrap:wrap">
<!-- QR-Code -->
<div style="text-align:center">
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
<div style="margin-top:0.3rem">
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
</div>
</div>
<!-- User-Verwaltung -->
<div style="flex:1;min-width:300px">
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
<div id="tv-users-list">
<div class="loading-msg">Lade Benutzer...</div>
</div>
<!-- Neuer User -->
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
<div class="form-grid">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
</div>
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="tv-new-password" placeholder="Passwort">
</div>
<div class="form-group">
<label>Rechte</label>
<div style="display:flex;flex-direction:column;gap:0.3rem">
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-techinfo"> Technische Details</label>
</div>
</div>
</div>
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
// === HLS Sessions Monitoring ===
function loadHlsSessions() {
fetch("/api/tv/hls-sessions")
.then(r => r.json())
.then(data => {
const container = document.getElementById("hls-sessions");
const sessions = data.sessions || [];
if (!sessions.length) {
container.innerHTML = '<div class="loading-msg">Keine aktiven Sessions</div>';
return;
}
container.innerHTML = `
<table class="stats-table" style="width:100%">
<thead>
<tr>
<th>Session</th>
<th>Video</th>
<th>Qualitaet</th>
<th>Laufzeit</th>
<th>Inaktiv</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${sessions.map(s => `
<tr>
<td><code>${s.session_id.substring(0, 8)}...</code></td>
<td>${escapeHtml(s.video_name || 'ID ' + s.video_id)}</td>
<td><span class="tag">${s.quality.toUpperCase()}</span></td>
<td>${formatDuration(s.age_sec)}</td>
<td>${formatDuration(s.idle_sec)}</td>
<td>${s.ready ? '<span class="status-badge ok">Aktiv</span>' : '<span class="status-badge warn">Startet...</span>'}</td>
<td><button class="btn-small btn-danger" onclick="destroyHlsSession('${s.session_id}')">Beenden</button></td>
</tr>
`).join("")}
</tbody>
</table>
`;
})
.catch(() => {
document.getElementById("hls-sessions").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
});
}
async function destroyHlsSession(sid) {
if (!await showConfirm("HLS-Session beenden?", {title: "Session beenden", okText: "Beenden", icon: "warn"})) return;
fetch("/api/tv/hls-sessions/" + sid, {method: "DELETE"})
.then(r => r.json())
.then(() => { showToast("Session beendet", "success"); loadHlsSessions(); })
.catch(e => showToast("Fehler: " + e, "error"));
}
function formatDuration(sec) {
if (sec < 60) return sec + "s";
if (sec < 3600) return Math.floor(sec / 60) + "m " + (sec % 60) + "s";
return Math.floor(sec / 3600) + "h " + Math.floor((sec % 3600) / 60) + "m";
}
// === TV-App User-Verwaltung ===
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
}
function tvLoadUsers() {
fetch("/api/tv/users")
.then(r => r.json())
.then(data => {
const container = document.getElementById("tv-users-list");
const users = data.users || [];
if (!users.length) {
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
return;
}
container.innerHTML = users.map(u => `
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div>
<strong>${escapeHtml(u.display_name || u.username)}</strong>
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
${u.show_tech_info ? '<span class="tag">Tech-Info</span>' : ''}
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
</div>
<div style="display:flex;gap:0.3rem">
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
</div>
</div>
`).join("");
})
.catch(() => {
document.getElementById("tv-users-list").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
});
}
function tvCreateUser() {
const username = document.getElementById("tv-new-username").value.trim();
const displayName = document.getElementById("tv-new-display").value.trim();
const password = document.getElementById("tv-new-password").value;
if (!username || !password) {
showToast("Benutzername und Passwort noetig", "error");
return;
}
fetch("/api/tv/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
username: username,
password: password,
display_name: displayName || username,
is_admin: document.getElementById("tv-new-admin").checked,
can_view_series: document.getElementById("tv-new-series").checked,
can_view_movies: document.getElementById("tv-new-movies").checked,
show_tech_info: document.getElementById("tv-new-techinfo").checked,
}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
document.getElementById("tv-new-username").value = "";
document.getElementById("tv-new-display").value = "";
document.getElementById("tv-new-password").value = "";
showToast("Benutzer erstellt", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvDeleteUser(userId, username) {
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
fetch("/api/tv/users/" + userId, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer geloescht", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvEditUser(userId) {
// User-Daten laden, dann Edit-Dialog anzeigen
const resp = await fetch("/api/tv/users").then(r => r.json());
const user = (resp.users || []).find(u => u.id === userId);
if (!user) return;
const newPass = await showPrompt("Neues Passwort (leer lassen um beizubehalten):", {
title: "Benutzer bearbeiten: " + user.username,
placeholder: "Neues Passwort...",
okText: "Weiter"
});
if (newPass === null) return;
const updates = {};
if (newPass) updates.password = newPass;
const newSeries = confirm("Serien anzeigen?");
const newMovies = confirm("Filme anzeigen?");
const newAdmin = confirm("Admin-Rechte?");
const newTechInfo = confirm("Technische Details anzeigen?");
updates.can_view_series = newSeries;
updates.can_view_movies = newMovies;
updates.is_admin = newAdmin;
updates.show_tech_info = newTechInfo;
fetch("/api/tv/users/" + userId, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(updates),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer aktualisiert", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
// TV-URL laden
function tvLoadUrl() {
fetch("/api/tv/url")
.then(r => r.json())
.then(data => {
const link = document.getElementById("tv-link");
if (link && data.url) {
link.href = data.url;
link.textContent = data.url;
}
})
.catch(() => {});
}
// === Init ===
document.addEventListener("DOMContentLoaded", () => {
tvLoadUsers();
tvLoadUrl();
loadHlsSessions();
// HLS-Sessions alle 15 Sekunden aktualisieren
setInterval(loadHlsSessions, 15000);
});
</script>
{% endblock %}