/** * 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: HLS-Streaming (Browser, kein nativer Player) else { 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", 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 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 = "▶"; } }, 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 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(); }); } // === 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 = "▶"; }); }); 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 = "▶"; }); } 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 = "▶"; }); } /** 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 = "❚❚"; scheduleHideControls(); } function onPause() { if (playBtn) playBtn.innerHTML = "▶"; 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 = '"; } 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 = '"; return html; } function _renderSubOptions() { let html = '"; return html; } function _renderQualityOptions() { const qualities = [ ["uhd", "Ultra HD"], ["hd", "HD"], ["sd", "SD"], ["low", "Niedrig"] ]; let html = '"; return html; } function _renderSpeedOptions() { const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; let html = '"; 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 || ""; }