/** * 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; // INPUT/TEXTAREA Editier-Modus: erst Enter druecken, dann tippen this._inputActive = 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")); } // INPUT-Editier-Modus beenden wenn Focus sich aendert if (this._inputActive && e.target && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") { this._inputActive = false; document.querySelectorAll(".input-editing").forEach( el => el.classList.remove("input-editing")); } if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (!e.target.closest("#tv-nav") && !e.target.closest(".tv-alpha-sidebar")) { // Nur echte Content-Elemente merken (nicht Nav/Sidebar) if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) { 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 sichtbaren Content-Bereich (Karten bevorzugen) const contentAreas = document.querySelectorAll( ".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid" ); for (const area of contentAreas) { if (!area.offsetHeight) continue; const firstEl = area.querySelector("[data-focusable]"); if (firstEl) { firstEl.focus(); return; } } // Fallback: erstes Content-Element 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: Nur im Editier-Modus Cursor-Navigation erlauben if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (active.type === "checkbox") { // Checkbox: normal navigieren } else if (this._inputActive) { // Editier-Modus: Links/Rechts fuer Cursor, Hoch/Runter navigiert weg if (direction === "ArrowLeft" || direction === "ArrowRight") return; } // Nicht aktiv: alle Richtungen navigieren weiter } // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter aendert Wert if (active && active.tagName === "SELECT") { if (this._selectActive) { // Editier-Modus: Wert manuell aendern (synthetische Events aendern SELECT nicht) if (direction === "ArrowUp" || direction === "ArrowDown") { const idx = active.selectedIndex; if (direction === "ArrowDown" && idx < active.options.length - 1) { active.selectedIndex = idx + 1; } else if (direction === "ArrowUp" && idx > 0) { active.selectedIndex = idx - 1; } e.preventDefault(); return; } } else { // Nicht im Editier-Modus: Navigation statt Wert-Aenderung if (direction === "ArrowUp" || direction === "ArrowDown") { e.preventDefault(); } } } 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 -> direkt zu Content-Karten (Filter/View-Switch ueberspringen) if (inNav && direction === "ArrowDown") { // Gespeicherten Content-Focus bevorzugen (nur wenn noch sichtbar) if (this._lastContentFocus && document.contains(this._lastContentFocus) && this._lastContentFocus.offsetHeight > 0) { this._lastContentFocus.focus(); this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); return; } // Direkt zum sichtbaren Content-Bereich (Karten/Listen-Eintraege) const contentAreas = document.querySelectorAll( ".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid" ); for (const area of contentAreas) { if (!area.offsetHeight) continue; const firstEl = area.querySelector("[data-focusable]"); if (firstEl) { firstEl.focus(); firstEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); return; } } // Fallback: 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; } } } } // ===== Alphabet-Sidebar Navigation ===== const inSidebar = active.closest(".tv-alpha-sidebar"); if (inSidebar) { if (direction === "ArrowLeft") { // Zurueck zum Content if (this._lastContentFocus && document.contains(this._lastContentFocus)) { this._lastContentFocus.focus(); } else { const firstCard = document.querySelector(".tv-grid [data-focusable], .tv-card[data-focusable]"); if (firstCard) firstCard.focus(); } e.preventDefault(); return; } if (direction === "ArrowUp" || direction === "ArrowDown") { // Sequentiell durch Buchstaben const letters = Array.from(document.querySelectorAll(".tv-alpha-letter[data-focusable]")) .filter(el => el.offsetHeight > 0); const idx = letters.indexOf(active); const next = direction === "ArrowDown" ? idx + 1 : idx - 1; if (next >= 0 && next < letters.length) { letters[next].focus(); letters[next].scrollIntoView({ block: "nearest", behavior: "smooth" }); } e.preventDefault(); return; } } // ArrowRight am rechten Grid-Rand -> Sidebar if (!inNav && !inSidebar && direction === "ArrowRight") { const sidebar = document.getElementById("alpha-sidebar"); if (sidebar && sidebar.offsetHeight > 0) { // Pruefen ob es noch ein Element rechts im Content gibt const contentEls = focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar")); const currentRect_r = active.getBoundingClientRect(); const rightNeighbor = contentEls.some(el => { const r = el.getBoundingClientRect(); return r.left > currentRect_r.right + 5 && Math.abs(r.top - currentRect_r.top) < 100; }); if (!rightNeighbor) { // Kein Content rechts -> zur Sidebar springen const sidebarLetters = Array.from(sidebar.querySelectorAll("[data-focusable]")) .filter(el => el.offsetHeight > 0); if (sidebarLetters.length > 0) { // Naechstgelegenen Buchstaben vertikal finden const cy_r = currentRect_r.top + currentRect_r.height / 2; let best = sidebarLetters[0]; let bestDist_r = Infinity; sidebarLetters.forEach(l => { const lr = l.getBoundingClientRect(); const d = Math.abs(lr.top + lr.height / 2 - cy_r); if (d < bestDist_r) { bestDist_r = d; best = l; } }); this._lastContentFocus = active; best.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") && !el.closest(".tv-alpha-sidebar")); 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; } // Input/Textarea: Enter aktiviert/deaktiviert Editier-Modus if ((active.tagName === "INPUT" && active.type !== "checkbox") || active.tagName === "TEXTAREA") { if (this._inputActive) { // Editier-Modus beenden this._inputActive = false; active.classList.remove("input-editing"); } else { // Editier-Modus starten this._inputActive = true; active.classList.add("input-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 = Editier-Modus beenden oder Blur if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (this._inputActive) { // Editier-Modus beenden, Focus bleibt this._inputActive = false; active.classList.remove("input-editing"); } else { // Nicht im Editier-Modus: Focus verlassen 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(); });