diff --git a/docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md b/docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md new file mode 100644 index 0000000..3ac3129 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md @@ -0,0 +1,355 @@ +# TV-App D-Pad & Usability Fixes (Samsung Tizen) + +**Datum:** 2026-03-16 +**Zielgerät:** Samsung Tizen TV (1920x1080) + +## Zusammenfassung + +5 Probleme bei der Fernbedienungs-Navigation der TV-App beheben: + +1. Alphabet-Sidebar nicht per D-Pad erreichbar +2. Obere Buchstaben verdeckt (zu viele Einträge) +3. Episoden-Cards zu klein / Laufzeit abgeschnitten +4. "Gesehen"-Button auf Episode-Cards nicht fokussierbar +5. Kein "Serie als gesehen"-Button (nur Staffel-Level) + +## Betroffene Dateien + +| Datei | Änderungen | +|-------|------------| +| `video-konverter/app/templates/tv/series.html` | Alphabet-Sidebar: nur verfügbare Buchstaben rendern | +| `video-konverter/app/templates/tv/series_detail.html` | Episode-Mark-Button fokussierbar machen, "Serie als gesehen"-Button | +| `video-konverter/app/static/tv/css/tv.css` | Episoden-Grid vergrößern, Sidebar anpassen, Mark-Button sichtbar | +| `video-konverter/app/static/tv/js/tv.js` | FocusManager: explizite Sidebar-Navigation | + +## Änderung 1: Alphabet-Sidebar nur verfügbare Buchstaben + +### Problem +Die Sidebar rendert alle 26 Buchstaben + `#` (27 Einträge × 36px = 972px). Buchstaben ohne Serien werden nur gedimmt, nicht entfernt. Auf 1080p-TVs ragt die Sidebar über den Bildschirm hinaus. + +### Lösung +Im Template `series.html` (Zeile 161-167): Nur Buchstaben rendern, die tatsächlich Serien haben. Das Backend liefert bereits die Serien mit `data-letter` Attribut — wir sammeln die verfügbaren Buchstaben serverseitig oder per Jinja2-Filter. + +**Ansatz:** Die verfügbaren Buchstaben werden im Template aus den Serien-Daten extrahiert: + +```html + +{% set available_letters = [] %} +{% for s in series %} + {% set letter = s.sort_title[0:1]|upper if s.sort_title else '#' %} + {% set letter = letter if letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' else '#' %} + {% if letter not in available_letters %} + {% do available_letters.append(letter) %} + {% endif %} +{% endfor %} + + +``` + +**Fallback:** Falls Jinja2 `{% do %}` nicht unterstützt wird, die Filterung per JavaScript im bestehenden IIFE durchführen: + +```javascript +// Buchstaben ohne Treffer komplett entfernen (statt nur dimmen) +(function() { + var avail = {}; + document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) { + var raw = item.dataset.letter; + avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true; + }); + document.querySelectorAll('.tv-alpha-letter').forEach(function(el) { + if (!avail[el.dataset.letter]) el.remove(); // entfernen statt dimmen + }); +})(); +``` + +**Bevorzugter Ansatz:** JavaScript-Variante — einfacher, kein Backend-Change nötig. + +## Änderung 2: Alphabet-Sidebar per D-Pad erreichbar machen + +### Problem +Der FocusManager (tv.js, Zeile 240-243) filtert bei der Nearest-Neighbor-Suche Nav-Elemente vs. Content-Elemente. Die Sidebar ist weder Nav noch "normaler" Content — sie ist `position: fixed` am rechten Rand. Der Nearest-Neighbor-Algorithmus findet sie nicht zuverlässig, weil: +- Die Sidebar bei `right: 12px` liegt, also außerhalb des Content-Grid-Flows +- Der Distanz-Bias (Zeile 266-267) horizontale Abstände 3× stärker gewichtet + +### Lösung +Explizite Sidebar-Navigation im FocusManager einbauen: + +1. **ArrowRight** am rechten Rand des Serien-Grids → Focus springt zum nächstgelegenen Buchstaben in der Sidebar +2. **ArrowLeft** in der Sidebar → Focus springt zurück zur letzten fokussierten Serien-Card +3. **ArrowUp/ArrowDown** in der Sidebar → sequentielle Navigation durch die Buchstaben + +In `tv.js`, nach der Nav-Logik (ca. Zeile 173), neue Sidebar-Logik einfügen: + +```javascript +// Alphabet-Sidebar Navigation +const inSidebar = active.closest(".tv-alpha-sidebar"); + +if (inSidebar) { + if (direction === "ArrowLeft") { + // Zurück zum Content + if (this._lastContentFocus && document.contains(this._lastContentFocus)) { + this._lastContentFocus.focus(); + } else { + const firstCard = document.querySelector(".tv-grid [data-focusable], .tv-card[data-focusable]"); + if (firstCard) firstCard.focus(); + } + e.preventDefault(); + return; + } + if (direction === "ArrowUp" || direction === "ArrowDown") { + // Sequentiell durch Buchstaben + const letters = Array.from(document.querySelectorAll(".tv-alpha-letter[data-focusable]")) + .filter(el => el.offsetHeight > 0); + const idx = letters.indexOf(active); + const next = direction === "ArrowDown" ? idx + 1 : idx - 1; + if (next >= 0 && next < letters.length) { + letters[next].focus(); + letters[next].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + e.preventDefault(); + return; + } +} + +// ArrowRight am rechten Grid-Rand -> Sidebar +if (!inNav && !inSidebar && direction === "ArrowRight") { + const sidebar = document.getElementById("alpha-sidebar"); + if (sidebar && sidebar.offsetHeight > 0) { + // Prüfen ob es noch ein Element rechts im Content gibt + const contentEls = focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar")); + const currentRect_r = active.getBoundingClientRect(); + const rightNeighbor = contentEls.some(el => { + const r = el.getBoundingClientRect(); + return r.left > currentRect_r.right + 5 && Math.abs(r.top - currentRect_r.top) < 100; + }); + if (!rightNeighbor) { + // Kein Content rechts -> zur Sidebar springen + const sidebarLetters = Array.from(sidebar.querySelectorAll("[data-focusable]")) + .filter(el => el.offsetHeight > 0); + if (sidebarLetters.length > 0) { + // Nächstgelegenen Buchstaben vertikal finden + const cy_r = currentRect_r.top + currentRect_r.height / 2; + let best = sidebarLetters[0]; + let bestDist_r = Infinity; + sidebarLetters.forEach(l => { + const lr = l.getBoundingClientRect(); + const d = Math.abs(lr.top + lr.height / 2 - cy_r); + if (d < bestDist_r) { bestDist_r = d; best = l; } + }); + this._lastContentFocus = active; + best.focus(); + e.preventDefault(); + return; + } + } + } +} +``` + +Zusätzlich: Die Sidebar-Elemente aus der normalen Nearest-Neighbor-Suche ausschließen (Zeile 240-243 erweitern): + +```javascript +const searchEls = inNav + ? focusables.filter(el => el.closest("#tv-nav")) + : focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar")); +``` + +## Änderung 3: Episoden-Cards vergrößern + +### Problem +Das Episode-Grid nutzt `minmax(180px, 1fr)` (tv.css, Zeile 1821). Auf einem 1080p-TV sind die Cards zu klein und die Laufzeit-Badge wird abgeschnitten. + +### Lösung +Grid-Spalten vergrößern und Laufzeit-Badge anpassen: + +```css +.tv-episode-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); /* war 180px */ + gap: 1rem; /* war 0.8rem */ +} + +.tv-ep-duration { + font-size: 0.8rem; /* war 0.7rem */ + padding: 3px 8px; /* war 2px 6px */ + white-space: nowrap; +} +``` + +## Änderung 4: "Gesehen"-Button auf Episode-Cards fokussierbar + +### Problem +Der Mark-Button (series_detail.html, Zeile 129-132) hat `tabindex="-1"` und kein `data-focusable`. Dadurch kann er per D-Pad nie erreicht werden. Zusätzlich hat er `opacity: 0` und wird nur bei hover/focus-within sichtbar — auf TV ohne Maus problematisch. + +### Lösung + +**Template** (series_detail.html, Zeile 129-132): +```html + +``` + +Änderungen: +- `tabindex="-1"` entfernen +- `data-focusable` hinzufügen + +**CSS** (tv.css): Mark-Button auf TV immer sichtbar machen: +```css +/* Mark-Button auf TV immer sichtbar (halbtransparent) */ +.tv-ep-tile-mark { + opacity: 0.5; /* war 0 — immer leicht sichtbar */ +} + +.tv-episode-tile:hover .tv-ep-tile-mark, +.tv-episode-tile:focus-within .tv-ep-tile-mark, +.tv-ep-tile-mark:focus { + opacity: 1; +} + +/* Focus-Ring für Mark-Button */ +.tv-ep-tile-mark:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +``` + +## Änderung 5: "Serie als gesehen markieren"-Button + +### Problem +Es gibt nur `markSeasonWatched()` pro Staffel (series_detail.html, Zeile 96-99). Bei Serien mit vielen Staffeln muss man jede einzeln durchgehen. + +### Lösung + +**Template** — Neuen Button im Header-Aktionsbereich (series_detail.html, Zeile 57-66): +```html +