Compare commits

...

8 commits

Author SHA1 Message Date
406ba57a2d feat(tv): Serie als gesehen markieren - Button im Header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:28:10 +01:00
764da40447 fix(tv): Gesehen-Button per D-Pad fokussierbar, auf TV sichtbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:26:41 +01:00
f78bfd293b fix(tv): Episoden-Cards vergrößert, Laufzeit-Badge vollständig sichtbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:25:45 +01:00
78dd9ebe03 fix(tv): Alphabet-Sidebar per D-Pad/Fernbedienung navigierbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:15:31 +01:00
bce5460fcf fix(tv): Alphabet-Sidebar zeigt nur verfügbare Buchstaben
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:07:55 +01:00
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
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
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
6 changed files with 1052 additions and 12 deletions

View file

@ -0,0 +1,531 @@
# 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:
```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();
});
})();
```
Konkret: Zeile 251 ändern von:
```javascript
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
```
zu:
```javascript
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**
```bash
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:
```javascript
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):
```javascript
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:
```javascript
// ===== 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:
```javascript
const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav"));
```
zu:
```javascript
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**
```bash
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:
```css
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
```
zu:
```css
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
```
Zeile 1820 ändern von:
```css
gap: 0.8rem;
```
zu:
```css
gap: 1rem;
```
- [ ] **Step 2: 1200px-Breakpoint anpassen**
In `tv.css`, Zeile 2020 ändern von:
```css
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
```
zu:
```css
.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:
```css
.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:
```css
.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**
```bash
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:
```html
<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:
```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>
```
- [ ] **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:
```css
/* 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:
```css
.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:
```css
.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**
```bash
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:
```html
<!-- 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:
```javascript
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:
```css
/* 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**
```bash
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**
```bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/VideoKonverter"
PUID=1000 PGID=1000 docker compose --profile cpu build
```

View file

@ -0,0 +1,353 @@
# 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.

View file

@ -455,9 +455,10 @@ a { color: var(--accent); text-decoration: none; }
right: 6px; right: 6px;
background: rgba(0,0,0,0.75); background: rgba(0,0,0,0.75);
color: #fff; color: #fff;
font-size: 0.7rem; font-size: 0.8rem;
padding: 2px 6px; padding: 3px 8px;
border-radius: 3px; border-radius: 3px;
white-space: nowrap;
} }
/* Info-Bereich */ /* Info-Bereich */
@ -1813,11 +1814,36 @@ textarea.input-editing {
font-size: 0.75rem; font-size: 0.75rem;
} }
/* 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);
}
/* --- Episode Card-Grid (Phase 5, Plex-Style) --- */ /* --- Episode Card-Grid (Phase 5, Plex-Style) --- */
.tv-episode-grid { .tv-episode-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.8rem; gap: 1rem;
padding: 6px; /* Platz fuer Focus-Ring + Scale-Transform */ padding: 6px; /* Platz fuer Focus-Ring + Scale-Transform */
} }
.tv-episode-tile { .tv-episode-tile {
@ -1880,6 +1906,12 @@ textarea.input-editing {
transition: opacity 0.2s, background 0.2s; transition: opacity 0.2s, background 0.2s;
z-index: 2; z-index: 2;
} }
/* TV-Geraete (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:hover .tv-ep-tile-mark,
.tv-episode-tile:focus-within .tv-ep-tile-mark, .tv-episode-tile:focus-within .tv-ep-tile-mark,
.tv-ep-tile-mark:focus { .tv-ep-tile-mark:focus {
@ -1894,9 +1926,12 @@ textarea.input-editing {
.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus { .tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
outline: none;
opacity: 1; opacity: 1;
} }
.tv-ep-tile-mark:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Detail-Panel oben (zeigt Beschreibung der fokussierten Episode) */ /* Detail-Panel oben (zeigt Beschreibung der fokussierten Episode) */
.tv-ep-detail-panel { .tv-ep-detail-panel {
@ -2017,7 +2052,7 @@ textarea.input-editing {
/* Responsive: Episode-Grid */ /* Responsive: Episode-Grid */
@media (min-width: 1200px) { @media (min-width: 1200px) {
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } .tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.5rem; } .tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.5rem; }

View file

@ -36,9 +36,9 @@ class FocusManager {
el => el.classList.remove("input-editing")); el => el.classList.remove("input-editing"));
} }
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav")) { if (!e.target.closest("#tv-nav") && !e.target.closest(".tv-alpha-sidebar")) {
// Nur echte Content-Elemente merken (nicht Filter/Controls) // 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-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) { 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; this._lastContentFocus = e.target;
} }
} }
@ -229,6 +229,70 @@ class FocusManager {
} }
} }
// ===== 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;
}
}
}
}
// Naechstes Element in Richtung finden (Nearest-Neighbor) // Naechstes Element in Richtung finden (Nearest-Neighbor)
const currentRect = active.getBoundingClientRect(); const currentRect = active.getBoundingClientRect();
const cx = currentRect.left + currentRect.width / 2; const cx = currentRect.left + currentRect.width / 2;
@ -240,7 +304,7 @@ class FocusManager {
// Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen // Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen
const searchEls = inNav const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav")) ? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav")); : focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
for (const el of searchEls) { for (const el of searchEls) {
if (el === active) continue; if (el === active) continue;

View file

@ -248,7 +248,7 @@ function filterByLetter(letter) {
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true; avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
}); });
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) { document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.classList.add('dimmed'); if (!avail[el.dataset.letter]) el.remove();
}); });
})(); })();
</script> </script>

View file

@ -63,6 +63,15 @@
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span> <span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('series.watchlist') }}</span> <span class="watchlist-text">{{ t('series.watchlist') }}</span>
</button> </button>
<!-- 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> </div>
</div> </div>
</div> </div>
@ -127,7 +136,7 @@
</div> </div>
</a> </a>
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}" <button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
tabindex="-1" data-focusable
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)"> onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003; &#10003;
</button> </button>
@ -254,6 +263,54 @@ function toggleWatched(videoId, btn) {
.catch(() => {}); .catch(() => {});
} }
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() {});
}
function markSeasonWatched(seriesId, seasonNum) { function markSeasonWatched(seriesId, seasonNum) {
const season = document.getElementById('season-' + seasonNum); const season = document.getElementById('season-' + seasonNum);
if (!season) return; if (!season) return;