docker.videokonverter/video-konverter/app/static/tv/js/player.js
data 4f151de78c feat: VideoKonverter v4.2 - TV Admin-Center, HLS-Streaming, Startseiten-Rubriken
- TV Admin-Center (/tv-admin): HLS-Settings, Session-Monitoring, User-Verwaltung
- HLS-Streaming: ffmpeg .ts-Segmente, hls.js, GPU VAAPI, SIGSTOP/SIGCONT
- Startseite: Rubriken (Weiterschauen, Neu, Serien, Filme, Schon gesehen)
- User-Settings: Startseiten-Rubriken konfigurierbar, Watch-Threshold
- UI: Amber/Gold Accent-Farbe, Focus-Ring-Fix, Player-Buttons einheitlich
- Cache-Busting: ?v= Timestamp auf allen CSS/JS Includes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:57:48 +01:00

956 lines
32 KiB
JavaScript

/**
* VideoKonverter TV - Video-Player v4.1
* HLS-Streaming mit hls.js, kompaktes Popup-Menue statt Panel-Overlay,
* 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 popupOpen = false; // Popup-Menue offen?
let popupSection = null; // Aktive Popup-Sektion (null = Hauptmenue)
let nextCountdown = null;
let episodesWatched = 0;
// HLS-State
let hlsInstance = null; // hls.js Instanz
let hlsSessionId = null; // Aktive HLS-Session-ID
let hlsReady = false; // HLS-Playback bereit?
let hlsSeekOffset = 0; // Server-seitiger Seek: echte Position im Video
let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs
/**
* Player initialisieren
* @param {Object} opts - Konfiguration
*/
function initPlayer(opts) {
cfg = opts;
currentQuality = opts.streamQuality || "hd";
// Client-Codec-Erkennung (welche Video-Codecs kann dieser Browser?)
clientCodecs = detectSupportedCodecs();
console.info("Client-Codecs:", clientCodecs.join(", "));
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 + HLS-Stream PARALLEL starten (nicht sequentiell warten!)
const infoReady = loadVideoInfo();
startHLSStream(opts.startPos || 0);
infoReady.then(() => updatePlayerButtons());
// Events
videoEl.addEventListener("timeupdate", onTimeUpdate);
videoEl.addEventListener("play", onPlay);
videoEl.addEventListener("pause", onPause);
videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("click", togglePlay);
// Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit)
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.addEventListener("canplay", hideLoading, {once: true});
// Controls UI
playBtn.addEventListener("click", togglePlay);
const btnFs = document.getElementById("btn-fullscreen");
if (btnFs) btnFs.addEventListener("click", toggleFullscreen);
document.getElementById("player-progress").addEventListener("click", onProgressClick);
// Einstellungen-Button -> Popup-Hauptmenue
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.addEventListener("click", () => togglePopup());
// Direkt-Buttons: Audio, Untertitel, Qualitaet
const btnAudio = document.getElementById("btn-audio");
if (btnAudio) btnAudio.addEventListener("click", () => openPopupSection("audio"));
const btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.addEventListener("click", () => openPopupSection("subs"));
const btnQuality = document.getElementById("btn-quality");
if (btnQuality) btnQuality.addEventListener("click", () => openPopupSection("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);
// Fullscreen nur auf Desktop/Handy anzeigen (nicht auf Samsung TV)
if (btnFs && isTizenTV()) {
btnFs.style.display = "none";
}
scheduleHideControls();
saveTimer = setInterval(saveProgress, 10000);
}
// === Erkennung: Samsung Tizen TV ===
function isTizenTV() {
return typeof tizen !== "undefined" || /Tizen/i.test(navigator.userAgent);
}
// === 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);
});
updateSubtitleTrack();
}
} catch (e) {
console.warn("Video-Info laden fehlgeschlagen:", e);
}
}
// === Codec-Erkennung ===
/**
* Erkennt automatisch welche Video-Codecs der Browser/TV decodieren kann.
* Wird beim HLS-Start an den Server geschickt -> Server entscheidet copy vs transcode.
*
* WICHTIG: Unterscheidung zwischen nativem HLS (Tizen/Safari) und MSE (hls.js):
* - Natives HLS: canPlayType() meldet oft AV1/VP9, aber der native HLS-Player
* unterstuetzt diese Codecs NICHT zuverlaessig in fMP4-Segmenten.
* -> Konservativ: nur H.264 (+ evtl. HEVC)
* - MSE/hls.js: MediaSource.isTypeSupported() ist zuverlaessig
* -> Alle unterstuetzten Codecs melden
*/
function detectSupportedCodecs() {
const codecs = [];
const el = document.createElement("video");
const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl");
const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported;
if (!hasNativeHLS && hasMSE) {
// MSE-basiert (hls.js auf Chrome/Firefox/Edge): zuverlaessige Erkennung
if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028"')) codecs.push("h264");
if (MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"')) codecs.push("hevc");
if (MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08"')) codecs.push("av1");
if (MediaSource.isTypeSupported('video/mp4; codecs="vp09.00.10.08"')) codecs.push("vp9");
} else {
// Natives HLS (Samsung Tizen, Safari, iOS):
// Konservativ - nur H.264 melden, da AV1/VP9 in HLS-fMP4 nicht zuverlaessig
codecs.push("h264");
if (el.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')
|| el.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
codecs.push("hevc");
}
}
if (!codecs.length) codecs.push("h264");
return codecs;
}
// === Loading-Indikator ===
let loadingTimer = null;
function showLoading() {
var el = document.getElementById("player-loading");
if (el) { el.classList.remove("hidden"); el.style.display = ""; }
// Fallback: Loading nach 8 Sekunden ausblenden (falls Events nicht feuern)
clearTimeout(loadingTimer);
loadingTimer = setTimeout(hideLoading, 8000);
}
function hideLoading() {
clearTimeout(loadingTimer);
var el = document.getElementById("player-loading");
if (!el) return;
el.style.display = "none";
}
// === HLS Streaming ===
async function startHLSStream(seekSec) {
// Loading-Spinner anzeigen
showLoading();
// Vorherige Session beenden
await cleanupHLS();
// Seek-Offset merken (ffmpeg -ss schneidet serverseitig)
hlsSeekOffset = seekSec > 0 ? Math.floor(seekSec) : 0;
// Neue HLS-Session vom Server anfordern
try {
const resp = await fetch("/tv/api/hls/start", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
video_id: cfg.videoId,
quality: currentQuality,
audio: currentAudio,
sound: cfg.soundMode || "stereo",
t: hlsSeekOffset,
codecs: clientCodecs || ["h264"],
}),
});
if (!resp.ok) {
console.error("HLS Session Start fehlgeschlagen:", resp.status);
setStreamUrlLegacy(seekSec);
return;
}
const data = await resp.json();
hlsSessionId = data.session_id;
const playlistUrl = data.playlist_url;
// Retry-Zaehler fuer Netzwerkfehler
let networkRetries = 0;
const MAX_RETRIES = 3;
// HLS abspielen
if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS (Safari, Tizen)
videoEl.src = playlistUrl;
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
// hls.js Polyfill (Chrome, Firefox, Edge)
hlsInstance = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
hlsInstance.loadSource(playlistUrl);
hlsInstance.attachMedia(videoEl);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
});
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error("HLS fataler Fehler:", data.type, data.details);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR
&& networkRetries < MAX_RETRIES) {
// Netzwerkfehler -> Retry mit Backoff
networkRetries++;
console.warn("HLS Netzwerkfehler, Retry " +
networkRetries + "/" + MAX_RETRIES);
setTimeout(() => hlsInstance.startLoad(),
1000 * networkRetries);
} else {
// Zu viele Retries oder anderer Fehler -> Fallback
cleanupHLS();
setStreamUrlLegacy(seekSec);
}
}
});
} else {
// Kein HLS moeglich -> Fallback
console.warn("Weder natives HLS noch hls.js verfuegbar");
setStreamUrlLegacy(seekSec);
}
} catch (e) {
console.error("HLS Start Fehler:", e);
hideLoading();
setStreamUrlLegacy(seekSec);
}
}
/** Fallback: Altes Pipe-Streaming (fMP4 ueber StreamResponse) */
function setStreamUrlLegacy(seekSec) {
const params = new URLSearchParams({
quality: currentQuality,
audio: currentAudio,
sound: cfg.soundMode || "stereo",
});
if (seekSec > 0) params.set("t", Math.floor(seekSec));
videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
}
/** HLS aufraumen: hls.js + Server-Session beenden */
async function cleanupHLS() {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (hlsSessionId) {
// Server-Session loeschen (fire & forget)
fetch(`/tv/api/hls/${hlsSessionId}`, {method: "DELETE"}).catch(() => {});
hlsSessionId = null;
}
hlsReady = false;
hlsSeekOffset = 0;
}
// === 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 dur = getDuration();
const cur = getCurrentTime();
const newTime = Math.max(0, Math.min(cur + seconds, dur));
if (hlsSessionId) {
// HLS: nativen Seek verwenden (hls.js unterstuetzt das)
videoEl.currentTime = Math.max(0, Math.min(
videoEl.currentTime + seconds, videoEl.duration || Infinity));
showControls();
} else {
// Legacy: neuen Stream starten
startHLSStream(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 = getDuration();
if (!dur) return;
// Absolute Seek-Position im Video
const seekTo = pct * dur;
// Immer neuen HLS-Stream starten (server-seitiger Seek)
startHLSStream(seekTo);
showControls();
}
// === Zeit-Funktionen ===
function getCurrentTime() {
if (!videoEl) return 0;
// Bei HLS mit Server-Seek: videoEl.currentTime + Offset = echte Position
return hlsSeekOffset + (videoEl.currentTime || 0);
}
function getDuration() {
// Echte Gesamtdauer des Videos (nicht der HLS-Stream-Dauer)
return cfg.duration || 0;
}
function onTimeUpdate() {
if (!videoEl) return;
const current = getCurrentTime();
const dur = getDuration();
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 || popupOpen) 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(() => {});
}
}
// === Popup-Menue (ersetzt das grosse Overlay-Panel) ===
function togglePopup() {
if (popupOpen) {
closePopup();
} else {
openPopupSection(null);
}
}
function openPopupSection(section) {
const popup = document.getElementById("player-popup");
if (!popup) return;
if (popupOpen && popupSection === section) {
// Gleiche Sektion nochmal -> schliessen
closePopup();
return;
}
popupOpen = true;
popupSection = section;
popup.style.display = "";
popup.classList.add("popup-visible");
renderPopup(section);
showControls();
// Focus auf ersten Button im Popup
requestAnimationFrame(() => {
const first = popup.querySelector("[data-focusable]");
if (first) first.focus();
});
}
function closePopup() {
const popup = document.getElementById("player-popup");
if (!popup) return;
popupOpen = false;
popupSection = null;
popup.classList.remove("popup-visible");
popup.style.display = "none";
}
function renderPopup(section) {
const popup = document.getElementById("player-popup");
if (!popup) return;
let html = "";
if (!section) {
// Hauptmenue: Liste aller Optionen
html = '<div class="popup-menu">';
// Audio
const audioLabel = _currentAudioLabel();
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('audio')">
<span class="popup-item-label">Audio</span>
<span class="popup-item-value">${audioLabel}</span>
</button>`;
// Untertitel
const subLabel = currentSub >= 0 ? _currentSubLabel() : "Aus";
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('subs')">
<span class="popup-item-label">Untertitel</span>
<span class="popup-item-value">${subLabel}</span>
</button>`;
// Qualitaet
const qualLabels = {uhd: "Ultra HD", hd: "HD", sd: "SD", low: "Niedrig"};
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('quality')">
<span class="popup-item-label">Qualit\u00e4t</span>
<span class="popup-item-value">${qualLabels[currentQuality] || "HD"}</span>
</button>`;
// Geschwindigkeit
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('speed')">
<span class="popup-item-label">Geschwindigkeit</span>
<span class="popup-item-value">${currentSpeed}x</span>
</button>`;
html += "</div>";
} else if (section === "audio") {
html = _renderAudioOptions();
} else if (section === "subs") {
html = _renderSubOptions();
} else if (section === "quality") {
html = _renderQualityOptions();
} else if (section === "speed") {
html = _renderSpeedOptions();
}
popup.innerHTML = html;
}
function _currentAudioLabel() {
if (videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
const a = videoInfo.audio_tracks[currentAudio];
const ch = a.channels > 2 ? ` ${a.channels}ch` : "";
return langName(a.lang) + ch;
}
return "Spur 1";
}
function _currentSubLabel() {
if (videoInfo && videoInfo.subtitle_tracks && videoInfo.subtitle_tracks[currentSub]) {
return langName(videoInfo.subtitle_tracks[currentSub].lang);
}
return "Spur " + (currentSub + 1);
}
function _renderAudioOptions() {
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Audio</button>`;
if (videoInfo && videoInfo.audio_tracks) {
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="popup-option${active}" data-focusable onclick="switchAudio(${i})">${label}${ch}</button>`;
});
}
html += "</div>";
return html;
}
function _renderSubOptions() {
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Untertitel</button>`;
html += `<button class="popup-option${currentSub === -1 ? ' active' : ''}" data-focusable onclick="switchSub(-1)">Aus</button>`;
if (videoInfo && 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="popup-option${active}" data-focusable onclick="switchSub(${i})">${label}</button>`;
});
}
html += "</div>";
return html;
}
function _renderQualityOptions() {
const qualities = [
["uhd", "Ultra HD"], ["hd", "HD"],
["sd", "SD"], ["low", "Niedrig"]
];
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Qualit\u00e4t</button>`;
qualities.forEach(([val, label]) => {
const active = val === currentQuality ? " active" : "";
html += `<button class="popup-option${active}" data-focusable onclick="switchQuality('${val}')">${label}</button>`;
});
html += "</div>";
return html;
}
function _renderSpeedOptions() {
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Geschwindigkeit</button>`;
speeds.forEach(s => {
const active = s === currentSpeed ? " active" : "";
html += `<button class="popup-option${active}" data-focusable onclick="switchSpeed(${s})">${s}x</button>`;
});
html += "</div>";
return html;
}
// === Audio/Sub/Quality/Speed wechseln ===
function switchAudio(idx) {
if (idx === currentAudio) return;
currentAudio = idx;
// Neuen HLS-Stream mit anderer Audio-Spur starten
const currentTime = getCurrentTime();
startHLSStream(currentTime);
renderPopup(popupSection);
updatePlayerButtons();
}
function switchSub(idx) {
currentSub = idx;
updateSubtitleTrack();
renderPopup(popupSection);
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 = getCurrentTime();
startHLSStream(currentTime);
renderPopup(popupSection);
updatePlayerButtons();
}
function switchSpeed(s) {
currentSpeed = s;
if (videoEl) videoEl.playbackRate = s;
renderPopup(popupSection);
}
// === 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);
cleanupHLS();
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);
}
// === D-Pad Navigation fuer Fernbedienung ===
function _getFocusables() {
if (popupOpen) {
const popup = document.getElementById("player-popup");
return popup ? Array.from(popup.querySelectorAll("[data-focusable]")) : [];
}
// "Naechste Episode" oder "Schaust du noch" Overlay?
const nextOv = document.getElementById("next-overlay");
if (nextOv && nextOv.style.display !== "none") {
return Array.from(nextOv.querySelectorAll("[data-focusable]"));
}
const stillOv = document.getElementById("still-watching-overlay");
if (stillOv && stillOv.style.display !== "none") {
return Array.from(stillOv.querySelectorAll("[data-focusable]"));
}
// Player-Controls
const controls = document.getElementById("player-controls");
return controls ? Array.from(controls.querySelectorAll("[data-focusable]")) : [];
}
function _focusNext(direction) {
const items = _getFocusables();
if (!items.length) return false;
const cur = items.indexOf(document.activeElement);
let next;
if (direction === 1) {
next = cur < 0 ? 0 : Math.min(cur + 1, items.length - 1);
} else {
next = cur < 0 ? items.length - 1 : Math.max(cur - 1, 0);
}
items[next].focus();
return true;
}
// === 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",
// Samsung Farbtasten
403: "ColorRed", 404: "ColorGreen",
405: "ColorYellow", 406: "ColorBlue",
};
const key = keyMap[e.keyCode] || e.key;
const active = document.activeElement;
const buttonFocused = active && active.hasAttribute("data-focusable") &&
active.tagName === "BUTTON";
// --- Popup offen: D-Pad navigiert im Popup ---
if (popupOpen) {
switch (key) {
case "Escape": case "Backspace":
if (popupSection) {
// Zurueck zum Hauptmenue
openPopupSection(null);
} else {
closePopup();
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.focus();
}
e.preventDefault(); return;
case "ArrowUp":
_focusNext(-1); e.preventDefault(); return;
case "ArrowDown":
_focusNext(1); e.preventDefault(); return;
case "ArrowLeft":
if (popupSection) {
openPopupSection(null);
} else {
closePopup();
}
e.preventDefault(); return;
case "ArrowRight":
case "Enter":
if (buttonFocused) active.click();
e.preventDefault(); return;
}
}
// --- "Naechste Episode" / "Schaust du noch" Overlay ---
const nextOv = document.getElementById("next-overlay");
const stillOv = document.getElementById("still-watching-overlay");
const modalOpen = (nextOv && nextOv.style.display !== "none") ||
(stillOv && stillOv.style.display !== "none");
if (modalOpen) {
switch (key) {
case "ArrowLeft": case "ArrowRight":
_focusNext(key === "ArrowRight" ? 1 : -1);
e.preventDefault(); return;
case "Enter":
if (buttonFocused) active.click();
e.preventDefault(); return;
}
}
// --- Controls sichtbar + Button fokussiert: D-Pad navigiert ---
if (controlsVisible && buttonFocused) {
switch (key) {
case "ArrowLeft":
_focusNext(-1); showControls(); e.preventDefault(); return;
case "ArrowRight":
_focusNext(1); showControls(); e.preventDefault(); return;
case "ArrowUp":
active.blur(); showControls(); e.preventDefault(); return;
case "ArrowDown":
active.blur(); showControls(); e.preventDefault(); return;
case "Enter":
active.click(); showControls(); e.preventDefault(); return;
}
}
// --- Standard Player-Tasten ---
switch (key) {
case " ": case "Play": case "Pause":
togglePlay(); e.preventDefault(); break;
case "Enter":
if (!controlsVisible) {
showControls();
if (playBtn) playBtn.focus();
} else {
togglePlay();
}
e.preventDefault(); break;
case "ArrowLeft": case "Rewind":
seekRelative(-10); showControls(); e.preventDefault(); break;
case "ArrowRight": case "FastForward":
seekRelative(10); showControls(); e.preventDefault(); break;
case "ArrowUp":
if (!controlsVisible) {
showControls();
if (playBtn) playBtn.focus();
} else {
if (playBtn) playBtn.focus();
showControls();
}
e.preventDefault(); break;
case "ArrowDown":
if (!controlsVisible) {
showControls();
if (playBtn) playBtn.focus();
} else {
if (playBtn) playBtn.focus();
showControls();
}
e.preventDefault(); break;
case "Escape": case "Backspace": case "Stop":
saveProgress();
cleanupHLS();
setTimeout(() => window.history.back(), 100);
e.preventDefault(); break;
case "f":
toggleFullscreen(); e.preventDefault(); break;
case "s":
togglePopup(); e.preventDefault(); break;
case "n":
if (cfg.nextVideoId) playNextEpisode();
e.preventDefault(); break;
// Samsung Farbtasten: Direkt-Zugriff auf Popup-Sektionen
case "ColorRed":
openPopupSection("audio"); e.preventDefault(); break;
case "ColorGreen":
openPopupSection("subs"); e.preventDefault(); break;
case "ColorYellow":
openPopupSection("quality"); e.preventDefault(); break;
case "ColorBlue":
openPopupSection("speed"); e.preventDefault(); break;
}
}
// === Watch-Progress speichern ===
function saveProgress(completed) {
if (!cfg.videoId || !videoEl) return;
const pos = getCurrentTime();
const dur = getDuration();
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();
cleanupHLS();
});
// === 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 || "";
}