TV-App (/tv/): - Login mit bcrypt-Passwort-Hashing und DB-Sessions (30 Tage) - Home (Weiterschauen, Serien, Filme), Serien-Detail mit Staffeln - Film-Uebersicht und Detail, Fullscreen Video-Player - Suche mit Live-Ergebnissen, Watch-Progress (alle 10s gespeichert) - D-Pad/Fernbedienung-Navigation (FocusManager, Samsung Tizen Keys) - PWA: manifest.json, Service Worker, Icons fuer Handy/Tablet - Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade) Admin-Erweiterungen: - QR-Code fuer TV-App URL - User-Verwaltung (CRUD) mit Rechte-Konfiguration - Log-API: GET /api/log?lines=100&level=INFO Tizen-App (tizen-app/): - Wrapper-App fuer Samsung Smart TVs (.wgt Paket) - Einmalige Server-IP Eingabe, danach automatische Verbindung - Installationsanleitung (INSTALL.md) Bug-Fixes: - executeImport: Job-ID vor resetImport() gesichert - cursor(aiomysql.DictCursor) statt cursor(dict) - DB-Spalten width/height statt video_width/video_height Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
7.7 KiB
JavaScript
276 lines
7.7 KiB
JavaScript
/**
|
|
* VideoKonverter TV - Video-Player
|
|
* Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung
|
|
* Speichert Watch-Progress automatisch
|
|
*/
|
|
|
|
let videoEl = null;
|
|
let videoId = 0;
|
|
let videoDuration = 0;
|
|
let progressBar = null;
|
|
let timeDisplay = null;
|
|
let playBtn = null;
|
|
let controlsTimer = null;
|
|
let saveTimer = null;
|
|
let controlsVisible = true;
|
|
|
|
/**
|
|
* Player initialisieren
|
|
* @param {number} id - Video-ID
|
|
* @param {number} startPos - Startposition in Sekunden
|
|
* @param {number} duration - Video-Dauer in Sekunden (Fallback)
|
|
*/
|
|
function initPlayer(id, startPos, duration) {
|
|
videoId = id;
|
|
videoDuration = duration;
|
|
|
|
videoEl = document.getElementById("player-video");
|
|
progressBar = document.getElementById("player-progress-bar");
|
|
timeDisplay = document.getElementById("player-time");
|
|
playBtn = document.getElementById("btn-play");
|
|
|
|
if (!videoEl) return;
|
|
|
|
// Stream-URL setzen (ffmpeg-Transcoding Endpoint)
|
|
const streamUrl = `/api/library/videos/${id}/stream` +
|
|
(startPos > 0 ? `?t=${Math.floor(startPos)}` : "");
|
|
videoEl.src = streamUrl;
|
|
|
|
// Events
|
|
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
|
videoEl.addEventListener("play", onPlay);
|
|
videoEl.addEventListener("pause", onPause);
|
|
videoEl.addEventListener("ended", onEnded);
|
|
videoEl.addEventListener("loadedmetadata", () => {
|
|
if (videoEl.duration && isFinite(videoEl.duration)) {
|
|
videoDuration = videoEl.duration;
|
|
}
|
|
});
|
|
|
|
// Klick auf Video -> Play/Pause
|
|
videoEl.addEventListener("click", togglePlay);
|
|
|
|
// Controls UI
|
|
playBtn.addEventListener("click", togglePlay);
|
|
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
|
|
|
|
// Progress-Bar klickbar fuer Seeking
|
|
document.getElementById("player-progress").addEventListener("click", onProgressClick);
|
|
|
|
// Tastatur-Steuerung
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
// Maus/Touch-Bewegung -> Controls anzeigen
|
|
document.addEventListener("mousemove", showControls);
|
|
document.addEventListener("touchstart", showControls);
|
|
|
|
// Controls nach 4 Sekunden ausblenden
|
|
scheduleHideControls();
|
|
|
|
// Watch-Progress alle 10 Sekunden speichern
|
|
saveTimer = setInterval(saveProgress, 10000);
|
|
}
|
|
|
|
// === Playback-Controls ===
|
|
|
|
function togglePlay() {
|
|
if (!videoEl) return;
|
|
if (videoEl.paused) {
|
|
videoEl.play();
|
|
} else {
|
|
videoEl.pause();
|
|
}
|
|
}
|
|
|
|
function onPlay() {
|
|
if (playBtn) playBtn.innerHTML = "❚❚"; // Pause-Symbol
|
|
scheduleHideControls();
|
|
}
|
|
|
|
function onPause() {
|
|
if (playBtn) playBtn.innerHTML = "▶"; // Play-Symbol
|
|
showControls();
|
|
// Sofort speichern bei Pause
|
|
saveProgress();
|
|
}
|
|
|
|
function onEnded() {
|
|
// Video fertig -> als "completed" speichern
|
|
saveProgress(true);
|
|
// Zurueck navigieren nach 2 Sekunden
|
|
setTimeout(() => {
|
|
window.history.back();
|
|
}, 2000);
|
|
}
|
|
|
|
// === Seeking ===
|
|
|
|
function seekRelative(seconds) {
|
|
if (!videoEl) return;
|
|
const newTime = Math.max(0, Math.min(
|
|
videoEl.currentTime + seconds,
|
|
videoEl.duration || videoDuration
|
|
));
|
|
// Neue Stream-URL mit Zeitstempel
|
|
const wasPlaying = !videoEl.paused;
|
|
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
|
if (wasPlaying) videoEl.play();
|
|
showControls();
|
|
}
|
|
|
|
function onProgressClick(e) {
|
|
if (!videoEl) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
const dur = videoEl.duration || videoDuration;
|
|
if (!dur) return;
|
|
const newTime = pct * dur;
|
|
// Neue Stream-URL mit Zeitstempel
|
|
const wasPlaying = !videoEl.paused;
|
|
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
|
if (wasPlaying) videoEl.play();
|
|
showControls();
|
|
}
|
|
|
|
// === Zeit-Anzeige und Progress ===
|
|
|
|
function onTimeUpdate() {
|
|
if (!videoEl) return;
|
|
const current = videoEl.currentTime;
|
|
const dur = videoEl.duration || videoDuration;
|
|
|
|
// Progress-Bar
|
|
if (progressBar && dur > 0) {
|
|
progressBar.style.width = ((current / dur) * 100) + "%";
|
|
}
|
|
|
|
// Zeit-Anzeige
|
|
if (timeDisplay) {
|
|
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
|
}
|
|
}
|
|
|
|
function formatTime(sec) {
|
|
if (!sec || !isFinite(sec)) return "0:00";
|
|
const h = Math.floor(sec / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
const s = Math.floor(sec % 60);
|
|
if (h > 0) {
|
|
return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
|
}
|
|
return m + ":" + String(s).padStart(2, "0");
|
|
}
|
|
|
|
// === Controls Ein-/Ausblenden ===
|
|
|
|
function showControls() {
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (wrapper) wrapper.classList.remove("player-hide-controls");
|
|
controlsVisible = true;
|
|
scheduleHideControls();
|
|
}
|
|
|
|
function hideControls() {
|
|
if (!videoEl || videoEl.paused) return;
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (wrapper) wrapper.classList.add("player-hide-controls");
|
|
controlsVisible = false;
|
|
}
|
|
|
|
function scheduleHideControls() {
|
|
if (controlsTimer) clearTimeout(controlsTimer);
|
|
controlsTimer = setTimeout(hideControls, 4000);
|
|
}
|
|
|
|
// === Fullscreen ===
|
|
|
|
function toggleFullscreen() {
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (!document.fullscreenElement) {
|
|
(wrapper || document.documentElement).requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen().catch(() => {});
|
|
}
|
|
}
|
|
|
|
// === Tastatur-Steuerung ===
|
|
|
|
function onKeyDown(e) {
|
|
// Samsung Tizen Remote Keys
|
|
const keyMap = {
|
|
10009: "Escape",
|
|
10182: "Escape",
|
|
415: "Play",
|
|
19: "Pause",
|
|
413: "Stop",
|
|
417: "FastForward",
|
|
412: "Rewind",
|
|
};
|
|
const key = keyMap[e.keyCode] || e.key;
|
|
|
|
switch (key) {
|
|
case " ":
|
|
case "Enter":
|
|
case "Play":
|
|
case "Pause":
|
|
togglePlay();
|
|
e.preventDefault();
|
|
break;
|
|
case "ArrowLeft":
|
|
case "Rewind":
|
|
seekRelative(-10);
|
|
e.preventDefault();
|
|
break;
|
|
case "ArrowRight":
|
|
case "FastForward":
|
|
seekRelative(10);
|
|
e.preventDefault();
|
|
break;
|
|
case "ArrowUp":
|
|
// Lautstaerke hoch (falls vom Browser unterstuetzt)
|
|
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
|
|
showControls();
|
|
e.preventDefault();
|
|
break;
|
|
case "ArrowDown":
|
|
// Lautstaerke runter
|
|
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
|
|
showControls();
|
|
e.preventDefault();
|
|
break;
|
|
case "Escape":
|
|
case "Backspace":
|
|
case "Stop":
|
|
// Zurueck navigieren
|
|
saveProgress();
|
|
setTimeout(() => window.history.back(), 100);
|
|
e.preventDefault();
|
|
break;
|
|
case "f":
|
|
toggleFullscreen();
|
|
e.preventDefault();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// === Watch-Progress speichern ===
|
|
|
|
function saveProgress(completed) {
|
|
if (!videoId || !videoEl) return;
|
|
const pos = videoEl.currentTime || 0;
|
|
const dur = videoEl.duration || videoDuration || 0;
|
|
if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern
|
|
|
|
fetch("/tv/api/watch-progress", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
video_id: videoId,
|
|
position_sec: pos,
|
|
duration_sec: dur,
|
|
}),
|
|
}).catch(() => {}); // Fehler ignorieren (nicht kritisch)
|
|
}
|
|
|
|
// Beim Verlassen der Seite speichern
|
|
window.addEventListener("beforeunload", () => saveProgress());
|