- Alphabet-Seitenleiste (A-Z) auf Serien-/Filme-Seite - Separate Player-Buttons fuer Audio/Untertitel/Qualitaet - Batch-Thumbnail-Generierung per Button in der Bibliothek - Redundante Dateien in Episoden-Tabelle orange markiert - Gesehen-Markierung per Episode/Staffel - Genre-Filter als Select-Element statt Chips - Fix: tvdb_episode_cache fehlende Spalten (overview, image_url) - Fix: Login Auto-Fill-Erkennung statt Flash - Fix: Profil-Wechsel zeigt alle User Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|