docker.videokonverter/video-konverter/app/static/tv/js/player.js
data 78368db582 fix: PWA Direct-Play statt HLS + Template-Fix series_detail
- Browser/PWA nutzt jetzt direkte MP4-Wiedergabe mit Range-Requests
- Codec-Pruefung (H.264/HEVC/AV1) mit automatischem HLS-Fallback
- direct_play_url zur Library Video-Info-Route hinzugefuegt
- Doppeltes endblock in series_detail.html entfernt (500er Fehler)

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

1608 lines
57 KiB
JavaScript

/**
* VideoKonverter TV - Video-Player v5.0
* VKNative Bridge fuer Direct-Play (Tizen AVPlay / Android ExoPlayer),
* HLS-Streaming mit hls.js als Fallback,
* kompaktes Popup-Menue, 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
let hlsRetryCount = 0; // Retry-Zaehler fuer gesamten Stream-Start
let loadingTimeout = null; // Timeout fuer Loading-Spinner
// Native Player State (VKNative Bridge: Tizen AVPlay / Android ExoPlayer)
let useNativePlayer = false; // VKNative Direct-Play aktiv?
let nativePlayStarted = false; // VKNative _vkOnReady wurde aufgerufen?
let nativePlayTimeout = null; // Master-Timeout fuer VKNative-Start
// Legacy AVPlay Direct-Play State (Rueckwaerts-Kompatibilitaet)
let useDirectPlay = false; // Alter AVPlayBridge-Pfad aktiv?
/**
* 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;
// Prioritaet 1: VKNative Bridge (Tizen AVPlay / Android ExoPlayer)
if (window.VKNative) {
console.info("[Player] VKNative erkannt (Platform: " + window.VKNative.platform + ")");
showLoading();
// Master-Timeout: Falls VKNative nach 15s nicht gestartet hat -> HLS Fallback
nativePlayTimeout = setTimeout(function() {
if (!nativePlayStarted) {
console.warn("[Player] VKNative Master-Timeout (15s) - Fallback auf HLS");
_cleanupNativePlayer();
_nativeFallbackToHLS(opts.startPos || 0);
}
}, 15000);
_tryNativeDirectPlay(opts.startPos || 0);
}
// Prioritaet 2: Legacy AVPlayBridge (alte Tizen-App ohne VKNative)
else if (isTizenTV() && typeof AVPlayBridge !== "undefined" && AVPlayBridge.init()) {
showLoading();
_tryDirectPlay(opts.startPos || 0).then(success => {
if (!success) {
console.info("[Player] AVPlay Fallback -> HLS");
useDirectPlay = false;
const infoReady = loadVideoInfo();
startHLSStream(opts.startPos || 0);
infoReady.then(() => updatePlayerButtons());
}
});
}
// Prioritaet 3: Browser/PWA - Direct-Play (MP4) bevorzugt, HLS als Fallback
else {
showLoading();
const startPos = opts.startPos || 0;
loadVideoInfo().then(() => {
updatePlayerButtons();
_tryBrowserDirectPlay(startPos);
}).catch(() => {
startHLSStream(startPos);
});
}
// 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", onPlaying);
videoEl.addEventListener("canplay", hideLoading);
videoEl.addEventListener("loadeddata", hideLoading);
// Video-Error: Automatisch Retry mit Fallback
videoEl.addEventListener("error", onVideoError);
// 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();
goBack();
});
// 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() {
// VKNative Bridge: Codec-Liste vom nativen Player abfragen
if (window.VKNative && window.VKNative.getSupportedVideoCodecs) {
var nativeCodecs = window.VKNative.getSupportedVideoCodecs();
if (nativeCodecs && nativeCodecs.length) {
console.info("[Player] VKNative Video-Codecs:", nativeCodecs.join(", "));
return nativeCodecs;
}
}
const codecs = [];
const el = document.createElement("video");
const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl");
const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported;
if (hasMSE) {
// MSE-basiert: zuverlaessige Codec-Erkennung
// (auch auf Tizen, da wir hls.js dem nativen Player vorziehen)
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 {
// Kein MSE: konservativ nur H.264 + HEVC melden
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.classList.remove("fade-out");
el.style.display = "";
}
// Fallback: Loading nach 8 Sekunden ausblenden und Controls zeigen
clearTimeout(loadingTimer);
loadingTimer = setTimeout(function() {
hideLoading();
// Falls Video noch nicht spielt: Controls anzeigen damit User manuell starten kann
if (videoEl && videoEl.paused) {
showControls();
if (playBtn) playBtn.innerHTML = "&#9654;";
}
}, 8000);
}
function hideLoading() {
clearTimeout(loadingTimer);
clearTimeout(loadingTimeout);
loadingTimeout = null;
var el = document.getElementById("player-loading");
if (!el || el.classList.contains("fade-out")) return;
// Sanfter Uebergang: Loading-Overlay blendet ueber 1.5s aus
// Puffert gleichzeitig das Video waehrend der Ueberblendung
el.classList.add("fade-out");
setTimeout(function() {
el.style.display = "none";
}, 1600);
}
function onPlaying() {
hideLoading();
hlsRetryCount = 0; // Reset bei erfolgreichem Start
}
function onVideoError() {
// Video-Element Fehler: Retry oder Fallback
var err = videoEl.error;
var msg = err ? "Video-Fehler: Code " + err.code : "Video-Fehler";
console.error(msg, err);
if (hlsRetryCount < 2) {
hlsRetryCount++;
if (typeof showToast === "function")
showToast("Stream-Fehler, Retry " + hlsRetryCount + "/2...", "error");
var seekPos = getCurrentTime();
setTimeout(function() { startHLSStream(seekPos); }, 1000 * hlsRetryCount);
} else {
if (typeof showToast === "function")
showToast("Video konnte nicht gestartet werden", "error");
hideLoading();
}
}
// === AVPlay Direct-Play ===
/**
* Versucht Direct-Play via AVPlay (nur auf Tizen).
* Laedt erweiterte Video-Info vom Server und prueft Kompatibilitaet.
* @returns {boolean} true wenn Direct-Play gestartet wurde
*/
async function _tryDirectPlay(startPosSec) {
try {
// Erweiterte Video-Info laden (inkl. audio_codecs, video_codec_normalized)
const resp = await fetch(`/tv/api/video-info/${cfg.videoId}`);
if (!resp.ok) return false;
const info = await resp.json();
videoInfo = info; // Video-Info global setzen
// Bevorzugte Audio-/Untertitel-Spur finden
if (info.audio_tracks) {
const prefIdx = info.audio_tracks.findIndex(
a => a.lang === cfg.preferredAudio);
if (prefIdx >= 0) currentAudio = prefIdx;
}
if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) {
const subIdx = info.subtitle_tracks.findIndex(
s => s.lang === cfg.preferredSub);
if (subIdx >= 0) currentSub = subIdx;
}
// AVPlay Kompatibilitaet pruefen
if (!AVPlayBridge.canPlay(info)) {
console.info("[Player] Direct-Play nicht kompatibel");
return false;
}
// Direct-Play starten
useDirectPlay = true;
const directUrl = info.direct_play_url;
if (!directUrl) return false;
// Video-Element verstecken, AVPlay-Object anzeigen
if (videoEl) videoEl.style.display = "none";
const ok = AVPlayBridge.play(directUrl, {
seekMs: Math.floor(startPosSec * 1000),
onReady: () => {
hideLoading();
showControls();
scheduleHideControls();
},
onTimeUpdate: (ms) => {
// Progress-Bar und Zeit-Anzeige aktualisieren
const current = ms / 1000;
const dur = getDuration();
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
}
if (timeDisplay) {
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
}
},
onComplete: () => {
onEnded();
},
onError: (err) => {
console.error("[Player] AVPlay Fehler:", err);
if (typeof showToast === "function")
showToast("Direct-Play Fehler, wechsle zu HLS...", "error");
// Fallback auf HLS
_cleanupDirectPlay();
useDirectPlay = false;
if (videoEl) videoEl.style.display = "";
startHLSStream(startPosSec);
},
onBuffering: (buffering) => {
if (buffering) showLoading();
else hideLoading();
},
});
if (!ok) {
_cleanupDirectPlay();
return false;
}
updatePlayerButtons();
return true;
} catch (e) {
console.error("[Player] Direct-Play Init fehlgeschlagen:", e);
_cleanupDirectPlay();
return false;
}
}
/** AVPlay Direct-Play bereinigen */
function _cleanupDirectPlay() {
if (typeof AVPlayBridge !== "undefined") {
AVPlayBridge.stop();
}
useDirectPlay = false;
if (videoEl) videoEl.style.display = "";
}
// === VKNative Direct-Play ===
/**
* Versucht Direct-Play ueber VKNative Bridge (Tizen AVPlay / Android ExoPlayer).
* Laedt Video-Info und prueft Kompatibilitaet. Faellt bei Fehler auf HLS zurueck.
*/
async function _tryNativeDirectPlay(startPosSec) {
console.info("[Player] _tryNativeDirectPlay start (videoId=" + cfg.videoId + ", startPos=" + startPosSec + ")");
try {
// Erweiterte Video-Info laden
console.info("[Player] Lade Video-Info...");
var resp = await fetch("/tv/api/video-info/" + cfg.videoId);
console.info("[Player] Video-Info Response: HTTP " + resp.status);
if (!resp.ok) {
console.warn("[Player] Video-Info nicht ladbar (HTTP " + resp.status + ")");
_nativeFallbackToHLS(startPosSec);
return;
}
var info = await resp.json();
videoInfo = info;
console.info("[Player] Video-Info geladen: " + (info.video_codec_normalized || "?") +
"/" + (info.container || "?") + ", Audio: " + (info.audio_codecs || []).join(","));
// Bevorzugte Audio-/Untertitel-Spur finden
if (info.audio_tracks) {
var prefIdx = info.audio_tracks.findIndex(
function(a) { return a.lang === cfg.preferredAudio; });
if (prefIdx >= 0) currentAudio = prefIdx;
}
if (cfg.subtitlesEnabled && cfg.preferredSub && info.subtitle_tracks) {
var subIdx = info.subtitle_tracks.findIndex(
function(s) { return s.lang === cfg.preferredSub; });
if (subIdx >= 0) currentSub = subIdx;
}
// Untertitel als <track> hinzufuegen
if (info.subtitle_tracks) {
info.subtitle_tracks.forEach(function(sub, i) {
var 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();
}
// VKNative Kompatibilitaets-Pruefung
var canPlay = window.VKNative.canDirectPlay(info);
console.info("[Player] VKNative canDirectPlay: " + canPlay);
if (!canPlay) {
console.info("[Player] VKNative: Codec nicht kompatibel -> HLS Fallback");
_nativeFallbackToHLS(startPosSec);
return;
}
// VKNative Callbacks registrieren
window._vkOnReady = function() {
nativePlayStarted = true;
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
hideLoading();
showControls();
scheduleHideControls();
console.info("[Player] VKNative Direct-Play gestartet");
};
window._vkOnTimeUpdate = function(ms) {
var current = ms / 1000;
var dur = getDuration();
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
}
if (timeDisplay) {
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
}
};
window._vkOnComplete = function() {
onEnded();
};
window._vkOnError = function(msg) {
console.error("[Player] VKNative Fehler:", msg);
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
if (typeof showToast === "function")
showToast("Direct-Play Fehler, wechsle zu HLS...", "error");
_cleanupNativePlayer();
startHLSStream(startPosSec);
};
window._vkOnBuffering = function(buffering) {
// Nur Loading-Text aendern, nicht den ganzen Spinner neu starten
// (wuerde den Master-Timeout zuruecksetzen)
var el = document.getElementById("player-loading");
if (el) {
var textEl = el.querySelector(".player-loading-text");
if (textEl) textEl.textContent = buffering ? "Puffert..." : "Stream wird geladen...";
}
};
window._vkOnPlayStateChanged = function(playing) {
if (playing) {
nativePlayStarted = true;
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
onPlay();
} else {
onPause();
}
};
// Return/Back-Taste auf Fernbedienung -> zurueck ins Menue navigieren
window._vkOnStopped = function() {
saveProgress();
_cleanupNativePlayer();
goBack();
};
// Direct-Play starten
var directUrl = info.direct_play_url;
console.info("[Player] Direct-Play URL: " + directUrl);
if (!directUrl) {
console.warn("[Player] Keine Direct-Play-URL vorhanden");
_nativeFallbackToHLS(startPosSec);
return;
}
// Stream-Token holen (fuer AVPlay, das keinen Cookie-Jar hat)
try {
var tokenResp = await fetch("/tv/api/stream-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ video_id: cfg.videoId }),
});
if (tokenResp.ok) {
var tokenData = await tokenResp.json();
if (tokenData.token) {
directUrl += (directUrl.indexOf("?") >= 0 ? "&" : "?") +
"token=" + encodeURIComponent(tokenData.token);
console.info("[Player] Stream-Token angehaengt");
}
}
} catch (tokenErr) {
console.warn("[Player] Stream-Token Fehler (ignoriert):", tokenErr);
}
useNativePlayer = true;
console.info("[Player] VKNative.play() aufrufen...");
var ok = window.VKNative.play(directUrl, info, {
seekMs: Math.floor(startPosSec * 1000)
});
console.info("[Player] VKNative.play() Ergebnis: " + ok);
if (!ok) {
console.warn("[Player] VKNative.play() fehlgeschlagen");
_cleanupNativePlayer();
_nativeFallbackToHLS(startPosSec);
return;
}
updatePlayerButtons();
} catch (e) {
console.error("[Player] VKNative Init fehlgeschlagen:", e);
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
_cleanupNativePlayer();
_nativeFallbackToHLS(startPosSec);
}
}
/** VKNative bereinigen */
function _cleanupNativePlayer() {
console.info("[Player] _cleanupNativePlayer()");
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
if (window.VKNative) {
try { window.VKNative.stop(); } catch (e) { console.warn("[Player] VKNative.stop() Fehler:", e); }
}
useNativePlayer = false;
nativePlayStarted = false;
// Callbacks entfernen
window._vkOnReady = null;
window._vkOnTimeUpdate = null;
window._vkOnComplete = null;
window._vkOnError = null;
window._vkOnBuffering = null;
window._vkOnPlayStateChanged = null;
window._vkOnStopped = null;
}
/** Fallback von VKNative auf HLS */
function _nativeFallbackToHLS(startPosSec) {
console.info("[Player] Fallback auf HLS (startPos=" + startPosSec + ")");
clearTimeout(nativePlayTimeout);
nativePlayTimeout = null;
useNativePlayer = false;
nativePlayStarted = false;
if (videoEl) videoEl.style.display = "";
var infoReady = loadVideoInfo();
startHLSStream(startPosSec);
infoReady.then(function() { updatePlayerButtons(); });
}
// === Browser Direct-Play (MP4 mit Range-Requests) ===
/**
* Versucht direkte MP4-Wiedergabe im Browser (PWA).
* Faellt auf HLS zurueck wenn Codec nicht unterstuetzt oder Fehler.
*/
function _tryBrowserDirectPlay(seekSec) {
if (!videoInfo || !videoInfo.direct_play_url) {
console.info("[Player] Keine Direct-Play-URL, Fallback auf HLS");
startHLSStream(seekSec);
return;
}
var directUrl = videoInfo.direct_play_url;
console.info("[Player] Browser Direct-Play: " + directUrl);
// Pruefen ob der Browser den Codec abspielen kann
var codec = (videoInfo.video_codec || "").toLowerCase();
var canPlay = false;
if (codec === "h264" || codec === "avc1") {
canPlay = !!videoEl.canPlayType('video/mp4; codecs="avc1.640028"');
} else if (codec === "hevc" || codec === "h265" || codec === "hvc1") {
canPlay = !!videoEl.canPlayType('video/mp4; codecs="hvc1.1.6.L120"');
} else if (codec === "av1") {
canPlay = !!videoEl.canPlayType('video/mp4; codecs="av01.0.08M.08"');
} else {
// Unbekannter Codec - einfach versuchen
canPlay = true;
}
if (!canPlay) {
console.info("[Player] Browser unterstuetzt Codec '" + codec + "' nicht, Fallback auf HLS");
startHLSStream(seekSec);
return;
}
// Direct-Play starten
useDirectPlay = true;
videoEl.src = directUrl;
// Seek-Position setzen sobald Metadaten geladen
if (seekSec > 0) {
videoEl.addEventListener("loadedmetadata", function() {
videoEl.currentTime = seekSec;
}, { once: true });
}
// Fehler-Handler: Bei Fehler auf HLS wechseln
var directErrorHandler = function(e) {
console.warn("[Player] Direct-Play Fehler, wechsle zu HLS:", e);
videoEl.removeEventListener("error", directErrorHandler);
useDirectPlay = false;
videoEl.removeAttribute("src");
videoEl.load();
startHLSStream(seekSec);
};
videoEl.addEventListener("error", directErrorHandler, { once: true });
// Abspielen sobald bereit
videoEl.addEventListener("canplay", function() {
videoEl.removeEventListener("error", directErrorHandler);
}, { once: true });
videoEl.play().catch(function(e) {
console.warn("[Player] Autoplay blockiert (Direct-Play):", e);
hideLoading();
showControls();
if (playBtn) playBtn.innerHTML = "&#9654;";
});
}
// === 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);
if (typeof showToast === "function")
showToast("HLS-Start fehlgeschlagen (HTTP " + resp.status + ")", "error");
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 - hls.js bevorzugen (bessere A/V-Sync-Korrektur
// als native HLS-Player, besonders bei fMP4-Remux von WebM-Quellen)
if (typeof Hls !== "undefined" && Hls.isSupported()) {
// hls.js (bevorzugt - funktioniert zuverlaessig auf allen Plattformen)
hlsInstance = new Hls({
maxBufferLength: 60, // 60s Vorpuffer (mehr Reserve)
maxMaxBufferLength: 120, // Max 120s vorpuffern
maxBufferSize: 200 * 1024 * 1024, // 200 MB RAM-Limit
backBufferLength: 30, // Bereits gesehene 30s behalten
startLevel: -1,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 10000,
maxLoadTimeMs: 30000, // 30s fuer grosse Segmente (AV1 copy: 20-34 MB)
timeoutRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
},
},
});
hlsInstance.loadSource(playlistUrl);
hlsInstance.attachMedia(videoEl);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(e => {
console.warn("Autoplay blockiert:", e);
hideLoading();
showControls();
if (playBtn) playBtn.innerHTML = "&#9654;";
});
});
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++;
if (typeof showToast === "function")
showToast("Netzwerkfehler, Retry " +
networkRetries + "/" + MAX_RETRIES + "...", "error");
setTimeout(() => hlsInstance.startLoad(),
1000 * networkRetries);
} else {
// Zu viele Retries oder anderer Fehler -> Fallback
if (typeof showToast === "function")
showToast("Stream-Fehler: " + data.details, "error");
cleanupHLS();
setStreamUrlLegacy(seekSec);
}
}
});
} else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
// Nativer HLS-Player (Fallback wenn hls.js nicht verfuegbar)
videoEl.src = playlistUrl;
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(e => {
console.warn("Autoplay blockiert (nativ):", e);
hideLoading();
showControls();
if (playBtn) playBtn.innerHTML = "&#9654;";
});
} else {
// Kein HLS moeglich -> Fallback
console.warn("Weder hls.js noch natives HLS verfuegbar");
setStreamUrlLegacy(seekSec);
}
} catch (e) {
console.error("HLS Start Fehler:", e);
if (typeof showToast === "function")
showToast("Stream-Start fehlgeschlagen: " + e.message, "error");
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(e => {
console.warn("Autoplay blockiert (Legacy):", e);
hideLoading();
showControls();
if (playBtn) playBtn.innerHTML = "&#9654;";
});
}
/** 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 (useNativePlayer && window.VKNative) {
window.VKNative.togglePlay();
// PlayStateChanged-Callback aktualisiert Icon automatisch
return;
}
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
AVPlayBridge.togglePlay();
if (AVPlayBridge.isPlaying()) onPlay();
else onPause();
return;
}
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();
}
async function onEnded() {
// Fortschritt + Watch-Status speichern (wartet auf API-Antwort)
await 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;
}
// Player bereinigen
if (useNativePlayer) _cleanupNativePlayer();
if (useDirectPlay) _cleanupDirectPlay();
cleanupHLS();
// Zurueck zur Seriendetail-Seite navigieren
if (cfg.seriesDetailUrl) {
if (cfg.nextVideoId && cfg.autoplay) {
// Autoplay: Serie mit Countdown auf naechster Episode anzeigen
window.location.href = cfg.seriesDetailUrl +
"?post_play=1&next_video=" + cfg.nextVideoId +
"&countdown=" + (cfg.autoplayCountdown || 10);
} else {
// Kein Autoplay: Serie zeigen, zur letzten Episode scrollen
window.location.href = cfg.seriesDetailUrl +
"?last_watched=" + cfg.videoId;
}
} else {
// Kein Serien-Kontext (Film etc.) -> einfach zurueck
goBack();
}
}
// === Seeking ===
function seekRelative(seconds) {
if (useNativePlayer && window.VKNative) {
let cur = getCurrentTime();
let dur = getDuration();
let newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000));
window.VKNative.seek(newMs);
showControls();
return;
}
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
const cur = getCurrentTime();
const dur = getDuration();
const newMs = Math.max(0, Math.min((cur + seconds) * 1000, dur * 1000));
AVPlayBridge.seek(newMs);
showControls();
return;
}
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) {
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;
const seekTo = pct * dur;
if (useNativePlayer && window.VKNative) {
window.VKNative.seek(seekTo * 1000);
showControls();
return;
}
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
AVPlayBridge.seek(seekTo * 1000);
showControls();
return;
}
if (!videoEl) return;
// HLS: Immer neuen Stream starten (server-seitiger Seek)
startHLSStream(seekTo);
showControls();
}
// === Zeit-Funktionen ===
function getCurrentTime() {
if (useNativePlayer && window.VKNative) {
return window.VKNative.getCurrentTime() / 1000; // ms -> sec
}
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
return AVPlayBridge.getCurrentTime() / 1000; // ms -> sec
}
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() {
// Bei VKNative: Controls ausblenden wenn abgespielt wird
if (useNativePlayer && window.VKNative) {
if (!window.VKNative.isPlaying() || popupOpen) return;
} else 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;
const currentTime = getCurrentTime();
if (useNativePlayer && window.VKNative) {
// VKNative: Audio-Track wechseln versuchen
var ok = window.VKNative.setAudioTrack(idx);
if (ok) {
// Erfolg (z.B. Android ExoPlayer kann Track direkt wechseln)
renderPopup(popupSection);
updatePlayerButtons();
return;
}
// Nicht moeglich (z.B. Tizen AVPlay) -> HLS Fallback
console.info("[Player] VKNative Audio-Wechsel nicht moeglich -> HLS Fallback");
_cleanupNativePlayer();
if (videoEl) videoEl.style.display = "";
}
if (useDirectPlay) {
console.info("[Player] Audio-Wechsel -> HLS Fallback");
_cleanupDirectPlay();
useDirectPlay = false;
if (videoEl) videoEl.style.display = "";
}
// Neuen HLS-Stream mit anderer Audio-Spur starten
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 (useNativePlayer && window.VKNative) {
window.VKNative.setPlaybackSpeed(s);
}
if (videoEl) videoEl.playbackRate = s;
renderPopup(popupSection);
}
// === Navigation ===
/** Zurueck mit Fade-Out-Animation */
function goBack() {
var wrapper = document.getElementById("player-wrapper");
if (wrapper) {
wrapper.style.transition = "opacity 0.3s ease";
wrapper.style.opacity = "0";
}
setTimeout(function() { window.history.back(); }, 300);
}
// === 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";
}
async function playNextEpisode() {
if (nextCountdown) clearInterval(nextCountdown);
if (useNativePlayer) _cleanupNativePlayer();
if (useDirectPlay) _cleanupDirectPlay();
await 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";
goBack();
}
// === 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 + Android TV Media 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",
// Android TV Media Keys
85: "Play", // KEYCODE_MEDIA_PLAY_PAUSE
126: "Play", // KEYCODE_MEDIA_PLAY
127: "Pause", // KEYCODE_MEDIA_PAUSE
86: "Stop", // KEYCODE_MEDIA_STOP
87: "FastForward", // KEYCODE_MEDIA_NEXT
88: "Rewind", // KEYCODE_MEDIA_PREVIOUS
90: "FastForward", // KEYCODE_MEDIA_FAST_FORWARD
89: "Rewind", // KEYCODE_MEDIA_REWIND
};
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();
if (useNativePlayer) _cleanupNativePlayer();
if (useDirectPlay) _cleanupDirectPlay();
cleanupHLS();
goBack();
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 && !useDirectPlay && !useNativePlayer)) return;
const dur = getDuration();
// Bei completed: Position = Duration (garantiert ueber Schwelle)
const pos = completed ? dur : getCurrentTime();
if (pos < 5 && !completed) return;
const payload = {
video_id: cfg.videoId,
position_sec: pos,
duration_sec: dur,
};
if (completed) payload.completed = true;
return fetch("/tv/api/watch-progress", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
}).catch(() => {});
}
window.addEventListener("beforeunload", () => {
// Fortschritt per sendBeacon speichern (zuverlaessig beim Seitenabbau)
if (cfg.videoId && (videoEl || useDirectPlay || useNativePlayer)) {
const dur = getDuration();
const pos = getCurrentTime();
if (pos >= 5) {
navigator.sendBeacon("/tv/api/watch-progress",
new Blob([JSON.stringify({
video_id: cfg.videoId,
position_sec: pos,
duration_sec: dur,
})], {type: "application/json"}));
}
}
// VKNative bereinigen
if (useNativePlayer && window.VKNative) {
try { window.VKNative.stop(); } catch (e) { /* ignorieren */ }
}
// Legacy AVPlay bereinigen
if (useDirectPlay && typeof AVPlayBridge !== "undefined") {
AVPlayBridge.stop();
}
// HLS-Session per sendBeacon beenden (fetch wird beim Seitenabbau abgebrochen)
if (hlsSessionId) {
navigator.sendBeacon(`/tv/api/hls/${hlsSessionId}/stop`, "");
}
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
});
// === 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 || "";
}