diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 451280b..fe92ea6 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -14,18 +14,36 @@ export type Datei = { id: string; fileName: string; sizeBytes: number; container: string | null; durationSeconds: number | null; streams: Spur[]; }; +/** Was dieser Benutzer mit einem Titel zu tun hatte. */ +export type Stand = { + positionSeconds: number; runtimeSeconds: number | null; + watched: boolean; playCount: number; + favourite: boolean; rating: number | null; dismissed: boolean; + lastPlayedAt: string | null; +}; export type Film = { id: string; title: string; year: number | null; overview: string | null; posterImage: string | null; backdropImage: string | null; files: Datei[]; + state: Stand | null; }; export type Serie = { id: string; title: string; year: number | null; overview: string | null; posterImage: string | null; backdropImage: string | null; - episodeCount: number; seasons: number[]; + episodeCount: number; seasons: number[]; unwatchedCount: number; }; export type Folge = { id: string; season: number; episode: number; title: string | null; overview: string | null; stillImage: string | null; files: Datei[]; + state: Stand | null; +}; + +/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */ +export type Angefangen = { + id: string; kind: 'movie' | 'episode'; + title: string; subTitle: string | null; + season: number | null; episode: number | null; year: number | null; + posterImage: string | null; backdropImage: string | null; + state: Stand; }; export type Bibliothek = { id: string; name: string; kind: string; path: string }; export type Seite = { items: T[]; total: number }; @@ -168,8 +186,40 @@ export const serien = (q: { search?: string; skip?: number; take?: number } = {} export const folgen = (serie: string, staffel?: number) => anfrage>(`/series/${serie}/episodes${abfrage({ season: staffel })}`); +export const film = (id: string) => anfrage(`/movies/${id}`); +export const serie = (id: string) => anfrage(`/series/${id}`); + export const lieferung = (datei: string) => anfrage(`/files/${datei}/delivery`); +/* ---------- Stand je Benutzer ---------- */ + +export const weiterschauen = (take = 20) => + anfrage>(`/home/continue${abfrage({ take })}`); + +export const naechsteFolgen = (take = 20) => + anfrage>(`/home/next${abfrage({ take })}`); + +/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */ +export const fortschritt = (id: string, stelle: number, laenge?: number | null) => + anfrage(`/items/${id}/progress`, { + method: 'POST', + body: JSON.stringify({ positionSeconds: stelle, runtimeSeconds: laenge ?? null }), + }); + +export const gesehen = (id: string, wert: boolean) => + anfrage(`/items/${id}/watched`, { + method: 'PUT', body: JSON.stringify({ watched: wert }), + }); + +/** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */ +export const weglegen = (id: string) => + anfrage(`/items/${id}/dismiss`, { method: 'POST' }); + +export const favorit = (id: string, wert: boolean) => + anfrage(`/items/${id}/favourite`, { + method: 'PUT', body: JSON.stringify({ favourite: wert }), + }); + export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`; /* ---------- Verwaltung ---------- */ diff --git a/web/app/src/style.css b/web/app/src/style.css index 39832a9..ec94e0e 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -144,6 +144,24 @@ button, input, select { font: inherit; color: inherit; } .poster img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .2s; } .poster.geladen img { opacity: 1; } +/* Der Fortschrittsbalken auf der Kachel. Flaro zeigt ihn ab der ersten + Sekunde — es gibt keinen Anteil, der zu klein wäre, um ihn zu merken. */ +.fortschritt { + position: absolute; left: 0; right: 0; bottom: 0; height: 3px; + background: rgba(255, 255, 255, .22); +} +/* Eine Mindestbreite, damit auch eine einzige Minute sichtbar ist — bei + 2:36 Laufzeit wären das sonst anderthalb Bildpunkte. */ +.fortschritt > div { height: 100%; min-width: 4px; background: var(--accent); } + +/* Die Plakette mit den offenen Folgen, wie bei Plex in der Ecke. */ +.offen { + position: absolute; top: 6px; right: 6px; + min-width: 18px; padding: 1px 5px; border-radius: 9px; + background: var(--accent); color: #fff; + font-size: 11px; font-weight: 600; line-height: 16px; text-align: center; +} + .rowline { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .rowline strong { font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/app/src/util.ts b/web/app/src/util.ts index a520e72..efc5f8c 100644 --- a/web/app/src/util.ts +++ b/web/app/src/util.ts @@ -24,7 +24,9 @@ export function groesse(bytes: number): string { * * Der Platzhalter hält die Fläche frei, bis das Bild da ist — ohne ihn * springt das Raster beim Nachladen jedes Mal um. */ -export function plakat(schluessel: string | null, beschriftung: string): HTMLElement { +export function plakat( + schluessel: string | null, beschriftung: string, fortschritt?: number | null, +): HTMLElement { const rahmen = document.createElement('div'); rahmen.className = 'poster'; rahmen.setAttribute('role', 'img'); @@ -40,9 +42,34 @@ export function plakat(schluessel: string | null, beschriftung: string): HTMLEle img.onload = () => rahmen.classList.add('geladen'); rahmen.appendChild(img); } + + // Der Balken unten, wie bei Plex. Er erscheint, sobald überhaupt etwas + // gesehen wurde — es gibt keinen Anteil, der dafür zu klein wäre. + if (fortschritt != null && fortschritt > 0) { + const balken = document.createElement('div'); + balken.className = 'fortschritt'; + const gefuellt = document.createElement('div'); + gefuellt.style.width = `${Math.min(100, fortschritt * 100)}%`; + balken.appendChild(gefuellt); + rahmen.appendChild(balken); + } return rahmen; } +/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */ +export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined) + : number | null { + if (!stand?.runtimeSeconds || stand.runtimeSeconds <= 0) return null; + return stand.positionSeconds / stand.runtimeSeconds; +} + +/** „noch 42 Min." — was auf einer Kachel wirklich interessiert. */ +export function verbleibend(stand: { positionSeconds: number; runtimeSeconds: number | null }): string { + if (!stand.runtimeSeconds) return ''; + const rest = Math.max(0, stand.runtimeSeconds - stand.positionSeconds); + return rest < 60 ? 'fast durch' : `noch ${Math.round(rest / 60)} Min.`; +} + export function knopf(text: string, klasse = 'btn'): HTMLButtonElement { const b = document.createElement('button'); b.className = klasse; diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index fc91eaa..822ff5f 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -1,9 +1,11 @@ import { - filme, serien, folgen, lieferung, downloadAdresse, vorschlaege, zuordnen, - bildAdresse, istAdmin, - type Film, type Serie, type Folge, type Datei, type Lieferung, + film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse, + vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, + type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand, } from '../api'; -import { esc, dauer, groesse, plakat, knopf, feld, fehlerzeile, leer } from '../util'; +import { + esc, dauer, groesse, plakat, knopf, feld, fehlerzeile, leer, anteil, verbleibend, +} from '../util'; type Art = 'filme' | 'serien'; @@ -15,28 +17,35 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem async function laden(): Promise { wurzel.innerHTML = '
'; try { + // **Einzeln geholt, nicht aus einer Liste gefischt.** Vorher wurden + // 200 Titel geladen und darin gesucht; bei 201 Filmen hätte die + // Detailseite des letzten „gibt es nicht mehr" gesagt. if (art === 'filme') { - const seite = await filme({ take: 200 }); - const film = seite.items.find(f => f.id === id); - if (!film) { wurzel.innerHTML = ''; wurzel.appendChild(leer('Diesen Film gibt es nicht mehr.')); return; } + const film = await filmLaden(id); wurzel.innerHTML = ''; - wurzel.append(kopfbereich(film), dateiliste(film.files), zuordnungsbereich(film.title, film.year)); + wurzel.append( + kopfbereich(film, film.state), + dateiliste(film.files, film.id, film.state), + zuordnungsbereich(film.title, film.year)); } else { - const seite = await serien({ take: 200 }); - const serie = seite.items.find(s => s.id === id); - if (!serie) { wurzel.innerHTML = ''; wurzel.appendChild(leer('Diese Serie gibt es nicht mehr.')); return; } + const serie = await serieLaden(id); wurzel.innerHTML = ''; - wurzel.append(kopfbereich(serie), await staffeln(serie), zuordnungsbereich(serie.title, serie.year)); + wurzel.append( + kopfbereich(serie, null), + await staffeln(serie), + zuordnungsbereich(serie.title, serie.year)); } } catch (fehler) { wurzel.innerHTML = ''; - wurzel.appendChild(fehlerzeile((fehler as Error).message)); + wurzel.appendChild(fehler instanceof Error && /404/.test(fehler.message) + ? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.') + : fehlerzeile((fehler as Error).message)); } } /* ---------- Kopf ---------- */ - function kopfbereich(eintrag: Film | Serie): HTMLElement { + function kopfbereich(eintrag: Film | Serie, stand: Stand | null): HTMLElement { const kopf = document.createElement('header'); kopf.className = 'detail-head'; @@ -49,13 +58,17 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const text = document.createElement('div'); text.className = 'detail-text'; const jahr = eintrag.year ? ` (${eintrag.year})` : ''; + const offen = 'unwatchedCount' in eintrag && eintrag.unwatchedCount > 0 + ? `${eintrag.unwatchedCount} von ${eintrag.episodeCount} Folgen offen` + : ''; text.innerHTML = `

${esc(eintrag.title)}${esc(jahr)}

` + + (stand?.watched ? 'Gesehen' : offen) + `

${esc(eintrag.overview ?? 'Keine Beschreibung.')}

`; const innen = document.createElement('div'); innen.className = 'detail-inner'; - innen.append(plakat(eintrag.posterImage, eintrag.title), text); + innen.append(plakat(eintrag.posterImage, eintrag.title, anteil(stand)), text); kopf.append(zurueckKnopf, innen); return kopf; @@ -63,7 +76,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem /* ---------- Dateien und Spuren ---------- */ - function dateiliste(dateien: Datei[]): HTMLElement { + function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement { const bereich = document.createElement('section'); bereich.className = 'settings'; @@ -92,26 +105,43 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem }).join(''); block.appendChild(spuren); - block.appendChild(aktionen(datei)); + block.appendChild(aktionen(datei, titel, stand)); bereich.appendChild(block); }); return bereich; } - function aktionen(datei: Datei): HTMLElement { + function aktionen(datei: Datei, titel: string, stand: Stand | null): HTMLElement { const reihe = document.createElement('div'); reihe.className = 'btn-row'; - const stand = document.createElement('div'); - stand.className = 'hint'; + const zustandszeile = document.createElement('div'); + zustandszeile.className = 'hint'; - const abspielen = knopf('Abspielen', 'btn btn-primary'); + // **Gibt es eine Stelle, stehen beide Knöpfe da** — und zwar auch bei + // einem gesehenen Film. Der Server wirft die Stelle nicht weg, wenn das + // Häkchen kommt; sie hier trotzdem zu verschweigen hiesse, den + // Unterschied wegzuwerfen, um dessentwillen er sie behält. + // + // Ein „Abspielen", das stillschweigend in der Mitte anfängt, wäre die + // andere falsche Lösung: eine Überraschung, keine Hilfe. + const stelle = stand?.positionSeconds ?? 0; + const abspielen = knopf( + stelle > 0 ? `Weiterschauen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary'); + const vonVorn = stelle > 0 ? knopf('Von vorn', 'btn') : null; const herunterladen = knopf('Herunterladen', 'btn'); + const haken = knopf(stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', 'btn'); + haken.onclick = async () => { + haken.disabled = true; + try { await gesehen(titel, !stand?.watched); await laden(); } + catch (fehler) { zustandszeile.textContent = (fehler as Error).message; haken.disabled = false; } + }; + /** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */ async function bereitstellen(): Promise { let auskunft: Lieferung; try { auskunft = await lieferung(datei.id); } - catch (fehler) { stand.textContent = (fehler as Error).message; return false; } + catch (fehler) { zustandszeile.textContent = (fehler as Error).message; return false; } if (auskunft.method === 'directFile') return true; if (auskunft.packageState === 'ready') return true; @@ -121,28 +151,31 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const gruende: string[] = []; if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`); if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`); - stand.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`; + zustandszeile.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`; // Der erste Abruf stösst an, danach wird nachgefragt. for (let i = 0; i < 600; i++) { const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' }); - if (antwort.ok) { stand.textContent = ''; return true; } - if (antwort.status !== 202) { stand.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; } + if (antwort.ok) { zustandszeile.textContent = ''; return true; } + if (antwort.status !== 202) { zustandszeile.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; } const wie = await lieferung(datei.id); - if (wie.error) { stand.textContent = wie.error; return false; } - stand.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`; + if (wie.error) { zustandszeile.textContent = wie.error; return false; } + zustandszeile.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`; await new Promise(r => setTimeout(r, 1000)); } - stand.textContent = 'Das Umpacken dauert ungewöhnlich lange.'; + zustandszeile.textContent = 'Das Umpacken dauert ungewöhnlich lange.'; return false; } - abspielen.onclick = async () => { - abspielen.disabled = true; - if (await bereitstellen()) spieler(downloadAdresse(datei.id)); - abspielen.disabled = false; - }; + async function starten(ab: number, knopfDavon: HTMLButtonElement): Promise { + knopfDavon.disabled = true; + if (await bereitstellen()) spieler(downloadAdresse(datei.id), titel, ab, laden); + knopfDavon.disabled = false; + } + + abspielen.onclick = () => void starten(stelle, abspielen); + if (vonVorn) vonVorn.onclick = () => void starten(0, vonVorn); herunterladen.onclick = async () => { herunterladen.disabled = true; @@ -157,9 +190,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem herunterladen.disabled = false; }; - reihe.append(abspielen, herunterladen); + reihe.append(abspielen); + if (vonVorn) reihe.appendChild(vonVorn); + reihe.append(herunterladen, haken); const huelle = document.createElement('div'); - huelle.append(reihe, stand); + huelle.append(reihe, zustandszeile); return huelle; } @@ -189,8 +224,11 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem block.className = 'setting'; const zeile = document.createElement('div'); zeile.className = 'rowline'; + const nebenbei = folge.state?.watched ? 'Gesehen' + : folge.state && folge.state.positionSeconds > 0 ? verbleibend(folge.state) : ''; zeile.innerHTML = - `${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}`; + `${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}` + + (nebenbei ? `${esc(nebenbei)}` : ''); block.appendChild(zeile); if (folge.overview) { @@ -199,7 +237,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem text.textContent = folge.overview; block.appendChild(text); } - folge.files.forEach(datei => block.appendChild(aktionen(datei))); + folge.files.forEach(datei => block.appendChild(aktionen(datei, folge.id, folge.state))); return block; } @@ -276,7 +314,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem /* ---------- Abspielen ---------- */ -function spieler(adresse: string): void { +/** Wie oft der Stand zum Server geht. */ +const MELDEABSTAND = 10_000; + +function spieler(adresse: string, titel: string, ab: number, danach: () => void): void { const schicht = document.createElement('div'); schicht.className = 'pl-fill'; @@ -285,10 +326,37 @@ function spieler(adresse: string): void { video.controls = true; video.autoplay = true; video.style.cssText = 'width:100%;height:100%;background:#000'; + if (ab > 0) video.currentTime = ab; + + // **Alle zehn Sekunden, nicht bei jedem Bild.** `timeupdate` feuert + // viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit + // Meldungen zu bewerfen, die sich um 250 ms unterscheiden. + let zuletzt = 0; + const melden = (erzwingen = false) => { + const jetzt = Date.now(); + if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return; + zuletzt = jetzt; + const laenge = Number.isFinite(video.duration) ? video.duration : null; + // Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür + // mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere + // Hälfte des Tauschs. + void fortschritt(titel, video.currentTime, laenge).catch(() => {}); + }; + + video.ontimeupdate = () => melden(); + video.onpause = () => melden(true); + video.onended = () => melden(true); const schliessen = knopf('Schliessen', 'btn'); schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2'; - schliessen.onclick = () => { video.pause(); schicht.remove(); }; + schliessen.onclick = () => { + video.pause(); + melden(true); + schicht.remove(); + // Damit Knopfbeschriftung, Balken und Häkchen stimmen, wenn man + // dahinter wieder auftaucht. + danach(); + }; schicht.append(video, schliessen); schicht.onkeydown = e => { if (e.key === 'Escape') schliessen.click(); }; diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index b877945..d304d02 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -1,18 +1,24 @@ -import { filme, serien, type Film, type Serie } from '../api'; -import { esc, plakat, leer, fehlerzeile } from '../util'; +import { + filme, serien, weiterschauen, naechsteFolgen, + type Film, type Serie, type Angefangen, +} from '../api'; +import { esc, plakat, leer, fehlerzeile, anteil, verbleibend } from '../util'; -/** Die Startseite: je eine waagerechte Reihe, wie bei Plex. +type Art = 'filme' | 'serien'; + +/** Die Startseite: waagerechte Reihen, wie bei Plex. * - * Noch ohne „Weiterschauen" — dafür braucht es den Sehfortschritt, und der - * kommt erst in Stufe 4. Eine leere Reihe mit diesem Namen wäre eine Zusage, - * die niemand einlöst. */ -export function startseite(oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement { + * Oben, was angefangen ist — darum geht man schliesslich hin. Darunter die + * Mediathek. Eine Reihe, die nichts enthält, wird gar nicht erst gezeigt. */ +export function startseite(oeffnen: (art: Art, id: string) => void): HTMLElement { const wurzel = document.createElement('div'); wurzel.innerHTML = '
'; void (async () => { try { - const [f, s] = await Promise.all([filme({ take: 24 }), serien({ take: 24 })]); + const [weiter, naechste, f, s] = await Promise.all([ + weiterschauen(), naechsteFolgen(), filme({ take: 24 }), serien({ take: 24 }), + ]); wurzel.innerHTML = ''; if (f.total === 0 && s.total === 0) { @@ -20,6 +26,9 @@ export function startseite(oeffnen: (art: 'filme' | 'serien', id: string) => voi + 'und starte einen Scan.')); return; } + + if (weiter.items.length) wurzel.appendChild(angefangenReihe('Weiterschauen', weiter)); + if (naechste.items.length) wurzel.appendChild(angefangenReihe('Nächste Folge', naechste)); if (f.total > 0) wurzel.appendChild(reihe(`Filme · ${f.total}`, f.items, 'filme')); if (s.total > 0) wurzel.appendChild(reihe(`Serien · ${s.total}`, s.items, 'serien')); } catch (fehler) { @@ -28,7 +37,45 @@ export function startseite(oeffnen: (art: 'filme' | 'serien', id: string) => voi } })(); - function reihe(titel: string, eintraege: (Film | Serie)[], art: 'filme' | 'serien'): HTMLElement { + /** Eine Reihe aus Einträgen mit Stand — Filme und Folgen gemischt. */ + function angefangenReihe(titel: string, seite: { items: Angefangen[] }): HTMLElement { + const bereich = document.createElement('section'); + bereich.className = 'reihe'; + + const h = document.createElement('h2'); + h.textContent = titel; + + const streifen = document.createElement('div'); + streifen.className = 'strip'; + + seite.items.forEach(eintrag => { + const k = document.createElement('button'); + k.className = 'card'; + k.appendChild(plakat(eintrag.posterImage, eintrag.title, anteil(eintrag.state))); + + const unten = document.createElement('div'); + unten.className = 'rowline'; + // Bei einer Folge oben die Serie, darunter wo man ist — eine Folge + // ohne ihre Serie zu nennen hilft niemandem beim Wiedererkennen. + const zweite = eintrag.kind === 'episode' + ? `S${String(eintrag.season).padStart(2, '0')}E${String(eintrag.episode).padStart(2, '0')}` + + (eintrag.subTitle ? ` · ${eintrag.subTitle}` : '') + : verbleibend(eintrag.state); + unten.innerHTML = `${esc(eintrag.title)}` + + `${esc(zweite)}`; + k.appendChild(unten); + + // Angesprungen wird der Titel, nicht die Folge: von dort kommt man zur + // Folge, umgekehrt fehlte der Zusammenhang. + k.onclick = () => oeffnen(eintrag.kind === 'movie' ? 'filme' : 'serien', eintrag.id); + streifen.appendChild(k); + }); + + bereich.append(h, streifen); + return bereich; + } + + function reihe(titel: string, eintraege: (Film | Serie)[], art: Art): HTMLElement { const bereich = document.createElement('section'); bereich.className = 'reihe'; @@ -41,7 +88,17 @@ export function startseite(oeffnen: (art: 'filme' | 'serien', id: string) => voi eintraege.forEach(eintrag => { const k = document.createElement('button'); k.className = 'card'; - k.appendChild(plakat(eintrag.posterImage, eintrag.title)); + + const bild = plakat( + eintrag.posterImage, eintrag.title, + 'state' in eintrag ? anteil(eintrag.state) : null); + if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) { + const plakette = document.createElement('span'); + plakette.className = 'offen'; + plakette.textContent = String(eintrag.unwatchedCount); + bild.appendChild(plakette); + } + k.appendChild(bild); const unten = document.createElement('div'); unten.className = 'rowline'; diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 44e9f47..fa53f5c 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -1,5 +1,5 @@ import { filme, serien, type Film, type Serie } from '../api'; -import { esc, plakat, leer, fehlerzeile } from '../util'; +import { esc, plakat, leer, fehlerzeile, anteil } from '../util'; type Art = 'filme' | 'serien'; @@ -66,7 +66,17 @@ export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HT function karte(eintrag: Film | Serie): HTMLElement { const k = document.createElement('button'); k.className = 'card'; - k.appendChild(plakat(eintrag.posterImage, eintrag.title)); + + const bild = plakat( + eintrag.posterImage, eintrag.title, + 'state' in eintrag ? anteil(eintrag.state) : null); + if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) { + const plakette = document.createElement('span'); + plakette.className = 'offen'; + plakette.textContent = String(eintrag.unwatchedCount); + bild.appendChild(plakette); + } + k.appendChild(bild); const unten = document.createElement('div'); unten.className = 'rowline'; @@ -75,6 +85,7 @@ export function mediathek(art: Art, oeffnen: (art: Art, id: string) => void): HT const teile = [ eintrag.year ? String(eintrag.year) : null, 'episodeCount' in eintrag ? `${eintrag.episodeCount} Folgen` : null, + 'state' in eintrag && eintrag.state?.watched ? 'Gesehen' : null, ].filter(Boolean); unten.innerHTML = `${esc(eintrag.title)}` + `${esc(teile.join(' · '))}`;