From f10fb4c1bc9f6bb97674520f88506d2014769668 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 6 Oct 2026 15:42:42 +0200 Subject: [PATCH] Abspielen spielt ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Aufmacher der Startseite stand „Abspielen", und wer draufdrückte, landete auf der Detailseite. Gemessen am laufenden Server: beide Knöpfe riefen dieselbe Funktion — `oeffnen` —, der eine hiess nur anders. Drei Dinge waren falsch, alle an derselben Stelle: **Der Knopf log.** Jetzt startet er den Spieler, mit Fortsetzen ab der Stelle, an der man war. Ohne Datei — eine ganze Serie — heisst er „Ansehen" und führt dorthin, wo man wählen kann. **Eine angefangene Folge war kein Film.** Sie lief durch `alsHero` mit `art: 'filme'` und ihrer eigenen Kennung; der Klick ging auf `#/filme/` und endete in „Diesen Film gibt es nicht mehr". Nachgemessen im Browser, dann `angefangenAlsHero` dafür gebaut: die Detailseite gehört der Serie, der Fortschritt der Folge. **Ein geplanter Titel sah aus wie jeder andere.** Jetzt steht statt des Knopfes „Bald ist es soweit" mit dem Termin, wie auf der Detailseite. Dafür nennt `/home/continue` und `/home/next` nun Datei, Länge und Termin, und die Vorschläge bringen ihre Dateien mit — ohne die konnte der Aufmacher gar nicht abspielen. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 15 ++- .../Flaro.Api/Endpoints/PlaybackEndpoints.cs | 73 ++++++++++-- server/tests/Flaro.Api.Tests/PlaybackTests.cs | 40 +++++++ web/app/src/api.ts | 6 + web/app/src/hero.ts | 105 ++++++++++++++++-- web/app/src/views/home.ts | 30 ++++- 6 files changed, 248 insertions(+), 21 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 30be4eb..ec2875f 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -618,7 +618,20 @@ public sealed record ContinueEntry( int? Season, int? Episode, int? Year, string? PosterImage, string? BackdropImage, ItemStateResponse State, - Guid? SeriesId = null); + Guid? SeriesId = null, + + /// Die Datei, die abgespielt würde. + /// + /// + /// Damit „Abspielen" abspielt. Der Aufmacher der Startseite hatte + /// keine Datei zur Hand und konnte deshalb nur die Detailseite öffnen — + /// ein Knopf, auf dem „Abspielen" steht und der etwas anderes tut. + /// + Guid? FileId = null, + double? DurationSeconds = null, + + /// Ab wann abspielbar. In der Zukunft heisst: noch nicht. + DateTimeOffset? PublishAt = null); /// Eine laufende Wiedergabe — für das Dashboard. public sealed record SessionResponse( diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 7984bab..04ef834 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -286,7 +286,7 @@ public static class PlaybackEndpoints var folgen = await db.Episodes.AsNoTracking() .Where(f => serienIds.Contains(f.SeriesId)) - .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage }) + .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage, f.PublishAt }) .ToListAsync(); var folgenIds = folgen.Select(f => f.Id).ToList(); @@ -319,9 +319,19 @@ public static class PlaybackEndpoints naechste.SeasonNumber, naechste.EpisodeNumber, null, serie.PosterImage, naechste.StillImage ?? serie.PosterImage, zustand is null ? Leer() : Abbild(zustand), - SeriesId: x.SeriesId)); + SeriesId: x.SeriesId, + PublishAt: naechste.PublishAt)); } + // Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für + // alle Folgen aller angefassten Serien. + var erste = await Erstdateien(db, [], liste.Select(e => e.Id).ToList()); + liste = liste.Select(e => e with + { + FileId = erste.GetValueOrDefault(e.Id).Id, + DurationSeconds = erste.GetValueOrDefault(e.Id).Sekunden, + }).ToList(); + return Results.Ok(new Page(liste, liste.Count)); }); @@ -383,9 +393,24 @@ public static class PlaybackEndpoints .Select(x => x.Film) .ToListAsync(); + // **Mit Datei und Termin.** Ein Vorschlag ohne Datei liesse den + // Aufmacher „Abspielen" anbieten und dann doch nur blättern; ein + // Vorschlag ohne Termin verschwiege, dass es ihn noch nicht gibt. + var vorschlagIds = treffer.Select(m => m.Id).ToList(); + var vorschlagsdateien = await db.MediaFiles.AsNoTracking() + .Where(d => d.MovieId != null && vorschlagIds.Contains(d.MovieId!.Value)) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel }) + .ToListAsync(); + var items = treffer.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, - [], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList(); + vorschlagsdateien.Where(d => d.MovieId == m.Id) + .Select(d => new MediaFileResponse( + d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container, + d.Duration?.TotalSeconds, [], d.VersionLabel)) + .ToList(), + null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, + PublishAt: m.PublishAt)).ToList(); return Results.Ok(new Page(items, items.Count)); }); @@ -412,33 +437,67 @@ public static class PlaybackEndpoints var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id)) - .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage }) + .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt }) .ToDictionaryAsync(m => m.Id); var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id)) .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, - SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, + SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt, }) .ToDictionaryAsync(f => f.Id); + var dateien = await Erstdateien(db, filme, folgen); + var liste = new List(zustände.Count); foreach (var z in zustände) { if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m)) liste.Add(new ContinueEntry( m.Id, "movie", m.Title, null, null, null, m.Year, - m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z))); + m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z), + FileId: dateien.GetValueOrDefault(m.Id).Id, + DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden, + PublishAt: m.PublishAt)); else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) liste.Add(new ContinueEntry( f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z), - SeriesId: f.SerienId)); + SeriesId: f.SerienId, + FileId: dateien.GetValueOrDefault(f.Id).Id, + DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden, + PublishAt: f.PublishAt)); } return liste; } + /// Je Titel die erste Datei — die, die abgespielt würde. + /// + /// + /// In einer Abfrage für alle. Zwanzig Zeilen auf der Startseite + /// wären sonst zwanzig Abfragen, und SQLite führt die nacheinander aus. + /// + private static async Task> Erstdateien( + FlaroContext db, List filme, List folgen) + { + if (filme.Count == 0 && folgen.Count == 0) return []; + + var roh = await db.MediaFiles.AsNoTracking() + .Where(d => (d.MovieId != null && filme.Contains(d.MovieId!.Value)) + || (d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value))) + .Select(d => new { d.Id, d.MovieId, d.EpisodeId, d.Duration }) + .ToListAsync(); + + return roh + .GroupBy(d => d.MovieId ?? d.EpisodeId!.Value) + .ToDictionary(g => g.Key, g => + { + var erste = g.First(); + return ((Guid?)erste.Id, erste.Duration?.TotalSeconds); + }); + } + // ------------------------------------------------------------------ // Gemeinsames // ------------------------------------------------------------------ diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index 6518c3e..f8e8b6b 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -139,6 +139,46 @@ public class PlaybackTests }); } + /// „Weiterschauen" muss sagen, was abgespielt würde. + /// + /// + /// Sonst kann der Aufmacher nur blättern. Er bot „Abspielen" an + /// und öffnete die Detailseite — weil ihm die Datei fehlte. Und bei einer + /// Folge führte er auf #/filme/<Folgenkennung>, wo nichts + /// steht: die Detailseite gehört der Serie, der Fortschritt der Folge. + /// + [Test] + public async Task Weiterschauen_nennt_Datei_und_Serie() + { + Datei("Dark (2017)/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items.Single(); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + var folge = folgen!.Items.Single(); + + await Melden(client, folge.Id, 120, 1500); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + var zeile = reihe!.Items.Single(); + + Assert.Multiple(() => + { + Assert.That(zeile.Kind, Is.EqualTo("episode")); + Assert.That(zeile.SeriesId, Is.EqualTo(serie.Id), + "ohne die Serie landet der Aufmacher auf einer Folge, die als Film nicht existiert"); + Assert.That(zeile.FileId, Is.EqualTo(folge.Files.Single().Id), + "ohne die Datei kann „Abspielen\u201c nicht abspielen"); + // Dieselbe Länge wie die Datei selbst. Im Prüfbestand liegen + // Attrappen ohne Spuren, also steht dort beidemal nichts — der + // Punkt ist, dass es derselbe Wert ist. + Assert.That(zeile.DurationSeconds, Is.EqualTo(folge.Files.Single().DurationSeconds)); + }); + } + [Test] public async Task Durchgeschautes_verlaesst_Weiterschauen() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 4cdf0c8..10492e2 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -101,6 +101,12 @@ export type Angefangen = { state: Stand; /** Zu welcher Serie die Folge gehört — dorthin führt die Kachel. */ seriesId: string | null; + /** Die Datei, die abgespielt würde. Ohne sie gibt es nichts zu starten. */ + fileId?: string | null; + /** Ihre Länge, für die Zeitleiste des Spielers. */ + durationSeconds?: number | null; + /** Ab wann abspielbar. In der Zukunft heisst: noch nicht. */ + publishAt?: string | null; }; export type Bibliothek = { id: string; name: string; diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts index 7e9d5e5..8d9e07e 100644 --- a/web/app/src/hero.ts +++ b/web/app/src/hero.ts @@ -1,9 +1,10 @@ -import { bildAdresse, type Film, type Serie } from './api'; +import { bildAdresse, type Angefangen, type Film, type Serie } from './api'; import { icons } from './icons'; -import { knopf } from './util'; +import { dauer, knopf } from './util'; -/** Was im Hero stehen kann — Film oder Serie, mehr braucht es nicht. */ +/** Was im Hero stehen kann — Film, Serie oder eine angefangene Folge. */ export type Heroeintrag = { + /** Wohin „Mehr" führt. Bei einer Folge zur Serie, nicht zur Folge. */ art: 'filme' | 'serien'; id: string; titel: string; @@ -14,6 +15,18 @@ export type Heroeintrag = { freigabe: string | null; bewertung: number | null; beschreibung: string | null; + + /** Ab wann es den Titel gibt. In der Zukunft heisst: noch nicht. */ + verfuegbarAb: string | null; + + /** Die Datei, die abgespielt würde. Ohne sie gibt es nichts zu starten. */ + dateiId: string | null; + /** Ihre Länge — aus der Mediathek, nicht aus dem Strom. */ + laenge: number | null; + /** Wo man stehengeblieben ist. */ + stelle: number; + /** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */ + standId: string; }; const WECHSEL_MS = 9000; @@ -40,7 +53,7 @@ const HOECHSTENS = 7; export function hero( eintraege: Heroeintrag[], oeffnen: (art: 'filme' | 'serien', id: string) => void, - abspielen: (art: 'filme' | 'serien', id: string) => void, + abspielen: (eintrag: Heroeintrag) => void, signal?: AbortSignal, ): HTMLElement | null { const mitBild = eintraege.filter(e => e.hintergrund).slice(0, HOECHSTENS); @@ -76,11 +89,25 @@ export function hero( text.className = 'hero-text'; const spielen = knopf('Abspielen', 'btn btn-primary'); - spielen.prepend(iconElement(icons.abspielen)); + const spielIcon = iconElement(icons.abspielen); + spielen.prepend(spielIcon); const mehr = knopf('Mehr', 'btn'); + + // **Statt des Knopfes, wenn es den Titel noch nicht gibt.** Ein Knopf, der + // „Abspielen" sagt und eine Absage bringt, ist schlimmer als keiner. + const bald = document.createElement('div'); + bald.className = 'verfuegbar-ab'; + const baldText = document.createElement('div'); + baldText.className = 'verfuegbar-text'; + const baldOben = document.createElement('strong'); + baldOben.textContent = 'Bald ist es soweit'; + const baldUnten = document.createElement('span'); + baldText.append(baldOben, baldUnten); + bald.append(iconElement(icons.uhr), baldText); + const knoepfe = document.createElement('div'); knoepfe.className = 'hero-knoepfe'; - knoepfe.append(spielen, mehr); + knoepfe.append(bald, spielen, mehr); inhalt.append(schriftzug, titel, meta, text, knoepfe); @@ -134,8 +161,36 @@ export function hero( ].filter(Boolean).join(' · '); text.textContent = e.beschreibung ?? ''; - spielen.onclick = () => abspielen(e.art, e.id); mehr.onclick = () => oeffnen(e.art, e.id); + + // **Drei Fälle, und jeder sagt die Wahrheit.** + // + // Geplant: kein Knopf, sondern der Termin. Mit Datei: abspielen, und + // zwar wirklich — vorher öffnete „Abspielen" die Detailseite, was + // niemand erwartet. Ohne Datei (eine ganze Serie): „Ansehen", denn es + // gibt keine eine Datei, die gemeint wäre. + const geplantAb = e.verfuegbarAb ? new Date(e.verfuegbarAb) : null; + const nochNicht = geplantAb !== null && geplantAb > new Date(); + + bald.classList.toggle('hidden', !nochNicht); + spielen.classList.toggle('hidden', nochNicht); + + if (nochNicht) { + baldUnten.textContent = `Ab ${geplantAb!.toLocaleString('de-CH', + { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + hour: '2-digit', minute: '2-digit' })} Uhr`; + return; + } + + const beschriftung = e.dateiId === null ? 'Ansehen' + : e.stelle > 0 ? `Fortsetzen ab ${dauer(e.stelle)}` + : 'Abspielen'; + spielen.textContent = beschriftung; + spielen.prepend(spielIcon); + spielIcon.classList.toggle('hidden', e.dateiId === null); + spielen.onclick = e.dateiId === null + ? () => oeffnen(e.art, e.id) + : () => abspielen(e); } function weiter(): void { zeige(jetzt + 1); } @@ -179,6 +234,7 @@ function iconElement(svg: string): HTMLElement { /** Baut Heroeinträge aus dem, was die Startseite ohnehin geholt hat. */ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroeintrag { + const datei = 'files' in eintrag ? eintrag.files[0] ?? null : null; return { art, id: eintrag.id, @@ -190,5 +246,40 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein freigabe: eintrag.contentRating, bewertung: eintrag.rating, beschreibung: eintrag.overview, + verfuegbarAb: eintrag.publishAt, + dateiId: datei?.id ?? null, + laenge: datei?.durationSeconds ?? null, + stelle: eintrag.state?.positionSeconds ?? 0, + standId: eintrag.id, + }; +} + +/** Dasselbe für eine angefangene Zeile. + * + * **Eine Folge ist kein Film.** Vorher lief auch sie durch `alsHero` — mit + * `art: 'filme'` und der Kennung der Folge. Wer den Aufmacher anklickte, + * landete auf `#/filme/` und las „Diesen Film gibt es nicht + * mehr". Die Detailseite gehört der Serie; der Fortschritt der Folge. + */ +export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag { + const folge = eintrag.kind === 'episode'; + return { + art: folge ? 'serien' : 'filme', + id: folge ? eintrag.seriesId ?? eintrag.id : eintrag.id, + titel: eintrag.title, + schriftzug: null, + hintergrund: eintrag.backdropImage ?? '', + jahr: eintrag.year, + laufzeit: null, + freigabe: null, + bewertung: null, + beschreibung: folge && eintrag.season !== null + ? `S${eintrag.season} · F${eintrag.episode}${eintrag.subTitle ? ` — ${eintrag.subTitle}` : ''}` + : null, + verfuegbarAb: eintrag.publishAt ?? null, + dateiId: eintrag.fileId ?? null, + laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds, + stelle: eintrag.state.positionSeconds, + standId: eintrag.id, }; } diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index f363c7d..f2e6a33 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -4,7 +4,8 @@ import { } from '../api'; import { leer, fehlerzeile, meldung } from '../util'; import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card'; -import { hero, alsHero, type Heroeintrag } from '../hero'; +import { hero, alsHero, angefangenAlsHero, type Heroeintrag } from '../hero'; +import { spieler } from '../spieler'; type Art = 'filme' | 'serien'; @@ -24,7 +25,12 @@ export function startseite( const wurzel = document.createElement('div'); wurzel.innerHTML = '
'; - void (async () => { + // **Benannt, damit der Spieler sie danach neu bauen kann.** Wer aus dem + // Aufmacher heraus zu Ende schaut, soll die Zeile „Fortsetzen" nicht + // unverändert wiederfinden. + void aufbauen(); + + async function aufbauen(): Promise { try { // **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst // acht Anfragen nacheinander, und die Startseite baut sich vor den @@ -56,16 +62,28 @@ export function startseite( heroTitel.push(e); }; - weiter.items.forEach(e => dazu(alsHero(e as never, 'serien' in e ? 'serien' : 'filme'))); + weiter.items.forEach(e => dazu(angefangenAlsHero(e))); bibliotheken.forEach((b, i) => { const art: Art = b.kind === 'Shows' ? 'serien' : 'filme'; bestaende[i]?.items.forEach(e => dazu(alsHero(e as never, art))); }); - vorschlag.items.forEach(e => dazu(alsHero(e as never, 'filme'))); + vorschlag.items.forEach(e => dazu(alsHero(e, 'filme'))); const aufmacher = hero(heroTitel, (art, id) => oeffnen(art, id), - (art, id) => oeffnen(art, id)); + e => { + // Ohne Datei gibt es nichts zu starten — dann führt der Knopf + // dorthin, wo man wählen kann. + if (e.dateiId === null) { oeffnen(e.art, e.id); return; } + spieler({ + dateiId: e.dateiId, + titelId: e.standId, + name: e.titel, + ab: e.stelle, + laenge: e.laenge, + danach: () => void aufbauen(), + }); + }); if (aufmacher) wurzel.appendChild(aufmacher); if (weiter.items.length) { @@ -106,7 +124,7 @@ export function startseite( wurzel.innerHTML = ''; wurzel.appendChild(fehlerzeile(meldung(fehler))); } - })(); + } return wurzel; }