docker.videokonverter/docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md
data efbda20e42 docs: Spec-Korrekturen nach Review
- Jinja2-Variante entfernt (nur JS-Lösung)
- i18n: bestehenden Key status.mark_series verwenden
- Responsive Breakpoint 1200px berücksichtigt
- opacity-Fix nur für TV (hover:none Media-Query)
- Änderung 2/2b als atomar markiert
- focusin-Handler Sidebar-Ausschluss dokumentiert

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

353 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:** 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.