# 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
``` **JavaScript** — Neue Funktion `markSeriesWatched()`: ```javascript function markSeriesWatched(btn) { const seriesId = btn.dataset.seriesId; // Alle ungesehenen Episoden aus ALLEN Staffeln sammeln const allCards = document.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)'); const ids = []; allCards.forEach(card => { const vid = card.dataset.videoId; if (vid) ids.push(parseInt(vid)); }); if (ids.length === 0) return; // Batch-Request an API Promise.all(ids.map(id => fetch('/tv/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }), }) )).then(() => { // Alle Episoden-Cards als gesehen markieren document.querySelectorAll('.tv-episode-tile').forEach(card => { card.classList.add('tv-ep-seen'); const markBtn = card.querySelector('.tv-ep-tile-mark'); if (markBtn) markBtn.classList.add('active'); const thumb = card.querySelector('.tv-ep-thumb'); if (thumb && !thumb.querySelector('.tv-ep-watched')) { const check = document.createElement('div'); check.className = 'tv-ep-watched'; check.innerHTML = '✓'; thumb.appendChild(check); } }); // Alle Staffel-Tabs als komplett markieren document.querySelectorAll('.tv-tab').forEach(tab => { if (!tab.classList.contains('tv-tab-complete')) { tab.classList.add('tv-tab-complete'); if (!tab.querySelector('.tv-tab-check')) { const check = document.createElement('span'); check.className = 'tv-tab-check'; check.innerHTML = ' ✓'; tab.appendChild(check); } } }); // Button-Zustand ändern btn.classList.add('active'); }).catch(() => {}); } ``` **CSS** — Styling für den neuen Button: ```css .tv-mark-series-btn { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.5rem 1rem; background: transparent; border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); cursor: pointer; font-size: 0.9rem; transition: background 0.2s, border-color 0.2s; } .tv-mark-series-btn:hover, .tv-mark-series-btn:focus { background: var(--bg-hover); border-color: var(--accent); } .tv-mark-series-btn.active { background: var(--accent); color: #000; border-color: var(--accent); } ``` **i18n** — Neuer Übersetzungsschlüssel: ``` series.mark_all_watched = "Serie als gesehen" (de_DE) series.mark_all_watched = "Mark series watched" (en_US) ``` ## Zusammenfassung der Änderungen | # | Datei | Art | Beschreibung | |---|-------|-----|-------------| | 1 | `series.html` | JS ändern | Buchstaben ohne Serien per JS entfernen statt dimmen | | 2 | `tv.js` | Code einfügen | Explizite Sidebar-Navigation (ArrowRight→Sidebar, ArrowLeft→Content, sequentiell Up/Down) | | 2b | `tv.js` | Code ändern | Sidebar aus Nearest-Neighbor-Suche ausschließen | | 3 | `tv.css` | CSS ändern | Episode-Grid: `minmax(240px, 1fr)`, Laufzeit-Badge größer | | 4 | `series_detail.html` | HTML ändern | Mark-Button: `tabindex="-1"` → `data-focusable` | | 4b | `tv.css` | CSS ändern | Mark-Button: `opacity: 0.5` statt `0`, Focus-Ring | | 5 | `series_detail.html` | HTML+JS einfügen | "Serie als gesehen"-Button + `markSeriesWatched()` | | 5b | `tv.css` | CSS einfügen | Styling für `.tv-mark-series-btn` | | 5c | i18n | Key einfügen | `series.mark_all_watched` | ## Risiken & Hinweise - **Batch-Requests:** `markSeriesWatched()` sendet einen Request pro Episode. Bei Serien mit 200+ Episoden könnte das den Server belasten. Alternative: Batch-Endpoint im Backend. Für jetzt akzeptabel, da `markSeasonWatched()` das gleiche Pattern nutzt. - **Sidebar-Navigation:** Die explizite Logik muss vor dem Nearest-Neighbor-Algorithmus greifen, sonst wird sie nie erreicht. - **Episode-Mark-Button:** Mit `data-focusable` wird der Button ein eigener D-Pad-Stopp. Der Nutzer muss also einmal extra navigieren pro Card (Card → Button). Das ist gewollt, damit man zwischen "Abspielen" (Card-Link) und "Als gesehen markieren" (Button) wählen kann.