Weiterschauen, sichtbar gemacht
Die Oberfläche zeigt jetzt, was der Server seit Stufe 4 weiss. Auf der Startseite zwei neue Reihen, ganz oben: **Weiterschauen** und **Nächste Folge**. Sie überschneiden sich nicht — was angefangen ist, steht links, was ansteht, rechts. Eine leere Reihe wird gar nicht erst gezeigt. Auf jeder Kachel ein Balken, und zwar **ab der ersten Sekunde**. Bei einem Zweieinhalbstünder sind zwei Minuten 1,3 % — anderthalb Bildpunkte. Der Balken hat deshalb eine Mindestbreite, sonst wäre das Versprechen „wir merken uns jede Sekunde" ausgerechnet dort unsichtbar, wo es zählt. Serien tragen die Zahl offener Folgen als Plakette in der Ecke. Auf der Detailseite stehen **beide Knöpfe nebeneinander**: „Weiterschauen ab 0:42" und „Von vorn". Auch bei einem gesehenen Film — der Server wirft die Stelle nicht weg, wenn das Häkchen kommt, und sie hier zu verschweigen hiesse, genau den Unterschied wegzuwerfen, um dessentwillen er sie behält. Dazu ein Schalter für gesehen/ungesehen. Der Spieler meldet alle zehn Sekunden, beim Pausieren, am Ende und beim Schliessen. Nicht bei jedem `timeupdate`: das feuert viermal je Sekunde, und daraus Anfragen zu machen hiesse, den Server mit Meldungen zu bewerfen, die sich um 250 ms unterscheiden. Eine verlorene Meldung kostet ein paar Sekunden Fortschritt — dafür mitten im Film einen Fehler einzublenden wäre die schlechtere Hälfte des Tauschs. **Nebenbei ein Fehler beseitigt, der nur mit grosser Mediathek aufgefallen wäre:** die Detailseite holte 200 Titel und suchte darin nach der Kennung. Bei 201 Filmen hätte sie vom letzten behauptet, es gebe ihn nicht mehr. Jetzt gibt es den Einzelabruf, und sie benutzt ihn. Im Container durchgespielt: „Der Untergang" von vorn, die MKV wird umgepackt, läuft durch — danach steht „Gesehen" unter dem Titel, der Balken ist voll, und der Knopf bietet beides an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67cf0fd1d9
commit
6d5824b889
+51
-1
@@ -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<T> = { 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<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
|
||||
|
||||
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
|
||||
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
|
||||
|
||||
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
|
||||
|
||||
/* ---------- Stand je Benutzer ---------- */
|
||||
|
||||
export const weiterschauen = (take = 20) =>
|
||||
anfrage<Seite<Angefangen>>(`/home/continue${abfrage({ take })}`);
|
||||
|
||||
export const naechsteFolgen = (take = 20) =>
|
||||
anfrage<Seite<Angefangen>>(`/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<Stand>(`/items/${id}/progress`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ positionSeconds: stelle, runtimeSeconds: laenge ?? null }),
|
||||
});
|
||||
|
||||
export const gesehen = (id: string, wert: boolean) =>
|
||||
anfrage<Stand>(`/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<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
|
||||
|
||||
export const favorit = (id: string, wert: boolean) =>
|
||||
anfrage<Stand>(`/items/${id}/favourite`, {
|
||||
method: 'PUT', body: JSON.stringify({ favourite: wert }),
|
||||
});
|
||||
|
||||
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
|
||||
|
||||
/* ---------- Verwaltung ---------- */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
+28
-1
@@ -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;
|
||||
|
||||
+107
-39
@@ -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<void> {
|
||||
wurzel.innerHTML = '<div class="spinner"></div>';
|
||||
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
|
||||
? `<span class="tiny">${eintrag.unwatchedCount} von ${eintrag.episodeCount} Folgen offen</span>`
|
||||
: '';
|
||||
text.innerHTML =
|
||||
`<h1>${esc(eintrag.title)}${esc(jahr)}</h1>` +
|
||||
(stand?.watched ? '<span class="tiny">Gesehen</span>' : offen) +
|
||||
`<p>${esc(eintrag.overview ?? 'Keine Beschreibung.')}</p>`;
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 =
|
||||
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`;
|
||||
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
|
||||
+ (nebenbei ? `<span class="tiny">${esc(nebenbei)}</span>` : '');
|
||||
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(); };
|
||||
|
||||
+67
-10
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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 = `<strong>${esc(eintrag.title)}</strong>`
|
||||
+ `<span class="tiny">${esc(zweite)}</span>`;
|
||||
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';
|
||||
|
||||
@@ -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 = `<strong>${esc(eintrag.title)}</strong>`
|
||||
+ `<span class="tiny">${esc(teile.join(' · '))}</span>`;
|
||||
|
||||
Reference in New Issue
Block a user