TV-App (/tv/): - Login mit bcrypt-Passwort-Hashing und DB-Sessions (30 Tage) - Home (Weiterschauen, Serien, Filme), Serien-Detail mit Staffeln - Film-Uebersicht und Detail, Fullscreen Video-Player - Suche mit Live-Ergebnissen, Watch-Progress (alle 10s gespeichert) - D-Pad/Fernbedienung-Navigation (FocusManager, Samsung Tizen Keys) - PWA: manifest.json, Service Worker, Icons fuer Handy/Tablet - Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade) Admin-Erweiterungen: - QR-Code fuer TV-App URL - User-Verwaltung (CRUD) mit Rechte-Konfiguration - Log-API: GET /api/log?lines=100&level=INFO Tizen-App (tizen-app/): - Wrapper-App fuer Samsung Smart TVs (.wgt Paket) - Einmalige Server-IP Eingabe, danach automatische Verbindung - Installationsanleitung (INSTALL.md) Bug-Fixes: - executeImport: Job-ID vor resetImport() gesichert - cursor(aiomysql.DictCursor) statt cursor(dict) - DB-Spalten width/height statt video_width/video_height Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
7.4 KiB
JavaScript
235 lines
7.4 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;
|
|
|
|
// 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();
|
|
});
|