docker.videokonverter/video-konverter/app/static/tv/js/player.js
data c7151e8bd1 feat: VideoKonverter v4.0.1 - UX-Verbesserungen, Batch-Thumbnails, Bugfixes
- Alphabet-Seitenleiste (A-Z) auf Serien-/Filme-Seite
- Separate Player-Buttons fuer Audio/Untertitel/Qualitaet
- Batch-Thumbnail-Generierung per Button in der Bibliothek
- Redundante Dateien in Episoden-Tabelle orange markiert
- Gesehen-Markierung per Episode/Staffel
- Genre-Filter als Select-Element statt Chips
- Fix: tvdb_episode_cache fehlende Spalten (overview, image_url)
- Fix: Login Auto-Fill-Erkennung statt Flash
- Fix: Profil-Wechsel zeigt alle User

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

536 lines
18 KiB
JavaScript

/**
* VideoKonverter TV - Video-Player v4.0
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
*/
// === State ===
let videoEl = null;
let cfg = {}; // Konfiguration aus initPlayer()
let videoInfo = null; // Audio/Subtitle-Tracks vom Server
let currentAudio = 0;
let currentSub = -1; // -1 = aus
let currentQuality = "hd";
let currentSpeed = 1.0;
let progressBar = null;
let timeDisplay = null;
let playBtn = null;
let controlsTimer = null;
let saveTimer = null;
let controlsVisible = true;
let overlayOpen = false;
let nextCountdown = null;
let episodesWatched = 0;
let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming
/**
* Player initialisieren
* @param {Object} opts - Konfiguration
*/
function initPlayer(opts) {
cfg = opts;
currentQuality = opts.streamQuality || "hd";
videoEl = document.getElementById("player-video");
progressBar = document.getElementById("player-progress-bar");
timeDisplay = document.getElementById("player-time");
playBtn = document.getElementById("btn-play");
if (!videoEl) return;
// Video-Info laden (Audio/Subtitle-Tracks)
loadVideoInfo().then(() => {
// Stream starten
setStreamUrl(opts.startPos || 0);
updatePlayerButtons();
});
// 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)) {
cfg.duration = videoEl.duration + seekOffset;
}
});
videoEl.addEventListener("click", togglePlay);
// Controls UI
playBtn.addEventListener("click", togglePlay);
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
document.getElementById("player-progress").addEventListener("click", onProgressClick);
// Einstellungen-Button
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.addEventListener("click", () => openOverlaySection(null));
// Separate Buttons: Audio, Untertitel, Qualitaet
const btnAudio = document.getElementById("btn-audio");
if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio"));
const btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.addEventListener("click", () => openOverlaySection("subs"));
const btnQuality = document.getElementById("btn-quality");
if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality"));
// Naechste-Episode-Button
const btnNext = document.getElementById("btn-next");
if (btnNext) btnNext.addEventListener("click", playNextEpisode);
// Naechste-Episode Overlay Buttons
const btnNextPlay = document.getElementById("btn-next-play");
if (btnNextPlay) btnNextPlay.addEventListener("click", playNextEpisode);
const btnNextCancel = document.getElementById("btn-next-cancel");
if (btnNextCancel) btnNextCancel.addEventListener("click", cancelNext);
// Schaust du noch?
const btnStillYes = document.getElementById("btn-still-yes");
if (btnStillYes) btnStillYes.addEventListener("click", () => {
document.getElementById("still-watching-overlay").style.display = "none";
episodesWatched = 0;
videoEl.play();
});
const btnStillNo = document.getElementById("btn-still-no");
if (btnStillNo) btnStillNo.addEventListener("click", () => {
saveProgress();
window.history.back();
});
// Tastatur-Steuerung
document.addEventListener("keydown", onKeyDown);
document.addEventListener("mousemove", showControls);
document.addEventListener("touchstart", showControls);
scheduleHideControls();
saveTimer = setInterval(saveProgress, 10000);
}
// === Video-Info laden ===
async function loadVideoInfo() {
try {
const resp = await fetch(`/api/library/videos/${cfg.videoId}/info`);
videoInfo = await resp.json();
// Bevorzugte Audio-Spur finden
if (videoInfo.audio_tracks) {
const prefIdx = videoInfo.audio_tracks.findIndex(
a => a.lang === cfg.preferredAudio);
if (prefIdx >= 0) currentAudio = prefIdx;
}
// Bevorzugte Untertitel-Spur finden
if (cfg.subtitlesEnabled && cfg.preferredSub && videoInfo.subtitle_tracks) {
const subIdx = videoInfo.subtitle_tracks.findIndex(
s => s.lang === cfg.preferredSub);
if (subIdx >= 0) currentSub = subIdx;
}
// Untertitel-Tracks als <track> hinzufuegen
if (videoInfo.subtitle_tracks) {
videoInfo.subtitle_tracks.forEach((sub, i) => {
const track = document.createElement("track");
track.kind = "subtitles";
track.src = `/api/library/videos/${cfg.videoId}/subtitles/${i}`;
track.srclang = sub.lang || "und";
track.label = langName(sub.lang) || `Spur ${i + 1}`;
if (i === currentSub) track.default = true;
videoEl.appendChild(track);
});
// Aktiven Track setzen
updateSubtitleTrack();
}
} catch (e) {
console.warn("Video-Info laden fehlgeschlagen:", e);
}
}
// === Stream-URL ===
function setStreamUrl(seekSec) {
seekOffset = seekSec || 0;
const params = new URLSearchParams({
quality: currentQuality,
audio: currentAudio,
sound: cfg.soundMode || "stereo",
});
if (seekSec > 0) params.set("t", Math.floor(seekSec));
const wasPlaying = videoEl && !videoEl.paused;
videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`;
if (wasPlaying) videoEl.play();
}
// === Playback-Controls ===
function togglePlay() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play();
else videoEl.pause();
}
function onPlay() {
if (playBtn) playBtn.innerHTML = "&#10074;&#10074;";
scheduleHideControls();
}
function onPause() {
if (playBtn) playBtn.innerHTML = "&#9654;";
showControls();
saveProgress();
}
function onEnded() {
saveProgress(true);
episodesWatched++;
// Schaust du noch? (wenn Max-Episoden erreicht)
if (cfg.autoplayMax > 0 && episodesWatched >= cfg.autoplayMax) {
document.getElementById("still-watching-overlay").style.display = "";
return;
}
// Naechste Episode
if (cfg.nextVideoId && cfg.autoplay) {
showNextEpisodeOverlay();
} else {
setTimeout(() => window.history.back(), 2000);
}
}
// === Seeking ===
function seekRelative(seconds) {
if (!videoEl) return;
const totalTime = seekOffset + videoEl.currentTime;
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
const newTime = Math.max(0, Math.min(totalTime + seconds, dur));
setStreamUrl(newTime);
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 = cfg.duration || (seekOffset + (videoEl.duration || 0));
if (!dur) return;
setStreamUrl(pct * dur);
showControls();
}
// === Zeit-Anzeige ===
function onTimeUpdate() {
if (!videoEl) return;
const current = seekOffset + videoEl.currentTime;
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
}
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 || overlayOpen) 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(() => {});
}
}
// === Einstellungen-Overlay ===
function toggleOverlay() {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
overlayOpen = !overlayOpen;
overlay.style.display = overlayOpen ? "" : "none";
if (overlayOpen) {
renderOverlay();
showControls();
}
}
function openOverlaySection(section) {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
if (overlayOpen) {
// Bereits offen -> schliessen
overlayOpen = false;
overlay.style.display = "none";
return;
}
overlayOpen = true;
overlay.style.display = "";
renderOverlay();
showControls();
if (section) {
var el = document.getElementById("overlay-" + section);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
}
function renderOverlay() {
// Audio-Spuren
const audioEl = document.getElementById("overlay-audio");
if (audioEl && videoInfo && videoInfo.audio_tracks) {
let html = "<h3>Audio</h3>";
videoInfo.audio_tracks.forEach((a, i) => {
const label = langName(a.lang) || `Spur ${i + 1}`;
const ch = a.channels > 2 ? ` (${a.channels}ch)` : "";
const active = i === currentAudio ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchAudio(${i})">${label}${ch}</button>`;
});
audioEl.innerHTML = html;
}
// Untertitel
const subsEl = document.getElementById("overlay-subs");
if (subsEl && videoInfo) {
let html = "<h3>Untertitel</h3>";
html += `<button class="overlay-option${currentSub === -1 ? ' active' : ''}" data-focusable onclick="switchSub(-1)">Aus</button>`;
if (videoInfo.subtitle_tracks) {
videoInfo.subtitle_tracks.forEach((s, i) => {
const label = langName(s.lang) || `Spur ${i + 1}`;
const active = i === currentSub ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchSub(${i})">${label}</button>`;
});
}
subsEl.innerHTML = html;
}
// Qualitaet
const qualEl = document.getElementById("overlay-quality");
if (qualEl) {
const qualities = [
["uhd", "Ultra HD"], ["hd", "HD"],
["sd", "SD"], ["low", "Niedrig"]
];
let html = "<h3>Qualit\u00e4t</h3>";
qualities.forEach(([val, label]) => {
const active = val === currentQuality ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchQuality('${val}')">${label}</button>`;
});
qualEl.innerHTML = html;
}
// Geschwindigkeit
const speedEl = document.getElementById("overlay-speed");
if (speedEl) {
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
let html = "<h3>Geschwindigkeit</h3>";
speeds.forEach(s => {
const active = s === currentSpeed ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchSpeed(${s})">${s}x</button>`;
});
speedEl.innerHTML = html;
}
}
function switchAudio(idx) {
if (idx === currentAudio) return;
currentAudio = idx;
// Neuen Stream mit anderer Audio-Spur starten
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime);
renderOverlay();
updatePlayerButtons();
}
function switchSub(idx) {
currentSub = idx;
updateSubtitleTrack();
renderOverlay();
updatePlayerButtons();
}
function updateSubtitleTrack() {
if (!videoEl || !videoEl.textTracks) return;
for (let i = 0; i < videoEl.textTracks.length; i++) {
videoEl.textTracks[i].mode = (i === currentSub) ? "showing" : "hidden";
}
}
function switchQuality(q) {
if (q === currentQuality) return;
currentQuality = q;
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime);
renderOverlay();
updatePlayerButtons();
}
function switchSpeed(s) {
currentSpeed = s;
if (videoEl) videoEl.playbackRate = s;
renderOverlay();
}
// === Naechste Episode ===
function showNextEpisodeOverlay() {
const overlay = document.getElementById("next-overlay");
if (!overlay) return;
overlay.style.display = "";
let remaining = cfg.autoplayCountdown || 10;
const countdownEl = document.getElementById("next-countdown");
nextCountdown = setInterval(() => {
remaining--;
if (countdownEl) countdownEl.textContent = remaining + "s";
if (remaining <= 0) {
clearInterval(nextCountdown);
playNextEpisode();
}
}, 1000);
if (countdownEl) countdownEl.textContent = remaining + "s";
}
function playNextEpisode() {
if (nextCountdown) clearInterval(nextCountdown);
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
}
function cancelNext() {
if (nextCountdown) clearInterval(nextCountdown);
const overlay = document.getElementById("next-overlay");
if (overlay) overlay.style.display = "none";
setTimeout(() => window.history.back(), 500);
}
// === 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;
// Overlay offen? -> Navigation im Overlay
if (overlayOpen && (key === "Escape" || key === "Backspace")) {
toggleOverlay();
e.preventDefault();
return;
}
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":
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
showControls(); e.preventDefault(); break;
case "ArrowDown":
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
showControls(); e.preventDefault(); break;
case "Escape": case "Backspace": case "Stop":
saveProgress();
setTimeout(() => window.history.back(), 100);
e.preventDefault(); break;
case "f":
toggleFullscreen(); e.preventDefault(); break;
case "s":
toggleOverlay(); e.preventDefault(); break;
case "n":
if (cfg.nextVideoId) playNextEpisode();
e.preventDefault(); break;
}
}
// === Watch-Progress speichern ===
function saveProgress(completed) {
if (!cfg.videoId || !videoEl) return;
const pos = seekOffset + (videoEl.currentTime || 0);
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
if (pos < 5 && !completed) return;
fetch("/tv/api/watch-progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_id: cfg.videoId,
position_sec: pos,
duration_sec: dur,
}),
}).catch(() => {});
}
window.addEventListener("beforeunload", () => saveProgress());
// === Button-Status aktualisieren ===
function updatePlayerButtons() {
// CC-Button: aktiv wenn Untertitel an
var btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.classList.toggle("active", currentSub >= 0);
// Quality-Badge: aktuellen Modus anzeigen
var badge = document.getElementById("quality-badge");
if (badge) {
var labels = { uhd: "4K", hd: "HD", sd: "SD", low: "LD" };
badge.textContent = labels[currentQuality] || "HD";
}
// Audio-Button: aktuelle Sprache anzeigen (Tooltip)
var btnAudio = document.getElementById("btn-audio");
if (btnAudio && videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
var lang = videoInfo.audio_tracks[currentAudio].lang;
btnAudio.title = langName(lang) || "Audio";
}
}
// === Hilfsfunktionen ===
const LANG_NAMES = {
deu: "Deutsch", eng: "English", fra: "Fran\u00e7ais",
spa: "Espa\u00f1ol", ita: "Italiano", jpn: "\u65e5\u672c\u8a9e",
kor: "\ud55c\uad6d\uc5b4", por: "Portugu\u00eas",
rus: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
zho: "\u4e2d\u6587", und: "Unbekannt",
};
function langName(code) {
return LANG_NAMES[code] || code || "";
}