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>
This commit is contained in:
Eduard Wisch 2026-03-01 09:49:05 +01:00
parent c7151e8bd1
commit e8f2d49949
5 changed files with 235 additions and 21 deletions

View file

@ -2,6 +2,30 @@
Alle relevanten Aenderungen am VideoKonverter-Projekt. Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [4.0.2] - 2026-03-01
### TV-App: FocusManager-Fix, Poster-Caching, Performance
#### Bugfixes
- **FocusManager Navigation**: Von der oberen Nav-Leiste nach unten navigieren springt jetzt direkt zu Content-Karten (Grid/Liste/Detail) statt bei Filter-Dropdowns und View-Switch-Buttons haengenzubleiben
- **Input/Select Editier-Modus**: Textfelder und Select-Dropdowns werden erst nach Enter-Bestaetigung editierbar - D-Pad Navigation kann jetzt ueber Formularfelder hinweg navigieren ohne haengenzubleiben
- **Content-Focus-Speicher**: `_lastContentFocus` merkt sich nur noch echte Content-Elemente (Karten, Listen-Eintraege), nicht mehr Filter/Controls
#### Performance
- **Poster-Caching mit Resize**: Poster-Bilder werden beim ersten Abruf lokal in `.metadata/` gespeichert und auf 300px Breite verkleinert (Pillow)
- 80% kleinere Bilder (233KB → 47KB pro Poster)
- Kein externer TVDB-Request mehr nach erstem Laden
- Cache-Hit: ~10ms statt ~80ms
- **Content-Visibility**: Versteckte View-Container nutzen `content-visibility: hidden` fuer bessere Render-Performance
#### Geaenderte Dateien (4 Dateien, +211/-21 Zeilen)
- `app/routes/library_api.py` - On-Demand Poster-Download + Pillow-Resize-Cache
- `app/routes/tv_api.py` - `_localize_posters()` Helper fuer lokale Poster-URLs
- `app/static/tv/css/tv.css` - Input-Editing-Style, Content-Visibility
- `app/static/tv/js/tv.js` - FocusManager: Input-Modus, Nav→Content Fix, initFocus Fix
---
## [4.0.1] - 2026-03-01 ## [4.0.1] - 2026-03-01
### TV-App: UX-Verbesserungen & Bugfixes ### TV-App: UX-Verbesserungen & Bugfixes

View file

@ -323,22 +323,93 @@ def setup_library_routes(app: web.Application, config: Config,
return web.json_response(results) return web.json_response(results)
async def get_metadata_image(request: web.Request) -> web.Response: async def get_metadata_image(request: web.Request) -> web.Response:
"""GET /api/library/metadata/{series_id}/{filename}""" """GET /api/library/metadata/{series_id}/{filename}?w=300
Laedt Bilder lokal aus .metadata/ oder downloaded on-demand von TVDB.
Optionaler Parameter w= verkleinert auf angegebene Breite (gecacht)."""
import os
import aiohttp as aiohttp_client
series_id = int(request.match_info["series_id"]) series_id = int(request.match_info["series_id"])
filename = request.match_info["filename"] filename = request.match_info["filename"]
detail = await library_service.get_series_detail(series_id) detail = await library_service.get_series_detail(series_id)
if not detail or not detail.get("folder_path"): if not detail:
return web.json_response( return web.json_response(
{"error": "Nicht gefunden"}, status=404 {"error": "Nicht gefunden"}, status=404
) )
import os
file_path = os.path.join( folder_path = detail.get("folder_path", "")
detail["folder_path"], ".metadata", filename meta_dir = os.path.join(folder_path, ".metadata") if folder_path else ""
) file_path = os.path.join(meta_dir, filename) if meta_dir else ""
if not os.path.isfile(file_path):
return web.json_response( # Lokale Datei nicht vorhanden? On-demand von TVDB downloaden
{"error": "Datei nicht gefunden"}, status=404 if not file_path or not os.path.isfile(file_path):
) poster_url = detail.get("poster_url", "")
if filename.startswith("poster") and poster_url and folder_path:
os.makedirs(meta_dir, exist_ok=True)
try:
async with aiohttp_client.ClientSession() as session:
async with session.get(
poster_url,
timeout=aiohttp_client.ClientTimeout(total=15)
) as resp:
if resp.status == 200:
data = await resp.read()
with open(file_path, "wb") as f:
f.write(data)
logging.info(
f"Poster heruntergeladen: Serie {series_id}"
f" ({len(data)} Bytes)")
else:
# Download fehlgeschlagen -> Redirect
raise web.HTTPFound(poster_url)
except web.HTTPFound:
raise
except Exception as e:
logging.warning(
f"Poster-Download fehlgeschlagen fuer Serie "
f"{series_id}: {e}")
if poster_url:
raise web.HTTPFound(poster_url)
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
elif filename.startswith("poster") and poster_url:
# Kein Ordner -> Redirect zur externen URL
raise web.HTTPFound(poster_url)
else:
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
# Resize-Parameter: ?w=300 verkleinert auf max. 300px Breite
width_param = request.query.get("w")
if width_param:
try:
target_w = int(width_param)
if 50 <= target_w <= 1000:
base, _ = os.path.splitext(filename)
cache_name = f"{base}_w{target_w}.jpg"
cache_path = os.path.join(meta_dir, cache_name)
if not os.path.isfile(cache_path):
try:
from PIL import Image
with Image.open(file_path) as img:
if img.width > target_w:
ratio = target_w / img.width
new_h = int(img.height * ratio)
img = img.resize(
(target_w, new_h), Image.LANCZOS
)
img = img.convert("RGB")
img.save(cache_path, "JPEG", quality=80)
except Exception as e:
logging.warning(
f"Poster-Resize fehlgeschlagen: {e}")
return web.FileResponse(file_path)
return web.FileResponse(cache_path)
except ValueError:
pass
return web.FileResponse(file_path) return web.FileResponse(file_path)
# === Filme === # === Filme ===

View file

@ -18,6 +18,22 @@ def setup_tv_routes(app: web.Application, config: Config,
library_service: LibraryService) -> None: library_service: LibraryService) -> None:
"""Registriert alle TV-App Routes""" """Registriert alle TV-App Routes"""
# --- Poster-URL Lokalisierung ---
# TVDB-URLs durch lokalen Endpunkt ersetzen (schnelleres Laden)
_POSTER_WIDTH = 300 # Max. Breite fuer Poster-Thumbnails
def _localize_posters(rows, content_type="series"):
"""poster_url durch lokalen Resize-Endpunkt ersetzen.
Vermeidet externe TVDB-Requests, Bilder werden lokal gecacht."""
for row in rows:
if content_type == "series":
row["poster_url"] = (
f"/api/library/metadata/{row['id']}"
f"/poster.jpg?w={_POSTER_WIDTH}"
)
# Filme: noch keine lokale Metadaten -> URL beibehalten
# --- Auth-Hilfsfunktionen --- # --- Auth-Hilfsfunktionen ---
async def get_tv_user(request: web.Request) -> dict | None: async def get_tv_user(request: web.Request) -> dict | None:
@ -193,6 +209,9 @@ def setup_tv_routes(app: web.Application, config: Config,
await cur.execute(movies_query, params) await cur.execute(movies_query, params)
movies = await cur.fetchall() movies = await cur.fetchall()
# Poster-URLs lokalisieren (kein TVDB-Laden)
_localize_posters(series, "series")
# Weiterschauen # Weiterschauen
continue_watching = await auth_service.get_continue_watching( continue_watching = await auth_service.get_continue_watching(
user["id"] user["id"]
@ -304,6 +323,9 @@ def setup_tv_routes(app: web.Application, config: Config,
if g: if g:
all_genres.add(g) all_genres.add(g)
# Poster-URLs lokalisieren (kein TVDB-Laden)
_localize_posters(series, "series")
# Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername) # Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername)
folder_data = [] folder_data = []
src_map = {str(src["id"]): src["name"] for src in sources} src_map = {str(src["id"]): src["name"] for src in sources}
@ -441,6 +463,9 @@ def setup_tv_routes(app: web.Application, config: Config,
if not series: if not series:
raise web.HTTPFound("/tv/series") raise web.HTTPFound("/tv/series")
# Poster-URL lokalisieren
_localize_posters([series], "series")
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/series_detail.html", request, { "tv/series_detail.html", request, {
"user": user, "user": user,
@ -775,6 +800,9 @@ def setup_tv_routes(app: web.Application, config: Config,
""", (search_term, search_term)) """, (search_term, search_term))
results_movies = await cur.fetchall() results_movies = await cur.fetchall()
# Poster-URLs lokalisieren
_localize_posters(results_series, "series")
# Such-History speichern # Such-History speichern
await auth_service.save_search(user["id"], query) await auth_service.save_search(user["id"], query)
else: else:
@ -1066,6 +1094,8 @@ def setup_tv_routes(app: web.Application, config: Config,
"""GET /tv/watchlist - Merkliste anzeigen""" """GET /tv/watchlist - Merkliste anzeigen"""
user = request["tv_user"] user = request["tv_user"]
wl = await auth_service.get_watchlist(user["id"]) wl = await auth_service.get_watchlist(user["id"])
# Poster-URLs lokalisieren
_localize_posters(wl["series"], "series")
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/watchlist.html", request, { "tv/watchlist.html", request, {
"user": user, "user": user,

View file

@ -144,6 +144,17 @@ a { color: var(--accent); text-decoration: none; }
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px; gap: 12px;
} }
/* Versteckte Ansichten vom Rendering ausschliessen */
[style*="display:none"].tv-view-grid,
[style*="display:none"].tv-view-list,
[style*="display:none"].tv-view-detail,
[style*="display:none"].tv-view-folder,
[style*="display: none"].tv-view-grid,
[style*="display: none"].tv-view-list,
[style*="display: none"].tv-view-detail,
[style*="display: none"].tv-view-folder {
content-visibility: hidden;
}
/* === Poster-Karten === */ /* === Poster-Karten === */
.tv-card { .tv-card {
@ -1307,6 +1318,13 @@ select.select-editing {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent); box-shadow: 0 0 0 2px var(--accent);
} }
/* INPUT/TEXTAREA im Editier-Modus */
input.input-editing,
textarea.input-editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
background: var(--bg-hover);
}
.color-picker-grid { .color-picker-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -14,6 +14,8 @@ class FocusManager {
this._lastContentFocus = null; this._lastContentFocus = null;
// SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte // SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte
this._selectActive = false; this._selectActive = false;
// INPUT/TEXTAREA Editier-Modus: erst Enter druecken, dann tippen
this._inputActive = false;
// Tastatur-Events abfangen // Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e)); document.addEventListener("keydown", (e) => this._onKeyDown(e));
@ -26,9 +28,19 @@ class FocusManager {
document.querySelectorAll(".select-editing").forEach( document.querySelectorAll(".select-editing").forEach(
el => el.classList.remove("select-editing")); 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 && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav")) { if (!e.target.closest("#tv-nav")) {
this._lastContentFocus = e.target; // 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;
}
} }
} }
}); });
@ -44,7 +56,19 @@ class FocusManager {
autofocusEl.focus(); autofocusEl.focus();
return; return;
} }
// Erstes Element im Content bevorzugen (nicht Nav) // 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]"); const contentFirst = document.querySelector(".tv-main [data-focusable]");
if (contentFirst) { if (contentFirst) {
contentFirst.focus(); contentFirst.focus();
@ -87,9 +111,15 @@ class FocusManager {
_navigate(direction, e) { _navigate(direction, e) {
const active = document.activeElement; const active = document.activeElement;
// Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation) // Input-Felder: Nur im Editier-Modus Cursor-Navigation erlauben
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (direction === "ArrowLeft" || direction === "ArrowRight") return; 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 // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen
@ -97,8 +127,12 @@ class FocusManager {
if (this._selectActive) { if (this._selectActive) {
// Editier-Modus: Hoch/Runter aendert den Wert // Editier-Modus: Hoch/Runter aendert den Wert
if (direction === "ArrowUp" || direction === "ArrowDown") return; if (direction === "ArrowUp" || direction === "ArrowDown") return;
} else {
// Nicht im Editier-Modus: native SELECT-Aenderung verhindern
if (direction === "ArrowUp" || direction === "ArrowDown") {
e.preventDefault();
}
} }
// Sonst: normal weiternavigieren (Select wird uebersprungen)
} }
const focusables = this._getFocusableElements(); const focusables = this._getFocusableElements();
@ -129,15 +163,31 @@ class FocusManager {
} }
} }
// Von Nav nach unten -> zum Content springen // Von Nav nach unten -> direkt zu Content-Karten (Filter/View-Switch ueberspringen)
if (inNav && direction === "ArrowDown") { if (inNav && direction === "ArrowDown") {
if (this._lastContentFocus && document.contains(this._lastContentFocus)) { // Gespeicherten Content-Focus bevorzugen (nur wenn noch sichtbar)
if (this._lastContentFocus && document.contains(this._lastContentFocus)
&& this._lastContentFocus.offsetHeight > 0) {
this._lastContentFocus.focus(); this._lastContentFocus.focus();
this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" }); this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" });
e.preventDefault(); e.preventDefault();
return; return;
} }
// Sonst: erstes Content-Element // 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]"); const contentFirst = document.querySelector(".tv-main [data-focusable]");
if (contentFirst) { if (contentFirst) {
contentFirst.focus(); contentFirst.focus();
@ -249,6 +299,22 @@ class FocusManager {
return; 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 // Checkbox: Toggle
if (active.tagName === "INPUT" && active.type === "checkbox") { if (active.tagName === "INPUT" && active.type === "checkbox") {
active.checked = !active.checked; active.checked = !active.checked;
@ -267,12 +333,17 @@ class FocusManager {
_goBack(e) { _goBack(e) {
const active = document.activeElement; const active = document.activeElement;
// In Input-Feldern: Escape = Blur // In Input-Feldern: Escape = Editier-Modus beenden oder Blur
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (e.key === "Escape" || e.keyCode === 10009) { 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(); active.blur();
e.preventDefault();
} }
e.preventDefault();
return; return;
} }