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;
}