/** * 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; // Tastatur-Events abfangen document.addEventListener("keydown", (e) => this._onKeyDown(e)); // 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; } 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; } 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; } // Naechstes Element in Richtung finden (Nearest-Neighbor) const current = active.getBoundingClientRect(); const cx = current.left + current.width / 2; const cy = current.top + current.height / 2; let bestEl = null; let bestDist = Infinity; for (const el of focusables) { if (el === active) continue; const rect = el.getBoundingClientRect(); // Element muss sichtbar sein if (rect.width === 0 || rect.height === 0) continue; const ex = rect.left + rect.width / 2; const ey = rect.top + rect.height / 2; // Pruefen ob Element in der richtigen Richtung liegt 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; // Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr) 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(); // Ins Sichtfeld scrollen 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 if (active.tagName === "A" || active.tagName === "BUTTON") { // Natuerliches Enter-Verhalten beibehalten 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, Backspace = natuerlich if (active && active.tagName === "INPUT") { if (e.key === "Escape") { active.blur(); e.preventDefault(); } return; } // Zurueck navigieren if (window.history.length > 1) { window.history.back(); e.preventDefault(); } } _getFocusableElements() { // Alle sichtbaren fokussierbaren Elemente 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(); });