docker.videokonverter/docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md
data 205830f474 docs: Design-Spec für TV-App D-Pad & Usability Fixes
5 Probleme identifiziert und Lösungen spezifiziert:
- Alphabet-Sidebar nicht per Fernbedienung erreichbar
- Obere Buchstaben verdeckt (nur verfügbare rendern)
- Episoden-Cards zu klein / Laufzeit abgeschnitten
- Gesehen-Button nicht fokussierbar (tabindex/-1)
- Kein Serie-als-gesehen-Button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:36:06 +01:00

355 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<!-- Verfügbare Buchstaben sammeln -->
{% 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 %}
<nav class="tv-alpha-sidebar" id="alpha-sidebar" ...>
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#' %}
{% if letter in available_letters %}
<span class="tv-alpha-letter" data-letter="{{ letter }}"
onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
{% endif %}
{% endfor %}
</nav>
```
**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
<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:
```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
<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('series.mark_all_watched') }}</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** — 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.