diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c735d..4943d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ 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 ### TV-App: UX-Verbesserungen & Bugfixes diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py index 734c10c..9a6ff4b 100644 --- a/video-konverter/app/routes/library_api.py +++ b/video-konverter/app/routes/library_api.py @@ -323,22 +323,93 @@ def setup_library_routes(app: web.Application, config: Config, return web.json_response(results) 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"]) filename = request.match_info["filename"] 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( {"error": "Nicht gefunden"}, status=404 ) - import os - file_path = os.path.join( - detail["folder_path"], ".metadata", filename - ) - if not os.path.isfile(file_path): - return web.json_response( - {"error": "Datei nicht gefunden"}, status=404 - ) + + folder_path = detail.get("folder_path", "") + meta_dir = os.path.join(folder_path, ".metadata") if folder_path else "" + file_path = os.path.join(meta_dir, filename) if meta_dir else "" + + # Lokale Datei nicht vorhanden? On-demand von TVDB downloaden + 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) # === Filme === diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index 7c9a022..dbf2ff6 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -18,6 +18,22 @@ def setup_tv_routes(app: web.Application, config: Config, library_service: LibraryService) -> None: """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 --- 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) movies = await cur.fetchall() + # Poster-URLs lokalisieren (kein TVDB-Laden) + _localize_posters(series, "series") + # Weiterschauen continue_watching = await auth_service.get_continue_watching( user["id"] @@ -304,6 +323,9 @@ def setup_tv_routes(app: web.Application, config: Config, if g: all_genres.add(g) + # Poster-URLs lokalisieren (kein TVDB-Laden) + _localize_posters(series, "series") + # Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername) folder_data = [] 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: raise web.HTTPFound("/tv/series") + # Poster-URL lokalisieren + _localize_posters([series], "series") + return aiohttp_jinja2.render_template( "tv/series_detail.html", request, { "user": user, @@ -775,6 +800,9 @@ def setup_tv_routes(app: web.Application, config: Config, """, (search_term, search_term)) results_movies = await cur.fetchall() + # Poster-URLs lokalisieren + _localize_posters(results_series, "series") + # Such-History speichern await auth_service.save_search(user["id"], query) else: @@ -1066,6 +1094,8 @@ def setup_tv_routes(app: web.Application, config: Config, """GET /tv/watchlist - Merkliste anzeigen""" user = request["tv_user"] wl = await auth_service.get_watchlist(user["id"]) + # Poster-URLs lokalisieren + _localize_posters(wl["series"], "series") return aiohttp_jinja2.render_template( "tv/watchlist.html", request, { "user": user, diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index 6898334..6ffcd5f 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -144,6 +144,17 @@ a { color: var(--accent); text-decoration: none; } grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); 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 === */ .tv-card { @@ -1307,6 +1318,13 @@ select.select-editing { border-color: 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 { display: flex; flex-wrap: wrap; diff --git a/video-konverter/app/static/tv/js/tv.js b/video-konverter/app/static/tv/js/tv.js index 576b2b3..ea475b0 100644 --- a/video-konverter/app/static/tv/js/tv.js +++ b/video-konverter/app/static/tv/js/tv.js @@ -14,6 +14,8 @@ class FocusManager { 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)); @@ -26,9 +28,19 @@ class FocusManager { 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")) { - 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(); 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]"); if (contentFirst) { contentFirst.focus(); @@ -87,9 +111,15 @@ class FocusManager { _navigate(direction, e) { 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 (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 @@ -97,8 +127,12 @@ class FocusManager { 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(); + } } - // Sonst: normal weiternavigieren (Select wird uebersprungen) } 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 (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.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); 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]"); if (contentFirst) { contentFirst.focus(); @@ -249,6 +299,22 @@ class FocusManager { 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; @@ -267,12 +333,17 @@ class FocusManager { _goBack(e) { 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 (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(); - e.preventDefault(); } + e.preventDefault(); return; }