- 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>
14 KiB
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:
- Alphabet-Sidebar nicht per D-Pad erreichbar
- Obere Buchstaben verdeckt (zu viele Einträge)
- Episoden-Cards zu klein / Laufzeit abgeschnitten
- "Gesehen"-Button auf Episode-Cards nicht fokussierbar
- 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:
// 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: 12pxliegt, 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:
- ArrowRight am rechten Rand des Serien-Grids → Focus springt zum nächstgelegenen Buchstaben in der Sidebar
- ArrowLeft in der Sidebar → Focus springt zurück zur letzten fokussierten Serien-Card
- 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:
// 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):
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:
// 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.
/* 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):
<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"entfernendata-focusablehinzufü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:
/* 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):
<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('status.mark_series') }}</span>
</button>
</div>
JavaScript — Neue Funktion markSeriesWatched():
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:
.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, damarkSeasonWatched()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-focusablewird 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.