docker.videokonverter/video-konverter/app/static/tv/js/tv.js
data e8f2d49949 feat: VideoKonverter v4.0.2 - FocusManager-Fix, Poster-Caching, Performance
- FocusManager: Navigation von Nav-Leiste direkt zu Content-Karten
- Input/Select Editier-Modus: Erst Enter zum Bearbeiten, D-Pad navigiert weiter
- Poster lokal cachen + Pillow-Resize (233KB → 47KB, 80% kleiner)
- Content-Visibility fuer versteckte View-Container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:49:05 +01:00

455 lines
17 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;
// 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")) {
// Nur echte Content-Elemente merken (nicht Filter/Controls)
if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list")) {
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"
);
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 durchlassen
if (active && active.tagName === "SELECT") {
if (this._selectActive) {
// Editier-Modus: Hoch/Runter aendert den Wert
if (direction === "ArrowUp" || direction === "ArrowDown") return;
} else {
// Nicht im Editier-Modus: native SELECT-Aenderung verhindern
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"
);
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;
}
}
}
}
// 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;
}
// 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();
});