/** * VideoKonverter TV - Focus-Manager und Navigation * D-Pad Navigation fuer TV-Fernbedienungen (Samsung Tizen, Android TV) * + Lazy-Loading fuer Poster-Bilder */ // === Focus-Manager === class FocusManager { constructor() { this._enabled = true; this._currentFocus = null; // Merkt sich das letzte fokussierte Element im Content-Bereich this._lastContentFocus = null; // SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte this._selectActive = false; // Tastatur-Events abfangen document.addEventListener("keydown", (e) => this._onKeyDown(e)); // Focus-Tracking: merken wo wir zuletzt waren document.addEventListener("focusin", (e) => { // SELECT-Editier-Modus beenden wenn Focus sich aendert if (this._selectActive && e.target && e.target.tagName !== "SELECT") { this._selectActive = false; document.querySelectorAll(".select-editing").forEach( el => el.classList.remove("select-editing")); } if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (!e.target.closest("#tv-nav")) { this._lastContentFocus = e.target; } } }); // Initiales Focus-Element setzen requestAnimationFrame(() => this._initFocus()); } _initFocus() { // Erstes fokussierbares Element finden (nicht autofocus Inputs) const autofocusEl = document.querySelector("[autofocus]"); if (autofocusEl) { autofocusEl.focus(); return; } // Erstes Element im Content bevorzugen (nicht Nav) const contentFirst = document.querySelector(".tv-main [data-focusable]"); if (contentFirst) { contentFirst.focus(); return; } const first = document.querySelector("[data-focusable]"); if (first) first.focus(); } _onKeyDown(e) { if (!this._enabled) return; // Samsung Tizen Remote Key-Codes mappen const keyMap = { 37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown", 13: "Enter", 27: "Escape", 8: "Backspace", // Samsung Tizen spezifisch 10009: "Escape", // RETURN-Taste 10182: "Escape", // EXIT-Taste }; const key = keyMap[e.keyCode] || e.key; switch (key) { case "ArrowUp": case "ArrowDown": case "ArrowLeft": case "ArrowRight": this._navigate(key, e); break; case "Enter": this._activate(e); break; case "Escape": case "Backspace": this._goBack(e); break; } } _navigate(direction, e) { const active = document.activeElement; // Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation) if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (direction === "ArrowLeft" || direction === "ArrowRight") return; } // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen if (active && active.tagName === "SELECT") { if (this._selectActive) { // Editier-Modus: Hoch/Runter aendert den Wert if (direction === "ArrowUp" || direction === "ArrowDown") return; } // Sonst: normal weiternavigieren (Select wird uebersprungen) } const focusables = this._getFocusableElements(); if (!focusables.length) return; // Aktuelles Element const currentIdx = focusables.indexOf(active); if (currentIdx === -1) { // Kein fokussiertes Element -> erstes waehlen focusables[0].focus(); e.preventDefault(); return; } // Navigation innerhalb der Nav-Bar: Links/Rechts = sequentiell const inNav = active.closest("#tv-nav"); if (inNav && (direction === "ArrowLeft" || direction === "ArrowRight")) { // Alle Nav-Elemente sequentiell (links + rechts zusammen) const navEls = focusables.filter(el => el.closest("#tv-nav")); const navIdx = navEls.indexOf(active); if (navIdx !== -1) { const nextIdx = direction === "ArrowRight" ? navIdx + 1 : navIdx - 1; if (nextIdx >= 0 && nextIdx < navEls.length) { navEls[nextIdx].focus(); e.preventDefault(); return; } } } // Von Nav nach unten -> zum Content springen if (inNav && direction === "ArrowDown") { if (this._lastContentFocus && document.contains(this._lastContentFocus)) { this._lastContentFocus.focus(); this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); return; } // Sonst: erstes Content-Element const contentFirst = document.querySelector(".tv-main [data-focusable]"); if (contentFirst) { contentFirst.focus(); contentFirst.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); return; } } // Vom Content nach oben zur Nav springen wenn am oberen Rand if (!inNav && direction === "ArrowUp") { const current = active.getBoundingClientRect(); // Nur wenn Element nah am oberen Rand ist (< 200px vom Viewport-Top) if (current.top < 200) { // Pruefen ob es noch ein Element darueber im Content gibt const contentEls = focusables.filter(el => !el.closest("#tv-nav")); const above = contentEls.filter(el => { const r = el.getBoundingClientRect(); return r.top + r.height / 2 < current.top - 5; }); if (above.length === 0) { // Kein Element darueber -> zur Nav springen const activeNavItem = document.querySelector(".tv-nav-item.active"); if (activeNavItem) { activeNavItem.focus(); e.preventDefault(); return; } } } } // Naechstes Element in Richtung finden (Nearest-Neighbor) const currentRect = active.getBoundingClientRect(); const cx = currentRect.left + currentRect.width / 2; const cy = currentRect.top + currentRect.height / 2; let bestEl = null; let bestDist = Infinity; // Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen const searchEls = inNav ? focusables.filter(el => el.closest("#tv-nav")) : focusables.filter(el => !el.closest("#tv-nav")); for (const el of searchEls) { if (el === active) continue; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; const ex = rect.left + rect.width / 2; const ey = rect.top + rect.height / 2; const dx = ex - cx; const dy = ey - cy; let valid = false; switch (direction) { case "ArrowUp": valid = dy < -5; break; case "ArrowDown": valid = dy > 5; break; case "ArrowLeft": valid = dx < -5; break; case "ArrowRight": valid = dx > 5; break; } if (!valid) continue; let dist; if (direction === "ArrowUp" || direction === "ArrowDown") { dist = Math.abs(dy) + Math.abs(dx) * 3; } else { dist = Math.abs(dx) + Math.abs(dy) * 3; } if (dist < bestDist) { bestDist = dist; bestEl = el; } } if (bestEl) { bestEl.focus(); bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); e.preventDefault(); } } _activate(e) { const active = document.activeElement; if (!active || active === document.body) return; // Links, Buttons -> Click ausfuehren (natuerliches Enter-Verhalten) if (active.tagName === "A" || active.tagName === "BUTTON") { return; } // Select: Enter aktiviert/deaktiviert den Editier-Modus if (active.tagName === "SELECT") { if (this._selectActive) { // Wert bestaetigen, Editier-Modus beenden this._selectActive = false; active.classList.remove("select-editing"); // onchange ausloesen falls sich Wert geaendert hat active.dispatchEvent(new Event("change", { bubbles: true })); } else { // Editier-Modus starten this._selectActive = true; active.classList.add("select-editing"); } e.preventDefault(); return; } // Checkbox: Toggle if (active.tagName === "INPUT" && active.type === "checkbox") { active.checked = !active.checked; active.dispatchEvent(new Event("change", { bubbles: true })); e.preventDefault(); return; } // Andere fokussierbare Elemente -> Click simulieren if (active.hasAttribute("data-focusable")) { active.click(); e.preventDefault(); } } _goBack(e) { const active = document.activeElement; // In Input-Feldern: Escape = Blur if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (e.key === "Escape" || e.keyCode === 10009) { active.blur(); e.preventDefault(); } return; } // In Select-Feldern: Escape = Editier-Modus beenden oder Blur if (active && active.tagName === "SELECT") { if (this._selectActive) { // Editier-Modus beenden (Wert nicht uebernehmen) this._selectActive = false; active.classList.remove("select-editing"); } else { // Nicht im Editier-Modus -> Focus verlassen active.blur(); } e.preventDefault(); return; } // Wenn Focus in der Nav: nicht zurueck navigieren if (active && active.closest && active.closest("#tv-nav")) { // Focus zurueck zum Content verschieben if (this._lastContentFocus && document.contains(this._lastContentFocus)) { this._lastContentFocus.focus(); } e.preventDefault(); return; } // Wenn ein Player-Overlay offen ist, zuerst das schliessen const overlay = document.querySelector(".player-overlay.visible, .player-next-overlay.visible"); if (overlay) { overlay.classList.remove("visible"); e.preventDefault(); return; } // Zurueck navigieren if (window.history.length > 1) { window.history.back(); e.preventDefault(); } } _getFocusableElements() { const elements = document.querySelectorAll("[data-focusable]"); return Array.from(elements).filter(el => { if (el.offsetParent === null && el.style.position !== "fixed") return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); } } // === Horizontale Scroll-Reihen: Scroll per Pfeiltaste === function initRowScroll() { document.querySelectorAll(".tv-row").forEach(row => { // Maus-Rad horizontal scrollen row.addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault(); row.scrollLeft += e.deltaY; } }, { passive: false }); }); } // === Lazy-Loading fuer Poster (IntersectionObserver) === function initLazyLoad() { // Browser-natives loading="lazy" wird bereits verwendet // Zusaetzlich: Placeholder-Klasse entfernen nach Laden document.querySelectorAll("img.tv-card-img").forEach(img => { if (img.complete) return; img.style.opacity = "0"; img.style.transition = "opacity 0.3s"; img.addEventListener("load", () => { img.style.opacity = "1"; }, { once: true }); img.addEventListener("error", () => { // Fehlerhaftes Bild: Placeholder anzeigen img.style.display = "none"; const placeholder = document.createElement("div"); placeholder.className = "tv-card-placeholder"; placeholder.textContent = img.alt || "?"; img.parentNode.insertBefore(placeholder, img); }, { once: true }); }); } // === Navigation: Aktiven Tab highlighten === function initNavHighlight() { const path = window.location.pathname; document.querySelectorAll(".tv-nav-item").forEach(item => { const href = item.getAttribute("href"); if (href === path || (href !== "/tv/" && path.startsWith(href))) { item.classList.add("active"); } }); } // === Init === document.addEventListener("DOMContentLoaded", () => { window.focusManager = new FocusManager(); initRowScroll(); initLazyLoad(); initNavHighlight(); });