docker.videokonverter/video-konverter/app/static/tv/js/tv.js
data 99730f2f8f feat: VideoKonverter v3.1 - TV-App, Auth, Tizen, Log-API
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>
2026-02-28 09:26:19 +01:00

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();
});