docker.videokonverter/docs/superpowers/plans/2026-03-16-tv-dpad-fixes.md
data 26adabe55c docs: Implementierungsplan für TV-App D-Pad Fixes
6 Tasks mit exakten Zeilennummern und Code-Snippets:
1. Alphabet-Sidebar nur verfügbare Buchstaben
2. FocusManager Sidebar-Navigation
3. Episoden-Cards vergrößern
4. Gesehen-Button fokussierbar
5. Serie-als-gesehen Button
6. Finaler Test

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

18 KiB

TV-App D-Pad & Usability Fixes - Implementierungsplan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 5 Fernbedienungs-/Usability-Probleme der TV-App auf Samsung Tizen beheben.

Architecture: Reine Frontend-Änderungen an 4 Dateien — JavaScript (FocusManager + Template-Scripts), CSS (Episode-Grid, Mark-Button, neuer Button), HTML (Templates). Kein Backend-Change. Bestehender i18n-Key status.mark_series wird wiederverwendet.

Tech Stack: Vanilla JavaScript, CSS3, Jinja2-Templates, aiohttp

Spec: docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md


Dateiübersicht

Datei Änderung
video-konverter/app/static/tv/js/tv.js FocusManager: Sidebar-Navigation, focusin-Handler
video-konverter/app/static/tv/css/tv.css Episode-Grid größer, Mark-Button sichtbar, neuer Button-Style
video-konverter/app/templates/tv/series.html Alphabet-IIFE: entfernen statt dimmen
video-konverter/app/templates/tv/series_detail.html Mark-Button fokussierbar, "Serie als gesehen"-Button + JS

Task 1: Alphabet-Sidebar — nur verfügbare Buchstaben anzeigen

Files:

  • Modify: video-konverter/app/templates/tv/series.html:243-253

  • Step 1: IIFE ändern — el.remove() statt el.classList.add('dimmed')

In series.html, Zeile 243-253, das bestehende IIFE anpassen:

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

Konkret: Zeile 251 ändern von:

        if (!avail[el.dataset.letter]) el.classList.add('dimmed');

zu:

        if (!avail[el.dataset.letter]) el.remove();
  • Step 2: Manuell testen

Browser öffnen → TV-Serien-Seite → prüfen dass nur Buchstaben angezeigt werden, die tatsächlich Serien haben.

  • Step 3: Commit
git add video-konverter/app/templates/tv/series.html
git commit -m "fix(tv): Alphabet-Sidebar zeigt nur verfügbare Buchstaben"

Task 2: FocusManager — Sidebar per D-Pad erreichbar

Files:

  • Modify: video-konverter/app/static/tv/js/tv.js:38-43 (focusin-Handler)
  • Modify: video-konverter/app/static/tv/js/tv.js:230 (nach ArrowUp-Nav-Block, vor Nearest-Neighbor)
  • Modify: video-konverter/app/static/tv/js/tv.js:240-243 (searchEls-Filter)

WICHTIG: Alle 3 Änderungen sind atomar — zusammen einfügen!

  • Step 1: focusin-Handler anpassen — Sidebar aus Content-Tracking ausschließen

In tv.js, Zeile 38-43, die Bedingung erweitern. Aktuell:

            if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
                if (!e.target.closest("#tv-nav")) {
                    // Nur echte Content-Elemente merken (nicht Filter/Controls)
                    if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
                        this._lastContentFocus = e.target;
                    }
                }
            }

Ändern zu (.tv-alpha-sidebar aus der Content-Areas-Liste entfernen UND Sidebar explizit in äußerer Bedingung ausschließen):

            if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
                if (!e.target.closest("#tv-nav") && !e.target.closest(".tv-alpha-sidebar")) {
                    // Nur echte Content-Elemente merken (nicht Nav/Sidebar)
                    if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
                        this._lastContentFocus = e.target;
                    }
                }
            }
  • Step 2: Sidebar-Navigationslogik einfügen — nach ArrowUp-Block (Zeile 230)

In tv.js, nach Zeile 230 (dem Ende des if (!inNav && direction === "ArrowUp") Blocks), VOR dem Nearest-Neighbor-Block (Zeile 232: const currentRect = ...), folgenden Code einfügen:

        // ===== Alphabet-Sidebar Navigation =====
        const inSidebar = active.closest(".tv-alpha-sidebar");

        if (inSidebar) {
            if (direction === "ArrowLeft") {
                // Zurueck 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) {
                // Pruefen 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) {
                        // Naechstgelegenen 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;
                    }
                }
            }
        }
  • Step 3: Nearest-Neighbor searchEls-Filter erweitern — Sidebar ausschließen

In tv.js, die Zeilen mit dem searchEls-Filter (ca. Zeile 240-243, nach Einfügung verschoben) ändern von:

        const searchEls = inNav
            ? focusables.filter(el => el.closest("#tv-nav"))
            : focusables.filter(el => !el.closest("#tv-nav"));

zu:

        const searchEls = inNav
            ? focusables.filter(el => el.closest("#tv-nav"))
            : focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
  • Step 4: Manuell testen

Browser/Tizen-Emulator → Serien-Seite:

  1. D-Pad rechts am Grid-Rand → Sidebar bekommt Focus
  2. D-Pad hoch/runter in Sidebar → sequentiell durch Buchstaben
  3. D-Pad links in Sidebar → zurück zur letzten Card
  4. Enter in Sidebar → filtert Serien nach Buchstabe
  • Step 5: Commit
git add video-konverter/app/static/tv/js/tv.js
git commit -m "fix(tv): Alphabet-Sidebar per D-Pad/Fernbedienung navigierbar"

Task 3: Episoden-Cards vergrößern + Laufzeit-Badge

Files:

  • Modify: video-konverter/app/static/tv/css/tv.css:1817-1821 (Episode-Grid Basis)

  • Modify: video-konverter/app/static/tv/css/tv.css:2019-2020 (1200px Breakpoint)

  • Step 1: Basis-Grid vergrößern

In tv.css, Zeile 1819 ändern von:

    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));

zu:

    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));

Zeile 1820 ändern von:

    gap: 0.8rem;

zu:

    gap: 1rem;
  • Step 2: 1200px-Breakpoint anpassen

In tv.css, Zeile 2020 ändern von:

    .tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }

zu:

    .tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
  • Step 3: Laufzeit-Badge anpassen

In tv.css, die .tv-ep-duration-Regel finden (Zeile 452-461) und white-space: nowrap; hinzufügen sowie Font/Padding anpassen:

Aktuell:

.tv-ep-duration {
    position: absolute;
    bottom: 6px;
    right: 6px;
    background: rgba(0,0,0,0.75);
    color: #fff;
    font-size: 0.7rem;
    padding: 2px 6px;
    border-radius: 3px;
}

Ändern zu:

.tv-ep-duration {
    position: absolute;
    bottom: 6px;
    right: 6px;
    background: rgba(0,0,0,0.75);
    color: #fff;
    font-size: 0.8rem;
    padding: 3px 8px;
    border-radius: 3px;
    white-space: nowrap;
}
  • Step 4: Manuell testen

Browser → Serien-Detail-Seite → prüfen dass Episoden-Cards größer sind und die Laufzeit vollständig sichtbar ist.

  • Step 5: Commit
git add video-konverter/app/static/tv/css/tv.css
git commit -m "fix(tv): Episoden-Cards vergrößert, Laufzeit-Badge vollständig sichtbar"

Task 4: "Gesehen"-Button auf Episode-Cards fokussierbar machen

Files:

  • Modify: video-konverter/app/templates/tv/series_detail.html:129-132 (HTML)

  • Modify: video-konverter/app/static/tv/css/tv.css:1864-1899 (CSS)

  • Step 1: HTML — tabindex="-1" entfernen, data-focusable hinzufügen

In series_detail.html, Zeile 129-132 ändern von:

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

zu:

                <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>
  • Step 2: CSS — Mark-Button auf TV sichtbar machen (hover:none Media-Query)

In tv.css, nach der bestehenden .tv-ep-tile-mark-Regel (Zeile 1882, nach z-index: 2; und vor der hover/focus-Regel), folgende Media-Query einfügen:

/* TV-Geraete (kein Hover): Mark-Button immer leicht sichtbar */
@media (hover: none) {
    .tv-ep-tile-mark {
        opacity: 0.5;
    }
}
  • Step 3: CSS — Focus-Ring für Mark-Button

Die bestehende Regel in tv.css, Zeile 1894-1899 ist bereits vorhanden:

.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
    border-color: var(--accent);
    color: var(--accent);
    outline: none;
    opacity: 1;
}

Diese enthält outline: none — für D-Pad-Navigation brauchen wir stattdessen einen sichtbaren Focus-Ring. Ändern zu:

.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
    border-color: var(--accent);
    color: var(--accent);
    opacity: 1;
}
.tv-ep-tile-mark:focus {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}
  • Step 4: Manuell testen

Browser → Serien-Detail → D-Pad auf Episode-Card → dann D-Pad hoch/links zum Mark-Button → Enter drückt "Gesehen". Prüfen:

  1. Button ist leicht sichtbar (auf TV/touch)
  2. Button bekommt Focus-Ring bei D-Pad-Navigation
  3. Enter toggled gesehen/ungesehen
  • Step 5: Commit
git add video-konverter/app/templates/tv/series_detail.html video-konverter/app/static/tv/css/tv.css
git commit -m "fix(tv): Gesehen-Button per D-Pad fokussierbar, auf TV sichtbar"

Task 5: "Serie als gesehen markieren"-Button

Files:

  • Modify: video-konverter/app/templates/tv/series_detail.html:57-66 (HTML)

  • Modify: video-konverter/app/templates/tv/series_detail.html (JS am Ende des Script-Blocks)

  • Modify: video-konverter/app/static/tv/css/tv.css (neues Styling)

  • Step 1: HTML — Button im Header-Aktionsbereich einfügen

In series_detail.html, nach dem Watchlist-Button (Zeile 65, vor </div> auf Zeile 66), einfügen:

                <!-- 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>
  • Step 2: JavaScript — markSeriesWatched() Funktion hinzufügen

In series_detail.html, im <script>-Block nach der toggleWatched()-Funktion (nach Zeile 255), einfügen:

function markSeriesWatched(btn) {
    // Alle ungesehenen Episoden aus ALLEN Staffeln sammeln
    const allCards = document.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
    const ids = [];
    allCards.forEach(function(card) {
        var vid = card.dataset.videoId;
        if (vid) ids.push(parseInt(vid));
    });
    if (ids.length === 0) return;

    // Batch-Request an API (gleiche Methode wie markSeasonWatched)
    Promise.all(ids.map(function(id) {
        return 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(function() {
        // Alle Episoden-Cards als gesehen markieren
        document.querySelectorAll('.tv-episode-tile').forEach(function(card) {
            card.classList.add('tv-ep-seen');
            var markBtn = card.querySelector('.tv-ep-tile-mark');
            if (markBtn) markBtn.classList.add('active');
            var thumb = card.querySelector('.tv-ep-thumb');
            if (thumb && !thumb.querySelector('.tv-ep-watched')) {
                var 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(function(tab) {
            if (!tab.classList.contains('tv-tab-complete')) {
                tab.classList.add('tv-tab-complete');
                if (!tab.querySelector('.tv-tab-check')) {
                    var check = document.createElement('span');
                    check.className = 'tv-tab-check';
                    check.innerHTML = ' &#10003;';
                    tab.appendChild(check);
                }
            }
        });
        // Button-Zustand aendern
        btn.classList.add('active');
    }).catch(function() {});
}
  • Step 3: CSS — Styling für .tv-mark-series-btn

In tv.css, am Ende der Serien-Detail-Styles (vor den Episode-Grid-Styles, ca. Zeile 1815), einfügen:

/* Serie als gesehen markieren - 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);
}
  • Step 4: Manuell testen

Browser → Serien-Detail-Seite:

  1. Button "Serie als gesehen" ist im Header sichtbar
  2. D-Pad kann den Button fokussieren
  3. Enter markiert alle Episoden aller Staffeln als gesehen
  4. Alle Staffel-Tabs zeigen Häkchen
  5. Button ändert Farbe zu accent
  • Step 5: Commit
git add video-konverter/app/templates/tv/series_detail.html video-konverter/app/static/tv/css/tv.css
git commit -m "feat(tv): Serie als gesehen markieren - Button im Header"

Task 6: Finaler Test & Gesamtcommit

  • Step 1: Alle Änderungen zusammen testen

Checkliste:

  • Serien-Seite: Alphabet zeigt nur verfügbare Buchstaben

  • Serien-Seite: D-Pad rechts → Sidebar, links → zurück, Enter → filtert

  • Serien-Detail: Episoden-Cards größer, Laufzeit vollständig sichtbar

  • Serien-Detail: D-Pad kann Gesehen-Button auf Cards fokussieren + Enter togglen

  • Serien-Detail: "Serie als gesehen"-Button funktioniert

  • Step 2: Docker-Image bauen und taggen

cd "/mnt/17 - Entwicklungen/20 - Projekte/VideoKonverter"
PUID=1000 PGID=1000 docker compose --profile cpu build