# 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:** Per JavaScript im bestehenden IIFE — Buchstaben ohne Treffer komplett entfernen statt dimmen. Das bestehende Script (series.html, Zeile 244-253) wird angepasst:

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

Kein Backend-Change nötig, kein Jinja2-Template-Change.

## Ä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;
            }
        }
    }
}
```

**WICHTIG: Beide folgenden Änderungen sind atomar — sie müssen zusammen eingefügt werden!**

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"));
```

Außerdem: Im `focusin`-Handler (tv.js) die Sidebar aus dem `_lastContentFocus`-Tracking ausschließen, damit der ArrowLeft-Rücksprung zuverlässig funktioniert:

```javascript
// Im focusin-Handler: Sidebar-Elemente nicht als Content-Focus speichern
if (!el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar")) {
    this._lastContentFocus = el;
}
```

**Enter-Handling:** Wenn der Nutzer in der Sidebar Enter drückt, simuliert der FocusManager einen Click auf das `data-focusable`-Element, was `onclick="filterByLetter()"` auslöst. Kein zusätzlicher Code nötig.

## Ä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. **Achtung:** Es gibt einen `@media (min-width: 1200px)` Breakpoint (tv.css, Zeile 2019-2020) der auf `minmax(220px, 1fr)` setzt — dieser muss ebenfalls angepasst werden.

```css
/* Basis (tv.css, Zeile 1821) */
.tv-episode-grid {
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));  /* war 180px */
    gap: 1rem;  /* war 0.8rem */
}

/* Responsive: 1200px+ Breakpoint (tv.css, Zeile 2019-2020) — anpassen */
@media (min-width: 1200px) {
    .tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }  /* war 220px */
}

.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
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
        data-focusable
        onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
    &#10003;
</button>
```

Änderungen:
- `tabindex="-1"` entfernen
- `data-focusable` hinzufügen

**CSS** (tv.css): Mark-Button auf TV immer sichtbar machen. Der Base-Wert `opacity: 0` bleibt für Desktop/Mobile erhalten — nur auf TV (kein Hover-Support) wird der Button dauerhaft sichtbar:

```css
/* TV-Geräte (kein Hover): Mark-Button immer leicht sichtbar */
@media (hover: none) {
    .tv-ep-tile-mark {
        opacity: 0.5;
    }
}

.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
<div class="tv-detail-actions">
    <button class="tv-watchlist-btn ..." ...>
        ...
    </button>
    <!-- NEU: Serie als gesehen markieren -->
    <button class="tv-mark-series-btn"
            id="btn-mark-series"
            data-focusable
            data-series-id="{{ series.id }}"
            onclick="markSeriesWatched(this)">
        <span class="mark-series-icon">&#10003;</span>
        <span class="mark-series-text">{{ t('status.mark_series') }}</span>
    </button>
</div>
```

**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 = '&#10003;';
                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 = ' &#10003;';
                    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** — Bestehender Schlüssel `status.mark_series` wird verwendet:
- `de.json`: `"mark_series": "Serie als gesehen"` (bereits vorhanden)
- `en.json`: `"mark_series": "Mark series as watched"` (bereits vorhanden)

Kein neuer i18n-Key nötig.

## 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 | **ATOMAR:** Explizite Sidebar-Navigation + Sidebar aus Nearest-Neighbor ausschließen + focusin-Handler anpassen |
| 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` nur auf TV (hover:none), 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 | — | Bestehender Key `status.mark_series` wird verwendet (kein Change) |

## 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.
