Thumbnails: - Negative Zaehlung gefixt (-23 von 5789): INNER JOIN statt separate COUNT - Verwaiste Thumbnail-Eintraege werden automatisch bereinigt - TVDB-Bilder werden lokal heruntergeladen statt extern verlinkt - Template nutzt nur noch lokale API, keine externen TVDB-URLs - Cache-Control: Thumbnails werden 7 Tage gecacht (Middleware ueberschreibt nicht mehr) - Fortschrittsbalken ins globale Progress-System verschoben (Thumbnails + Auto-Match) Watch-Status: - Feldnamen-Bug gefixt: position/duration -> position_sec/duration_sec - saveProgress(completed) setzt Position=Duration bei Video-Ende - Backend wertet completed-Flag aus Player: - Error-Recovery: Auto-Retry bei Video-Fehlern (2x) - Toast-Benachrichtigungen bei Stream-Fehlern (HLS, Netzwerk, Fallback) - onPlaying() Reset des Retry-Zaehlers Transcoding: - Neue Einstellung "Immer transcodieren" (force_transcode) im TV-Admin - Erzwingt H.264+AAC Transcoding fuer maximale Client-Kompatibilitaet - Kein Copy-Modus wenn aktiviert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
997 lines
34 KiB
JavaScript
997 lines
34 KiB
JavaScript
/**
|
|
* VideoKonverter TV - Video-Player v4.1
|
|
* HLS-Streaming mit hls.js, kompaktes Popup-Menue statt Panel-Overlay,
|
|
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
|
|
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
|
|
*/
|
|
|
|
// === State ===
|
|
let videoEl = null;
|
|
let cfg = {}; // Konfiguration aus initPlayer()
|
|
let videoInfo = null; // Audio/Subtitle-Tracks vom Server
|
|
let currentAudio = 0;
|
|
let currentSub = -1; // -1 = aus
|
|
let currentQuality = "hd";
|
|
let currentSpeed = 1.0;
|
|
let progressBar = null;
|
|
let timeDisplay = null;
|
|
let playBtn = null;
|
|
let controlsTimer = null;
|
|
let saveTimer = null;
|
|
let controlsVisible = true;
|
|
let popupOpen = false; // Popup-Menue offen?
|
|
let popupSection = null; // Aktive Popup-Sektion (null = Hauptmenue)
|
|
let nextCountdown = null;
|
|
let episodesWatched = 0;
|
|
|
|
// HLS-State
|
|
let hlsInstance = null; // hls.js Instanz
|
|
let hlsSessionId = null; // Aktive HLS-Session-ID
|
|
let hlsReady = false; // HLS-Playback bereit?
|
|
let hlsSeekOffset = 0; // Server-seitiger Seek: echte Position im Video
|
|
let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs
|
|
let hlsRetryCount = 0; // Retry-Zaehler fuer gesamten Stream-Start
|
|
let loadingTimeout = null; // Timeout fuer Loading-Spinner
|
|
|
|
/**
|
|
* Player initialisieren
|
|
* @param {Object} opts - Konfiguration
|
|
*/
|
|
function initPlayer(opts) {
|
|
cfg = opts;
|
|
currentQuality = opts.streamQuality || "hd";
|
|
|
|
// Client-Codec-Erkennung (welche Video-Codecs kann dieser Browser?)
|
|
clientCodecs = detectSupportedCodecs();
|
|
console.info("Client-Codecs:", clientCodecs.join(", "));
|
|
|
|
videoEl = document.getElementById("player-video");
|
|
progressBar = document.getElementById("player-progress-bar");
|
|
timeDisplay = document.getElementById("player-time");
|
|
playBtn = document.getElementById("btn-play");
|
|
|
|
if (!videoEl) return;
|
|
|
|
// Video-Info + HLS-Stream PARALLEL starten (nicht sequentiell warten!)
|
|
const infoReady = loadVideoInfo();
|
|
startHLSStream(opts.startPos || 0);
|
|
infoReady.then(() => updatePlayerButtons());
|
|
|
|
// Events
|
|
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
|
videoEl.addEventListener("play", onPlay);
|
|
videoEl.addEventListener("pause", onPause);
|
|
videoEl.addEventListener("ended", onEnded);
|
|
videoEl.addEventListener("click", togglePlay);
|
|
// Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit)
|
|
videoEl.addEventListener("playing", onPlaying);
|
|
videoEl.addEventListener("canplay", hideLoading, {once: true});
|
|
// 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();
|
|
window.history.back();
|
|
});
|
|
|
|
// Tastatur-Steuerung
|
|
document.addEventListener("keydown", onKeyDown);
|
|
document.addEventListener("mousemove", showControls);
|
|
document.addEventListener("touchstart", showControls);
|
|
|
|
// Fullscreen nur auf Desktop/Handy anzeigen (nicht auf Samsung TV)
|
|
if (btnFs && isTizenTV()) {
|
|
btnFs.style.display = "none";
|
|
}
|
|
|
|
scheduleHideControls();
|
|
saveTimer = setInterval(saveProgress, 10000);
|
|
}
|
|
|
|
// === Erkennung: Samsung Tizen TV ===
|
|
|
|
function isTizenTV() {
|
|
return typeof tizen !== "undefined" || /Tizen/i.test(navigator.userAgent);
|
|
}
|
|
|
|
// === Video-Info laden ===
|
|
|
|
async function loadVideoInfo() {
|
|
try {
|
|
const resp = await fetch(`/api/library/videos/${cfg.videoId}/info`);
|
|
videoInfo = await resp.json();
|
|
|
|
// Bevorzugte Audio-Spur finden
|
|
if (videoInfo.audio_tracks) {
|
|
const prefIdx = videoInfo.audio_tracks.findIndex(
|
|
a => a.lang === cfg.preferredAudio);
|
|
if (prefIdx >= 0) currentAudio = prefIdx;
|
|
}
|
|
|
|
// Bevorzugte Untertitel-Spur finden
|
|
if (cfg.subtitlesEnabled && cfg.preferredSub && videoInfo.subtitle_tracks) {
|
|
const subIdx = videoInfo.subtitle_tracks.findIndex(
|
|
s => s.lang === cfg.preferredSub);
|
|
if (subIdx >= 0) currentSub = subIdx;
|
|
}
|
|
|
|
// Untertitel-Tracks als <track> hinzufuegen
|
|
if (videoInfo.subtitle_tracks) {
|
|
videoInfo.subtitle_tracks.forEach((sub, i) => {
|
|
const track = document.createElement("track");
|
|
track.kind = "subtitles";
|
|
track.src = `/api/library/videos/${cfg.videoId}/subtitles/${i}`;
|
|
track.srclang = sub.lang || "und";
|
|
track.label = langName(sub.lang) || `Spur ${i + 1}`;
|
|
if (i === currentSub) track.default = true;
|
|
videoEl.appendChild(track);
|
|
});
|
|
updateSubtitleTrack();
|
|
}
|
|
} catch (e) {
|
|
console.warn("Video-Info laden fehlgeschlagen:", e);
|
|
}
|
|
}
|
|
|
|
// === Codec-Erkennung ===
|
|
|
|
/**
|
|
* Erkennt automatisch welche Video-Codecs der Browser/TV decodieren kann.
|
|
* Wird beim HLS-Start an den Server geschickt -> Server entscheidet copy vs transcode.
|
|
*
|
|
* WICHTIG: Unterscheidung zwischen nativem HLS (Tizen/Safari) und MSE (hls.js):
|
|
* - Natives HLS: canPlayType() meldet oft AV1/VP9, aber der native HLS-Player
|
|
* unterstuetzt diese Codecs NICHT zuverlaessig in fMP4-Segmenten.
|
|
* -> Konservativ: nur H.264 (+ evtl. HEVC)
|
|
* - MSE/hls.js: MediaSource.isTypeSupported() ist zuverlaessig
|
|
* -> Alle unterstuetzten Codecs melden
|
|
*/
|
|
function detectSupportedCodecs() {
|
|
const codecs = [];
|
|
const el = document.createElement("video");
|
|
const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl");
|
|
const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported;
|
|
|
|
if (!hasNativeHLS && hasMSE) {
|
|
// MSE-basiert (hls.js auf Chrome/Firefox/Edge): zuverlaessige Erkennung
|
|
if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028"')) codecs.push("h264");
|
|
if (MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"')) codecs.push("hevc");
|
|
if (MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08"')) codecs.push("av1");
|
|
if (MediaSource.isTypeSupported('video/mp4; codecs="vp09.00.10.08"')) codecs.push("vp9");
|
|
} else {
|
|
// Natives HLS (Samsung Tizen, Safari, iOS):
|
|
// Konservativ - nur H.264 melden, da AV1/VP9 in HLS-fMP4 nicht zuverlaessig
|
|
codecs.push("h264");
|
|
if (el.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')
|
|
|| el.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
|
|
codecs.push("hevc");
|
|
}
|
|
}
|
|
|
|
if (!codecs.length) codecs.push("h264");
|
|
return codecs;
|
|
}
|
|
|
|
// === Loading-Indikator ===
|
|
|
|
let loadingTimer = null;
|
|
|
|
function showLoading() {
|
|
var el = document.getElementById("player-loading");
|
|
if (el) { el.classList.remove("hidden"); el.style.display = ""; }
|
|
// Fallback: Loading nach 8 Sekunden ausblenden (falls Events nicht feuern)
|
|
clearTimeout(loadingTimer);
|
|
loadingTimer = setTimeout(hideLoading, 8000);
|
|
}
|
|
function hideLoading() {
|
|
clearTimeout(loadingTimer);
|
|
clearTimeout(loadingTimeout);
|
|
loadingTimeout = null;
|
|
var el = document.getElementById("player-loading");
|
|
if (!el) return;
|
|
el.style.display = "none";
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// === 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
|
|
if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
|
|
// Native HLS (Safari, Tizen)
|
|
videoEl.src = playlistUrl;
|
|
hlsReady = true;
|
|
videoEl.addEventListener("playing", hideLoading, {once: true});
|
|
videoEl.play().catch(() => {});
|
|
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
|
|
// hls.js Polyfill (Chrome, Firefox, Edge)
|
|
hlsInstance = new Hls({
|
|
maxBufferLength: 30,
|
|
maxMaxBufferLength: 60,
|
|
startLevel: -1,
|
|
});
|
|
hlsInstance.loadSource(playlistUrl);
|
|
hlsInstance.attachMedia(videoEl);
|
|
|
|
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
hlsReady = true;
|
|
videoEl.addEventListener("playing", hideLoading, {once: true});
|
|
videoEl.play().catch(() => {});
|
|
});
|
|
|
|
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
|
|
if (data.fatal) {
|
|
console.error("HLS fataler Fehler:", data.type, data.details);
|
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR
|
|
&& networkRetries < MAX_RETRIES) {
|
|
// Netzwerkfehler -> Retry mit Backoff
|
|
networkRetries++;
|
|
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 {
|
|
// Kein HLS moeglich -> Fallback
|
|
console.warn("Weder natives HLS noch hls.js 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(() => {});
|
|
}
|
|
|
|
/** HLS aufraumen: hls.js + Server-Session beenden */
|
|
async function cleanupHLS() {
|
|
if (hlsInstance) {
|
|
hlsInstance.destroy();
|
|
hlsInstance = null;
|
|
}
|
|
if (hlsSessionId) {
|
|
// Server-Session loeschen (fire & forget)
|
|
fetch(`/tv/api/hls/${hlsSessionId}`, {method: "DELETE"}).catch(() => {});
|
|
hlsSessionId = null;
|
|
}
|
|
hlsReady = false;
|
|
hlsSeekOffset = 0;
|
|
}
|
|
|
|
// === Playback-Controls ===
|
|
|
|
function togglePlay() {
|
|
if (!videoEl) return;
|
|
if (videoEl.paused) videoEl.play();
|
|
else videoEl.pause();
|
|
}
|
|
|
|
function onPlay() {
|
|
if (playBtn) playBtn.innerHTML = "❚❚";
|
|
scheduleHideControls();
|
|
}
|
|
|
|
function onPause() {
|
|
if (playBtn) playBtn.innerHTML = "▶";
|
|
showControls();
|
|
saveProgress();
|
|
}
|
|
|
|
function onEnded() {
|
|
saveProgress(true);
|
|
episodesWatched++;
|
|
|
|
// Schaust du noch? (wenn Max-Episoden erreicht)
|
|
if (cfg.autoplayMax > 0 && episodesWatched >= cfg.autoplayMax) {
|
|
document.getElementById("still-watching-overlay").style.display = "";
|
|
return;
|
|
}
|
|
|
|
// Naechste Episode
|
|
if (cfg.nextVideoId && cfg.autoplay) {
|
|
showNextEpisodeOverlay();
|
|
} else {
|
|
setTimeout(() => window.history.back(), 2000);
|
|
}
|
|
}
|
|
|
|
// === Seeking ===
|
|
|
|
function seekRelative(seconds) {
|
|
if (!videoEl) return;
|
|
const dur = getDuration();
|
|
const cur = getCurrentTime();
|
|
const newTime = Math.max(0, Math.min(cur + seconds, dur));
|
|
|
|
if (hlsSessionId) {
|
|
// HLS: nativen Seek verwenden (hls.js unterstuetzt das)
|
|
videoEl.currentTime = Math.max(0, Math.min(
|
|
videoEl.currentTime + seconds, videoEl.duration || Infinity));
|
|
showControls();
|
|
} else {
|
|
// Legacy: neuen Stream starten
|
|
startHLSStream(newTime);
|
|
showControls();
|
|
}
|
|
}
|
|
|
|
function onProgressClick(e) {
|
|
if (!videoEl) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
const dur = getDuration();
|
|
if (!dur) return;
|
|
|
|
// Absolute Seek-Position im Video
|
|
const seekTo = pct * dur;
|
|
// Immer neuen HLS-Stream starten (server-seitiger Seek)
|
|
startHLSStream(seekTo);
|
|
showControls();
|
|
}
|
|
|
|
// === Zeit-Funktionen ===
|
|
|
|
function getCurrentTime() {
|
|
if (!videoEl) return 0;
|
|
// Bei HLS mit Server-Seek: videoEl.currentTime + Offset = echte Position
|
|
return hlsSeekOffset + (videoEl.currentTime || 0);
|
|
}
|
|
|
|
function getDuration() {
|
|
// Echte Gesamtdauer des Videos (nicht der HLS-Stream-Dauer)
|
|
return cfg.duration || 0;
|
|
}
|
|
|
|
function onTimeUpdate() {
|
|
if (!videoEl) return;
|
|
const current = getCurrentTime();
|
|
const dur = getDuration();
|
|
|
|
if (progressBar && dur > 0) {
|
|
progressBar.style.width = ((current / dur) * 100) + "%";
|
|
}
|
|
if (timeDisplay) {
|
|
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
|
}
|
|
}
|
|
|
|
function formatTime(sec) {
|
|
if (!sec || !isFinite(sec)) return "0:00";
|
|
const h = Math.floor(sec / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
const s = Math.floor(sec % 60);
|
|
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
|
return m + ":" + String(s).padStart(2, "0");
|
|
}
|
|
|
|
// === Controls Ein-/Ausblenden ===
|
|
|
|
function showControls() {
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (wrapper) wrapper.classList.remove("player-hide-controls");
|
|
controlsVisible = true;
|
|
scheduleHideControls();
|
|
}
|
|
|
|
function hideControls() {
|
|
if (!videoEl || videoEl.paused || popupOpen) return;
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (wrapper) wrapper.classList.add("player-hide-controls");
|
|
controlsVisible = false;
|
|
}
|
|
|
|
function scheduleHideControls() {
|
|
if (controlsTimer) clearTimeout(controlsTimer);
|
|
controlsTimer = setTimeout(hideControls, 4000);
|
|
}
|
|
|
|
// === Fullscreen ===
|
|
|
|
function toggleFullscreen() {
|
|
const wrapper = document.getElementById("player-wrapper");
|
|
if (!document.fullscreenElement) {
|
|
(wrapper || document.documentElement).requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen().catch(() => {});
|
|
}
|
|
}
|
|
|
|
// === Popup-Menue (ersetzt das grosse Overlay-Panel) ===
|
|
|
|
function togglePopup() {
|
|
if (popupOpen) {
|
|
closePopup();
|
|
} else {
|
|
openPopupSection(null);
|
|
}
|
|
}
|
|
|
|
function openPopupSection(section) {
|
|
const popup = document.getElementById("player-popup");
|
|
if (!popup) return;
|
|
|
|
if (popupOpen && popupSection === section) {
|
|
// Gleiche Sektion nochmal -> schliessen
|
|
closePopup();
|
|
return;
|
|
}
|
|
|
|
popupOpen = true;
|
|
popupSection = section;
|
|
popup.style.display = "";
|
|
popup.classList.add("popup-visible");
|
|
renderPopup(section);
|
|
showControls();
|
|
|
|
// Focus auf ersten Button im Popup
|
|
requestAnimationFrame(() => {
|
|
const first = popup.querySelector("[data-focusable]");
|
|
if (first) first.focus();
|
|
});
|
|
}
|
|
|
|
function closePopup() {
|
|
const popup = document.getElementById("player-popup");
|
|
if (!popup) return;
|
|
popupOpen = false;
|
|
popupSection = null;
|
|
popup.classList.remove("popup-visible");
|
|
popup.style.display = "none";
|
|
}
|
|
|
|
function renderPopup(section) {
|
|
const popup = document.getElementById("player-popup");
|
|
if (!popup) return;
|
|
|
|
let html = "";
|
|
|
|
if (!section) {
|
|
// Hauptmenue: Liste aller Optionen
|
|
html = '<div class="popup-menu">';
|
|
|
|
// Audio
|
|
const audioLabel = _currentAudioLabel();
|
|
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('audio')">
|
|
<span class="popup-item-label">Audio</span>
|
|
<span class="popup-item-value">${audioLabel}</span>
|
|
</button>`;
|
|
|
|
// Untertitel
|
|
const subLabel = currentSub >= 0 ? _currentSubLabel() : "Aus";
|
|
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('subs')">
|
|
<span class="popup-item-label">Untertitel</span>
|
|
<span class="popup-item-value">${subLabel}</span>
|
|
</button>`;
|
|
|
|
// Qualitaet
|
|
const qualLabels = {uhd: "Ultra HD", hd: "HD", sd: "SD", low: "Niedrig"};
|
|
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('quality')">
|
|
<span class="popup-item-label">Qualit\u00e4t</span>
|
|
<span class="popup-item-value">${qualLabels[currentQuality] || "HD"}</span>
|
|
</button>`;
|
|
|
|
// Geschwindigkeit
|
|
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('speed')">
|
|
<span class="popup-item-label">Geschwindigkeit</span>
|
|
<span class="popup-item-value">${currentSpeed}x</span>
|
|
</button>`;
|
|
|
|
html += "</div>";
|
|
} else if (section === "audio") {
|
|
html = _renderAudioOptions();
|
|
} else if (section === "subs") {
|
|
html = _renderSubOptions();
|
|
} else if (section === "quality") {
|
|
html = _renderQualityOptions();
|
|
} else if (section === "speed") {
|
|
html = _renderSpeedOptions();
|
|
}
|
|
|
|
popup.innerHTML = html;
|
|
}
|
|
|
|
function _currentAudioLabel() {
|
|
if (videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
|
|
const a = videoInfo.audio_tracks[currentAudio];
|
|
const ch = a.channels > 2 ? ` ${a.channels}ch` : "";
|
|
return langName(a.lang) + ch;
|
|
}
|
|
return "Spur 1";
|
|
}
|
|
|
|
function _currentSubLabel() {
|
|
if (videoInfo && videoInfo.subtitle_tracks && videoInfo.subtitle_tracks[currentSub]) {
|
|
return langName(videoInfo.subtitle_tracks[currentSub].lang);
|
|
}
|
|
return "Spur " + (currentSub + 1);
|
|
}
|
|
|
|
function _renderAudioOptions() {
|
|
let html = '<div class="popup-submenu">';
|
|
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">← 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;
|
|
// Neuen HLS-Stream mit anderer Audio-Spur starten
|
|
const currentTime = getCurrentTime();
|
|
startHLSStream(currentTime);
|
|
renderPopup(popupSection);
|
|
updatePlayerButtons();
|
|
}
|
|
|
|
function switchSub(idx) {
|
|
currentSub = idx;
|
|
updateSubtitleTrack();
|
|
renderPopup(popupSection);
|
|
updatePlayerButtons();
|
|
}
|
|
|
|
function updateSubtitleTrack() {
|
|
if (!videoEl || !videoEl.textTracks) return;
|
|
for (let i = 0; i < videoEl.textTracks.length; i++) {
|
|
videoEl.textTracks[i].mode = (i === currentSub) ? "showing" : "hidden";
|
|
}
|
|
}
|
|
|
|
function switchQuality(q) {
|
|
if (q === currentQuality) return;
|
|
currentQuality = q;
|
|
const currentTime = getCurrentTime();
|
|
startHLSStream(currentTime);
|
|
renderPopup(popupSection);
|
|
updatePlayerButtons();
|
|
}
|
|
|
|
function switchSpeed(s) {
|
|
currentSpeed = s;
|
|
if (videoEl) videoEl.playbackRate = s;
|
|
renderPopup(popupSection);
|
|
}
|
|
|
|
// === Naechste Episode ===
|
|
|
|
function showNextEpisodeOverlay() {
|
|
const overlay = document.getElementById("next-overlay");
|
|
if (!overlay) return;
|
|
overlay.style.display = "";
|
|
let remaining = cfg.autoplayCountdown || 10;
|
|
const countdownEl = document.getElementById("next-countdown");
|
|
|
|
nextCountdown = setInterval(() => {
|
|
remaining--;
|
|
if (countdownEl) countdownEl.textContent = remaining + "s";
|
|
if (remaining <= 0) {
|
|
clearInterval(nextCountdown);
|
|
playNextEpisode();
|
|
}
|
|
}, 1000);
|
|
if (countdownEl) countdownEl.textContent = remaining + "s";
|
|
}
|
|
|
|
function playNextEpisode() {
|
|
if (nextCountdown) clearInterval(nextCountdown);
|
|
cleanupHLS();
|
|
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
|
|
}
|
|
|
|
function cancelNext() {
|
|
if (nextCountdown) clearInterval(nextCountdown);
|
|
const overlay = document.getElementById("next-overlay");
|
|
if (overlay) overlay.style.display = "none";
|
|
setTimeout(() => window.history.back(), 500);
|
|
}
|
|
|
|
// === D-Pad Navigation fuer Fernbedienung ===
|
|
|
|
function _getFocusables() {
|
|
if (popupOpen) {
|
|
const popup = document.getElementById("player-popup");
|
|
return popup ? Array.from(popup.querySelectorAll("[data-focusable]")) : [];
|
|
}
|
|
// "Naechste Episode" oder "Schaust du noch" Overlay?
|
|
const nextOv = document.getElementById("next-overlay");
|
|
if (nextOv && nextOv.style.display !== "none") {
|
|
return Array.from(nextOv.querySelectorAll("[data-focusable]"));
|
|
}
|
|
const stillOv = document.getElementById("still-watching-overlay");
|
|
if (stillOv && stillOv.style.display !== "none") {
|
|
return Array.from(stillOv.querySelectorAll("[data-focusable]"));
|
|
}
|
|
// Player-Controls
|
|
const controls = document.getElementById("player-controls");
|
|
return controls ? Array.from(controls.querySelectorAll("[data-focusable]")) : [];
|
|
}
|
|
|
|
function _focusNext(direction) {
|
|
const items = _getFocusables();
|
|
if (!items.length) return false;
|
|
const cur = items.indexOf(document.activeElement);
|
|
let next;
|
|
if (direction === 1) {
|
|
next = cur < 0 ? 0 : Math.min(cur + 1, items.length - 1);
|
|
} else {
|
|
next = cur < 0 ? items.length - 1 : Math.max(cur - 1, 0);
|
|
}
|
|
items[next].focus();
|
|
return true;
|
|
}
|
|
|
|
// === Tastatur-Steuerung ===
|
|
|
|
function onKeyDown(e) {
|
|
// Samsung Tizen Remote Keys
|
|
const keyMap = {
|
|
10009: "Escape", 10182: "Escape",
|
|
415: "Play", 19: "Pause", 413: "Stop",
|
|
417: "FastForward", 412: "Rewind",
|
|
// Samsung Farbtasten
|
|
403: "ColorRed", 404: "ColorGreen",
|
|
405: "ColorYellow", 406: "ColorBlue",
|
|
};
|
|
const key = keyMap[e.keyCode] || e.key;
|
|
|
|
const active = document.activeElement;
|
|
const buttonFocused = active && active.hasAttribute("data-focusable") &&
|
|
active.tagName === "BUTTON";
|
|
|
|
// --- Popup offen: D-Pad navigiert im Popup ---
|
|
if (popupOpen) {
|
|
switch (key) {
|
|
case "Escape": case "Backspace":
|
|
if (popupSection) {
|
|
// Zurueck zum Hauptmenue
|
|
openPopupSection(null);
|
|
} else {
|
|
closePopup();
|
|
const btnSettings = document.getElementById("btn-settings");
|
|
if (btnSettings) btnSettings.focus();
|
|
}
|
|
e.preventDefault(); return;
|
|
case "ArrowUp":
|
|
_focusNext(-1); e.preventDefault(); return;
|
|
case "ArrowDown":
|
|
_focusNext(1); e.preventDefault(); return;
|
|
case "ArrowLeft":
|
|
if (popupSection) {
|
|
openPopupSection(null);
|
|
} else {
|
|
closePopup();
|
|
}
|
|
e.preventDefault(); return;
|
|
case "ArrowRight":
|
|
case "Enter":
|
|
if (buttonFocused) active.click();
|
|
e.preventDefault(); return;
|
|
}
|
|
}
|
|
|
|
// --- "Naechste Episode" / "Schaust du noch" Overlay ---
|
|
const nextOv = document.getElementById("next-overlay");
|
|
const stillOv = document.getElementById("still-watching-overlay");
|
|
const modalOpen = (nextOv && nextOv.style.display !== "none") ||
|
|
(stillOv && stillOv.style.display !== "none");
|
|
if (modalOpen) {
|
|
switch (key) {
|
|
case "ArrowLeft": case "ArrowRight":
|
|
_focusNext(key === "ArrowRight" ? 1 : -1);
|
|
e.preventDefault(); return;
|
|
case "Enter":
|
|
if (buttonFocused) active.click();
|
|
e.preventDefault(); return;
|
|
}
|
|
}
|
|
|
|
// --- Controls sichtbar + Button fokussiert: D-Pad navigiert ---
|
|
if (controlsVisible && buttonFocused) {
|
|
switch (key) {
|
|
case "ArrowLeft":
|
|
_focusNext(-1); showControls(); e.preventDefault(); return;
|
|
case "ArrowRight":
|
|
_focusNext(1); showControls(); e.preventDefault(); return;
|
|
case "ArrowUp":
|
|
active.blur(); showControls(); e.preventDefault(); return;
|
|
case "ArrowDown":
|
|
active.blur(); showControls(); e.preventDefault(); return;
|
|
case "Enter":
|
|
active.click(); showControls(); e.preventDefault(); return;
|
|
}
|
|
}
|
|
|
|
// --- Standard Player-Tasten ---
|
|
switch (key) {
|
|
case " ": case "Play": case "Pause":
|
|
togglePlay(); e.preventDefault(); break;
|
|
case "Enter":
|
|
if (!controlsVisible) {
|
|
showControls();
|
|
if (playBtn) playBtn.focus();
|
|
} else {
|
|
togglePlay();
|
|
}
|
|
e.preventDefault(); break;
|
|
case "ArrowLeft": case "Rewind":
|
|
seekRelative(-10); showControls(); e.preventDefault(); break;
|
|
case "ArrowRight": case "FastForward":
|
|
seekRelative(10); showControls(); e.preventDefault(); break;
|
|
case "ArrowUp":
|
|
if (!controlsVisible) {
|
|
showControls();
|
|
if (playBtn) playBtn.focus();
|
|
} else {
|
|
if (playBtn) playBtn.focus();
|
|
showControls();
|
|
}
|
|
e.preventDefault(); break;
|
|
case "ArrowDown":
|
|
if (!controlsVisible) {
|
|
showControls();
|
|
if (playBtn) playBtn.focus();
|
|
} else {
|
|
if (playBtn) playBtn.focus();
|
|
showControls();
|
|
}
|
|
e.preventDefault(); break;
|
|
case "Escape": case "Backspace": case "Stop":
|
|
saveProgress();
|
|
cleanupHLS();
|
|
setTimeout(() => window.history.back(), 100);
|
|
e.preventDefault(); break;
|
|
case "f":
|
|
toggleFullscreen(); e.preventDefault(); break;
|
|
case "s":
|
|
togglePopup(); e.preventDefault(); break;
|
|
case "n":
|
|
if (cfg.nextVideoId) playNextEpisode();
|
|
e.preventDefault(); break;
|
|
// Samsung Farbtasten: Direkt-Zugriff auf Popup-Sektionen
|
|
case "ColorRed":
|
|
openPopupSection("audio"); e.preventDefault(); break;
|
|
case "ColorGreen":
|
|
openPopupSection("subs"); e.preventDefault(); break;
|
|
case "ColorYellow":
|
|
openPopupSection("quality"); e.preventDefault(); break;
|
|
case "ColorBlue":
|
|
openPopupSection("speed"); e.preventDefault(); break;
|
|
}
|
|
}
|
|
|
|
// === Watch-Progress speichern ===
|
|
|
|
function saveProgress(completed) {
|
|
if (!cfg.videoId || !videoEl) return;
|
|
const 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;
|
|
|
|
fetch("/tv/api/watch-progress", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify(payload),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
saveProgress();
|
|
cleanupHLS();
|
|
});
|
|
|
|
// === Button-Status aktualisieren ===
|
|
|
|
function updatePlayerButtons() {
|
|
// CC-Button: aktiv wenn Untertitel an
|
|
var btnSubs = document.getElementById("btn-subs");
|
|
if (btnSubs) btnSubs.classList.toggle("active", currentSub >= 0);
|
|
// Quality-Badge: aktuellen Modus anzeigen
|
|
var badge = document.getElementById("quality-badge");
|
|
if (badge) {
|
|
var labels = {uhd: "4K", hd: "HD", sd: "SD", low: "LD"};
|
|
badge.textContent = labels[currentQuality] || "HD";
|
|
}
|
|
// Audio-Button: aktuelle Sprache anzeigen (Tooltip)
|
|
var btnAudio = document.getElementById("btn-audio");
|
|
if (btnAudio && videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
|
|
var lang = videoInfo.audio_tracks[currentAudio].lang;
|
|
btnAudio.title = langName(lang) || "Audio";
|
|
}
|
|
}
|
|
|
|
// === Hilfsfunktionen ===
|
|
|
|
const LANG_NAMES = {
|
|
deu: "Deutsch", eng: "English", fra: "Fran\u00e7ais",
|
|
spa: "Espa\u00f1ol", ita: "Italiano", jpn: "\u65e5\u672c\u8a9e",
|
|
kor: "\ud55c\uad6d\uc5b4", por: "Portugu\u00eas",
|
|
rus: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
|
zho: "\u4e2d\u6587", und: "Unbekannt",
|
|
};
|
|
|
|
function langName(code) {
|
|
return LANG_NAMES[code] || code || "";
|
|
}
|