docker.videokonverter/video-konverter/app/static/tv/js/tv.js
data 61ca20bf8b fix: TV-App UX-Verbesserungen - Navigation, Ordner-Ansicht, Duplikate
- FocusManager: SELECT-Elemente, sequentielle Nav-Navigation, Zone-basiert
- Ordner-Ansicht (4. View) fuer Serien + Filme mit Quellen-Gruppierung
- Login-Flow: Lade-Spinner statt Form-Flash, Auto-Login bei 1 Profil
- Farbauswahl: Farbkreise statt input type=color (Samsung TV kompatibel)
- Duplikat-Episoden: Orange Markierung + Badge bei gleicher Episodennummer
- i18n: Neue Keys fuer Ordner-Ansicht und Duplikat-Markierung

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

353 lines
12 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;
// Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e));
// Focus-Tracking: merken wo wir zuletzt waren
document.addEventListener("focusin", (e) => {
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: Hoch/Runter dem Browser ueberlassen (Option wechseln)
if (active && active.tagName === "SELECT") {
if (direction === "ArrowUp" || direction === "ArrowDown") 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;
}
// 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 oeffnet/schliesst das Dropdown nativ
if (active.tagName === "SELECT") {
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 = Blur (zurueck zur Navigation)
if (active && active.tagName === "SELECT") {
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();
});