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>
This commit is contained in:
parent
95df4d7a90
commit
205830f474
1 changed files with 355 additions and 0 deletions
355
docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md
Normal file
355
docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md
Normal file
|
|
@ -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
|
||||||
|
<!-- 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)">
|
||||||
|
✓
|
||||||
|
</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">✓</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 = '✓';
|
||||||
|
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.
|
||||||
Loading…
Reference in a new issue