- 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>
1608 lines
57 KiB
JavaScript
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 = "▶";
|
|
}
|
|
}, 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 = "▶";
|
|
});
|
|
}
|
|
|
|
|
|
// === 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 = '<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)">← 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)">← 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)">← 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)">← 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 || "";
|
|
}
|