Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein Hintergrundbild, und das Karussell war nach einer Woche ein Standbild. Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter einem vierten Reiter, gespeist aus denselben Hintergründen. Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im selben Augenblick umschalten. Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. **Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten. Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt trägt der Schattenwurf der Schrift, was vorher eine Decke über dem ganzen Bild tat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8cf2118723
commit
1d70be0f99
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
|||||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
|
||||||
|
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
|
||||||
|
// dahinter.
|
||||||
|
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||||
|
|
||||||
var gesehen = Set<String>()
|
var gesehen = Set<String>()
|
||||||
return pfade
|
return pfade
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let files: [File]
|
let files: [File]
|
||||||
let state: ItemState?
|
let state: ItemState?
|
||||||
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
@@ -108,6 +110,8 @@ enum Wire {
|
|||||||
let overview: String?
|
let overview: String?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let episodeCount: Int
|
let episodeCount: Int
|
||||||
let seasons: [Int]
|
let seasons: [Int]
|
||||||
let unwatchedCount: Int
|
let unwatchedCount: Int
|
||||||
@@ -154,6 +158,8 @@ enum Wire {
|
|||||||
let year: Int?
|
let year: Int?
|
||||||
let posterImage: String?
|
let posterImage: String?
|
||||||
let backdropImage: String?
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
let state: ItemState
|
let state: ItemState
|
||||||
/// Zu welcher Serie die Folge gehört.
|
/// Zu welcher Serie die Folge gehört.
|
||||||
let seriesId: String?
|
let seriesId: String?
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ extension Wire.Movie {
|
|||||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
playCount: state?.playCount,
|
playCount: state?.playCount,
|
||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
@@ -37,6 +38,7 @@ extension Wire.Series {
|
|||||||
year: year,
|
year: year,
|
||||||
thumb: posterImage,
|
thumb: posterImage,
|
||||||
art: backdropImage,
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
@@ -107,6 +109,7 @@ extension Wire.Started {
|
|||||||
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||||
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||||
seriesThumb: folge ? posterImage : nil,
|
seriesThumb: folge ? posterImage : nil,
|
||||||
|
heroArt: heroImage,
|
||||||
index: episode,
|
index: episode,
|
||||||
parentIndex: season,
|
parentIndex: season,
|
||||||
seriesTitle: folge ? title : nil,
|
seriesTitle: folge ? title : nil,
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// die Serie, und so heißt es jetzt auch.
|
/// die Serie, und so heißt es jetzt auch.
|
||||||
public let seriesThumb: String?
|
public let seriesThumb: String?
|
||||||
public let seriesArt: String?
|
public let seriesArt: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt.
|
||||||
|
///
|
||||||
|
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||||||
|
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||||||
|
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||||||
|
/// Hintergrundbild.
|
||||||
|
public let heroArt: String?
|
||||||
|
|
||||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||||
// Staffel.
|
// Staffel.
|
||||||
@@ -126,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
parentThumb: String? = nil,
|
parentThumb: String? = nil,
|
||||||
seriesThumb: String? = nil,
|
seriesThumb: String? = nil,
|
||||||
seriesArt: String? = nil,
|
seriesArt: String? = nil,
|
||||||
|
heroArt: String? = nil,
|
||||||
index: Int? = nil,
|
index: Int? = nil,
|
||||||
parentIndex: Int? = nil,
|
parentIndex: Int? = nil,
|
||||||
parentTitle: String? = nil,
|
parentTitle: String? = nil,
|
||||||
@@ -166,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.parentThumb = parentThumb
|
self.parentThumb = parentThumb
|
||||||
self.seriesThumb = seriesThumb
|
self.seriesThumb = seriesThumb
|
||||||
self.seriesArt = seriesArt
|
self.seriesArt = seriesArt
|
||||||
|
self.heroArt = heroArt
|
||||||
self.index = index
|
self.index = index
|
||||||
self.parentIndex = parentIndex
|
self.parentIndex = parentIndex
|
||||||
self.parentTitle = parentTitle
|
self.parentTitle = parentTitle
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Flaro.Core.Media;
|
||||||
using Flaro.Core.Metadata;
|
using Flaro.Core.Metadata;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Flaro.Metadata;
|
using Flaro.Metadata;
|
||||||
@@ -43,13 +44,20 @@ public static class ArtworkEndpoints
|
|||||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||||
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
var aktuell = serie
|
var roh = serie
|
||||||
? await db.Series.Where(s => s.Id == id)
|
? await db.Series.Where(s => s.Id == id)
|
||||||
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
.Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder })
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
: await db.Movies.Where(m => m.Id == id)
|
: await db.Movies.Where(m => m.Id == id)
|
||||||
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
m.PosterPinned, m.BackdropPinned, m.LogoPinned, m.Aufmacherbilder })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var aktuell = roh is null ? null : new Bildstand(
|
||||||
|
roh.PosterImage, roh.BackdropImage, roh.LogoImage,
|
||||||
|
roh.PosterPinned, roh.BackdropPinned, roh.LogoPinned,
|
||||||
|
Aufmacherwahl.Lesen(roh.Aufmacherbilder));
|
||||||
|
|
||||||
if (aktuell is null) return Results.NotFound();
|
if (aktuell is null) return Results.NotFound();
|
||||||
|
|
||||||
@@ -104,6 +112,53 @@ public static class ArtworkEndpoints
|
|||||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **Die Mehrfachauswahl fürs Karussell.**
|
||||||
|
//
|
||||||
|
// Poster, Hintergrund und Schriftzug bleiben einzeln: sie stehen
|
||||||
|
// hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. Der
|
||||||
|
// Aufmacher dagegen ist eine Bühne — wer fünf Aufnahmen ausgesucht
|
||||||
|
// hat, soll sie auch zu sehen bekommen.
|
||||||
|
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork/aufmacher", async (
|
||||||
|
string art, Guid id, AufmacherWahlRequest anfrage,
|
||||||
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (anfrage.Urls.Count > Aufmacherwahl.Hoechstens)
|
||||||
|
return Einwand.Bitte($"Höchstens {Aufmacherwahl.Hoechstens} Bilder.");
|
||||||
|
|
||||||
|
// Erst alle in den Zwischenspeicher, dann setzen: sonst zeigte
|
||||||
|
// die Auswahl auf etwas, das es hier nicht gibt. Was sich nicht
|
||||||
|
// laden lässt, fällt heraus — ein einzelnes totes Bild soll
|
||||||
|
// nicht die ganze Auswahl verhindern.
|
||||||
|
var schluessel = new List<string>();
|
||||||
|
foreach (var url in anfrage.Urls.Take(Aufmacherwahl.Hoechstens))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) continue;
|
||||||
|
|
||||||
|
// **Was schon hier liegt, bleibt liegen.** Der Client
|
||||||
|
// schickt eine Mischung: die bereits gewählten Bilder als
|
||||||
|
// Zwischenspeicherschlüssel und die neuen als Adresse. Einen
|
||||||
|
// Schlüssel noch einmal herunterladen zu wollen hiesse, eine
|
||||||
|
// Adresse daraus zu raten, die es nicht gibt.
|
||||||
|
if (!url.Contains("://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
schluessel.Add(url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await ablage.StoreAsync(new RemoteImage(url), ct) is { } k) schluessel.Add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeile = Aufmacherwahl.Schreiben(schluessel);
|
||||||
|
|
||||||
|
var betroffen = art == "series"
|
||||||
|
? await db.Series.Where(s => s.Id == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct)
|
||||||
|
: await db.Movies.Where(m => m.Id == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct);
|
||||||
|
|
||||||
|
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||||
string art, Guid id, string kind, HttpRequest anfrage,
|
string art, Guid id, string kind, HttpRequest anfrage,
|
||||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -224,13 +224,23 @@ public sealed record SprachStand(string? Language, int Translated, int Total);
|
|||||||
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||||
public sealed record Bildstand(
|
public sealed record Bildstand(
|
||||||
string? Poster, string? Backdrop, string? Logo,
|
string? Poster, string? Backdrop, string? Logo,
|
||||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
bool PosterPinned, bool BackdropPinned, bool LogoPinned,
|
||||||
|
/// <summary>Die für den Aufmacher gewählten Bilder, höchstens fünf.</summary>
|
||||||
|
IReadOnlyList<string>? Heroes = null);
|
||||||
|
|
||||||
public sealed record Bildauswahl(
|
public sealed record Bildauswahl(
|
||||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||||
|
|
||||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||||
|
|
||||||
|
/// <summary>Die Mehrfachauswahl fürs Aufmacher-Karussell.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||||
|
/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AufmacherWahlRequest(IReadOnlyList<string> Urls);
|
||||||
|
|
||||||
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -455,7 +465,17 @@ public sealed record MovieResponse(
|
|||||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||||
/// erfahren, wohin dieser eine Film gehört.
|
/// erfahren, wohin dieser eine Film gehört.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Guid? LibraryId = null);
|
Guid? LibraryId = null,
|
||||||
|
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||||
|
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||||
|
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||||
|
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||||
|
/// gilt wie bisher der Hintergrund.
|
||||||
|
/// </remarks>
|
||||||
|
string? HeroImage = null);
|
||||||
|
|
||||||
public sealed record CreditResponse(
|
public sealed record CreditResponse(
|
||||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||||
@@ -536,7 +556,17 @@ public sealed record SeriesResponse(
|
|||||||
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
|
||||||
/// erfahren, wohin dieser eine Film gehört.
|
/// erfahren, wohin dieser eine Film gehört.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Guid? LibraryId = null);
|
Guid? LibraryId = null,
|
||||||
|
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
|
||||||
|
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
|
||||||
|
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
|
||||||
|
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
|
||||||
|
/// gilt wie bisher der Hintergrund.
|
||||||
|
/// </remarks>
|
||||||
|
string? HeroImage = null);
|
||||||
|
|
||||||
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
|
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
|
||||||
///
|
///
|
||||||
@@ -883,7 +913,10 @@ public sealed record ContinueEntry(
|
|||||||
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
|
||||||
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
string? SeriesBackdropImage = null);
|
string? SeriesBackdropImage = null,
|
||||||
|
|
||||||
|
/// <summary>Das Bild, das jetzt im Aufmacher gilt — siehe <c>Aufmacherwahl</c>.</summary>
|
||||||
|
string? HeroImage = null);
|
||||||
|
|
||||||
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ public static class LibraryContentEndpoints
|
|||||||
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
// Korrektur stand „WALL·E" weiter unter E, bis man die
|
||||||
// Seite neu lud.
|
// Seite neu lud.
|
||||||
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
|
||||||
|
m.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -559,7 +560,9 @@ public static class LibraryContentEndpoints
|
|||||||
PublishAt: m.PublishAt,
|
PublishAt: m.PublishAt,
|
||||||
TitlePinned: m.TitlePinned,
|
TitlePinned: m.TitlePinned,
|
||||||
SortTitle: m.SortTitleValue,
|
SortTitle: m.SortTitleValue,
|
||||||
SortTitlePinned: m.SortTitlePinned)).ToList();
|
SortTitlePinned: m.SortTitlePinned,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
@@ -615,6 +618,7 @@ public static class LibraryContentEndpoints
|
|||||||
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
|
||||||
.Min(f => f.PublishAt),
|
.Min(f => f.PublishAt),
|
||||||
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
|
||||||
|
s.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -643,7 +647,8 @@ public static class LibraryContentEndpoints
|
|||||||
State: stände.GetValueOrDefault(s.Id),
|
State: stände.GetValueOrDefault(s.Id),
|
||||||
TitlePinned: s.TitlePinned,
|
TitlePinned: s.TitlePinned,
|
||||||
SortTitle: s.SortTitleValue,
|
SortTitle: s.SortTitleValue,
|
||||||
SortTitlePinned: s.SortTitlePinned);
|
SortTitlePinned: s.SortTitlePinned,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||||
@@ -750,7 +755,8 @@ public static class LibraryContentEndpoints
|
|||||||
SortTitlePinned: film.SortTitlePinned,
|
SortTitlePinned: film.SortTitlePinned,
|
||||||
PublishAt: film.PublishAt,
|
PublishAt: film.PublishAt,
|
||||||
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
|
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
|
||||||
LibraryId: film.LibraryId));
|
LibraryId: film.LibraryId,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||||
@@ -787,7 +793,8 @@ public static class LibraryContentEndpoints
|
|||||||
SortTitlePinned: serie.SortTitlePinned,
|
SortTitlePinned: serie.SortTitlePinned,
|
||||||
PublishAt: folgen.Min(f => f.PublishAt),
|
PublishAt: folgen.Min(f => f.PublishAt),
|
||||||
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id),
|
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id),
|
||||||
LibraryId: serie.LibraryId));
|
LibraryId: serie.LibraryId,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(serie.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// **Eine Folge für sich.**
|
// **Eine Folge für sich.**
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Flaro.Api.Auth;
|
using Flaro.Api.Auth;
|
||||||
|
using Flaro.Core.Library;
|
||||||
|
using Flaro.Core.Media;
|
||||||
using Flaro.Core.Playback;
|
using Flaro.Core.Playback;
|
||||||
using Flaro.Data;
|
using Flaro.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -96,7 +98,18 @@ public static class PlaybackEndpoints
|
|||||||
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
|
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
|
||||||
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
|
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
|
||||||
|
|
||||||
var ergebnis = ProgressRule.Apply(gemeldet, laenge, regeln);
|
// **Der Abspann, wenn er erkannt wurde.** Er zieht die Schwelle
|
||||||
|
// fürs Häkchen nach vorn — siehe `ProgressRule.Apply`.
|
||||||
|
var abspannAb = zustand.EpisodeId is { } folge
|
||||||
|
? await db.Marken.AsNoTracking()
|
||||||
|
.Where(m => m.EpisodeId == folge && m.Art == Markenart.Abspann)
|
||||||
|
.Select(m => (double?)m.VonSekunden)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var ergebnis = ProgressRule.Apply(
|
||||||
|
gemeldet, laenge, regeln,
|
||||||
|
abspannAb is { } sek ? TimeSpan.FromSeconds(sek) : null);
|
||||||
|
|
||||||
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
||||||
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
||||||
@@ -610,7 +623,7 @@ public static class PlaybackEndpoints
|
|||||||
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
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))
|
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, m.PublishAt })
|
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt, m.Aufmacherbilder })
|
||||||
.ToDictionaryAsync(m => m.Id);
|
.ToDictionaryAsync(m => m.Id);
|
||||||
|
|
||||||
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
|
||||||
@@ -618,7 +631,7 @@ public static class PlaybackEndpoints
|
|||||||
{
|
{
|
||||||
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
|
||||||
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
|
||||||
SerienBild = s.BackdropImage,
|
SerienBild = s.BackdropImage, SerienAufmacher = s.Aufmacherbilder,
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(f => f.Id);
|
.ToDictionaryAsync(f => f.Id);
|
||||||
|
|
||||||
@@ -633,7 +646,8 @@ public static class PlaybackEndpoints
|
|||||||
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
|
||||||
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
FileId: dateien.GetValueOrDefault(m.Id).Id,
|
||||||
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
|
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
|
||||||
PublishAt: m.PublishAt));
|
PublishAt: m.PublishAt,
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)));
|
||||||
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
|
||||||
liste.Add(new ContinueEntry(
|
liste.Add(new ContinueEntry(
|
||||||
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
|
||||||
@@ -642,7 +656,12 @@ public static class PlaybackEndpoints
|
|||||||
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
FileId: dateien.GetValueOrDefault(f.Id).Id,
|
||||||
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
|
||||||
PublishAt: f.PublishAt,
|
PublishAt: f.PublishAt,
|
||||||
SeriesBackdropImage: f.SerienBild));
|
SeriesBackdropImage: f.SerienBild,
|
||||||
|
// **Das der Serie, nicht das der Folge.** Der Aufmacher
|
||||||
|
// zeigt, worum es geht; eine einzelne Folge hat dafür
|
||||||
|
// selten ein eigenes Bild und nie fünf.
|
||||||
|
HeroImage: Aufmacherwahl.Jetziges(
|
||||||
|
f.SerienAufmacher, f.SerienId, DateTimeOffset.UtcNow)));
|
||||||
}
|
}
|
||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? LogoImage { get; set; }
|
public string? LogoImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
|
||||||
|
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
|
||||||
|
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
|
||||||
|
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
|
||||||
|
/// eine Liste, die in eine Zeile passt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
|
||||||
|
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
|
||||||
|
/// Aufnahmen für das Karussell, und nur die wechseln.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? Aufmacherbilder { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +515,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? LogoImage { get; set; }
|
public string? LogoImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
|
||||||
|
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
|
||||||
|
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
|
||||||
|
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
|
||||||
|
/// eine Liste, die in eine Zeile passt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
|
||||||
|
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
|
||||||
|
/// Aufnahmen für das Karussell, und nur die wechseln.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? Aufmacherbilder { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
namespace Flaro.Core.Media;
|
||||||
|
|
||||||
|
/// <summary>Welches der gewählten Aufmacherbilder gerade an der Reihe ist.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Warum es wechselt.</b> Ein Karussell, das jedes Mal dieselben fünf
|
||||||
|
/// Bilder zeigt, ist nach einer Woche ein Standbild. Wer zu einem Titel
|
||||||
|
/// mehrere Aufnahmen ausgesucht hat, soll sie auch zu sehen bekommen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Aus der Uhr gerechnet, nicht gewürfelt und nicht gespeichert.</b> Ein
|
||||||
|
/// Zufall je Abruf hiesse, dass das Bild beim Blättern springt und beim
|
||||||
|
/// Zurückkommen ein drittes ist. Eine gespeicherte Wahl hiesse, einen
|
||||||
|
/// Schreibvorgang je Titel und Fenster. Aus Zeit und Kennung gerechnet ist
|
||||||
|
/// beides nicht nötig: innerhalb eines Fensters kommt überall dieselbe
|
||||||
|
/// Antwort heraus — im Web, in der App, nach einem Neustart.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Die Kennung geht mit in die Rechnung</b>, damit nicht alle Titel im
|
||||||
|
/// selben Augenblick umschalten. Sonst sähe eine Startseite alle fünf
|
||||||
|
/// Stunden vollständig anders aus statt nach und nach.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Aufmacherwahl
|
||||||
|
{
|
||||||
|
/// <summary>Wie lange ein Bild stehen bleibt.</summary>
|
||||||
|
public static readonly TimeSpan Fenster = TimeSpan.FromHours(5);
|
||||||
|
|
||||||
|
/// <summary>Wie viele Bilder je Titel höchstens gewählt werden dürfen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Fünf. Mehr hiesse, dass ein einzelnes kaum noch vorkommt: bei zehn
|
||||||
|
/// Bildern und fünf Stunden Fenster sähe man dasselbe zweimal im Monat.
|
||||||
|
/// </remarks>
|
||||||
|
public const int Hoechstens = 5;
|
||||||
|
|
||||||
|
/// <summary>Zerlegt die gespeicherte Zeile in die einzelnen Schlüssel.</summary>
|
||||||
|
public static IReadOnlyList<string> Lesen(string? gespeichert) =>
|
||||||
|
string.IsNullOrWhiteSpace(gespeichert)
|
||||||
|
? []
|
||||||
|
: gespeichert.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
/// <summary>Setzt die Zeile zusammen — gekürzt und ohne Doppelte.</summary>
|
||||||
|
public static string? Schreiben(IEnumerable<string> schluessel)
|
||||||
|
{
|
||||||
|
var sauber = schluessel
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
// Ein Komma im Schlüssel zerlegte die Zeile beim Lesen in zwei.
|
||||||
|
.Where(s => !s.Contains(','))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.Take(Hoechstens)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return sauber.Count == 0 ? null : string.Join(',', sauber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Das Bild, das jetzt gilt — oder <c>null</c>, wenn keines gewählt ist.</summary>
|
||||||
|
public static string? Jetziges(string? gespeichert, Guid titel, DateTimeOffset jetzt)
|
||||||
|
{
|
||||||
|
var bilder = Lesen(gespeichert);
|
||||||
|
if (bilder.Count == 0) return null;
|
||||||
|
if (bilder.Count == 1) return bilder[0];
|
||||||
|
|
||||||
|
var fenster = jetzt.ToUnixTimeSeconds() / (long)Fenster.TotalSeconds;
|
||||||
|
// Die ersten vier Bytes der Kennung als Versatz. **Nicht
|
||||||
|
// `GetHashCode`**: der ist je Programmlauf anders gestreut, und das
|
||||||
|
// Bild spränge bei jedem Neustart des Servers.
|
||||||
|
var versatz = BitConverter.ToUInt32(titel.ToByteArray(), 0);
|
||||||
|
|
||||||
|
var stelle = (long)((ulong)fenster + versatz) % bilder.Count;
|
||||||
|
return bilder[(int)stelle];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,21 @@ public static class ProgressRule
|
|||||||
/// es zu merken.
|
/// es zu merken.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="regeln">Ab welchem Anteil ein Titel als gesehen gilt.</param>
|
/// <param name="regeln">Ab welchem Anteil ein Titel als gesehen gilt.</param>
|
||||||
public static Outcome Apply(TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln)
|
/// <param name="abspannAb">
|
||||||
|
/// Wann der Abspann anfängt, falls er erkannt wurde.
|
||||||
|
///
|
||||||
|
/// <b>Dann zählt er, nicht der Anteil.</b> Manche Serie hängt sechs
|
||||||
|
/// Minuten Abspann an eine Folge von fünfundvierzig; neunzig Prozent
|
||||||
|
/// liegen dann mitten im Abspann, und die Folge gilt erst als gesehen,
|
||||||
|
/// wenn man ihn sich ganz ansieht. Die Schwelle dafür herunterzudrehen
|
||||||
|
/// hiesse, jeden anderen Titel zu früh abzuhaken. Der Abspann ist die
|
||||||
|
/// genauere Antwort auf dieselbe Frage: hier ist die Handlung vorbei.
|
||||||
|
///
|
||||||
|
/// Der Anteil bleibt daneben stehen und gilt weiter — wer über ihn
|
||||||
|
/// hinauskommt, ohne dass ein Abspann erkannt wäre, ist genauso fertig.
|
||||||
|
/// </param>
|
||||||
|
public static Outcome Apply(
|
||||||
|
TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln, TimeSpan? abspannAb = null)
|
||||||
{
|
{
|
||||||
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
|
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
|
||||||
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
|
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
|
||||||
@@ -62,7 +76,14 @@ public static class ProgressRule
|
|||||||
|
|
||||||
if (stelle > laenge) stelle = laenge;
|
if (stelle > laenge) stelle = laenge;
|
||||||
|
|
||||||
return new Outcome(stelle, Watched: stelle >= laenge * regeln.GesehenAnteil);
|
var schwelle = laenge * regeln.GesehenAnteil;
|
||||||
|
|
||||||
|
// Der Abspann zieht die Schwelle nach vorn, nie nach hinten: eine
|
||||||
|
// falsch erkannte Marke bei Minute vierzig darf nicht dazu führen,
|
||||||
|
// dass eine Folge erst nach der Marke als gesehen gilt.
|
||||||
|
if (abspannAb is { } ab && ab > TimeSpan.Zero && ab < schwelle) schwelle = ab;
|
||||||
|
|
||||||
|
return new Outcome(stelle, Watched: stelle >= schwelle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
||||||
|
|||||||
+1299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Flaro.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Aufmacherbilder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Aufmacherbilder",
|
||||||
|
table: "Series",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Aufmacherbilder",
|
||||||
|
table: "Movies",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Aufmacherbilder",
|
||||||
|
table: "Series");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Aufmacherbilder",
|
||||||
|
table: "Movies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -615,6 +615,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<long>("AddedAt")
|
b.Property<long>("AddedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Aufmacherbilder")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("BackdropImage")
|
b.Property<string>("BackdropImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -753,6 +756,9 @@ namespace Flaro.Data.Migrations
|
|||||||
b.Property<long>("AddedAt")
|
b.Property<long>("AddedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Aufmacherbilder")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("BackdropImage")
|
b.Property<string>("BackdropImage")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Flaro.Core.Media;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>Welches Aufmacherbild gerade gilt.</summary>
|
||||||
|
public class AufmacherwahlTests
|
||||||
|
{
|
||||||
|
private static readonly Guid Titel = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||||
|
private static readonly DateTimeOffset Montag = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Auswahl_gibt_es_keines()
|
||||||
|
{
|
||||||
|
Assert.That(Aufmacherwahl.Jetziges(null, Titel, Montag), Is.Null);
|
||||||
|
Assert.That(Aufmacherwahl.Jetziges("", Titel, Montag), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ein_einziges_Bild_bleibt_immer_dasselbe()
|
||||||
|
{
|
||||||
|
var spaeter = Montag.AddDays(30);
|
||||||
|
Assert.That(Aufmacherwahl.Jetziges("a", Titel, Montag), Is.EqualTo("a"));
|
||||||
|
Assert.That(Aufmacherwahl.Jetziges("a", Titel, spaeter), Is.EqualTo("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Innerhalb eines Fensters kommt überall dasselbe heraus.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der eigentliche Grund für die Rechnung.</b> Ein Zufall je Abruf
|
||||||
|
/// hiesse: beim Blättern springt das Bild, und die App zeigt ein
|
||||||
|
/// anderes als das Web.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Innerhalb_des_Fensters_wechselt_nichts()
|
||||||
|
{
|
||||||
|
var erstes = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag);
|
||||||
|
var kurzDarauf = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag.AddMinutes(90));
|
||||||
|
|
||||||
|
Assert.That(kurzDarauf, Is.EqualTo(erstes));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Nach_dem_Fenster_kommt_ein_anderes()
|
||||||
|
{
|
||||||
|
var erstes = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag);
|
||||||
|
var danach = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag + Aufmacherwahl.Fenster);
|
||||||
|
|
||||||
|
Assert.That(danach, Is.Not.EqualTo(erstes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Über einen Tag kommt jedes Bild vor.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Alle_Bilder_kommen_an_die_Reihe()
|
||||||
|
{
|
||||||
|
var gesehen = new HashSet<string?>();
|
||||||
|
for (var i = 0; i < 24; i++)
|
||||||
|
gesehen.Add(Aufmacherwahl.Jetziges("a,b,c", Titel, Montag.AddHours(i * 5)));
|
||||||
|
|
||||||
|
Assert.That(gesehen, Is.EquivalentTo(new[] { "a", "b", "c" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zwei Titel schalten nicht im selben Augenblick um.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Nicht_alle_Titel_zugleich()
|
||||||
|
{
|
||||||
|
var anderer = Guid.Parse("99999999-8888-7777-6666-555555555555");
|
||||||
|
var eins = Aufmacherwahl.Jetziges("a,b,c", Titel, Montag);
|
||||||
|
var zwei = Aufmacherwahl.Jetziges("a,b,c", anderer, Montag);
|
||||||
|
|
||||||
|
Assert.That(zwei, Is.Not.EqualTo(eins));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Mehr_als_fuenf_werden_abgeschnitten()
|
||||||
|
{
|
||||||
|
var zeile = Aufmacherwahl.Schreiben(["a", "b", "c", "d", "e", "f", "g"]);
|
||||||
|
Assert.That(Aufmacherwahl.Lesen(zeile), Has.Count.EqualTo(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Doppelte_und_Leere_fallen_heraus()
|
||||||
|
{
|
||||||
|
var zeile = Aufmacherwahl.Schreiben(["a", " a ", "", " ", "b"]);
|
||||||
|
Assert.That(Aufmacherwahl.Lesen(zeile), Is.EqualTo(new[] { "a", "b" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Komma im Schlüssel zerlegte die Zeile beim Lesen.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Ein_Schluessel_mit_Komma_wird_verworfen()
|
||||||
|
{
|
||||||
|
Assert.That(Aufmacherwahl.Lesen(Aufmacherwahl.Schreiben(["a,b", "c"])),
|
||||||
|
Is.EqualTo(new[] { "c" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Bilder_bleibt_die_Zeile_leer()
|
||||||
|
{
|
||||||
|
Assert.That(Aufmacherwahl.Schreiben([]), Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,4 +268,60 @@ public sealed class ProgressRuleTests
|
|||||||
|
|
||||||
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
|
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
|
||||||
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
|
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
|
||||||
|
|
||||||
|
/// <summary>Ein erkannter Abspann zieht das Häkchen nach vorn.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der Fall aus dem Gebrauch.</b> Eine Serie hängt sechs Minuten
|
||||||
|
/// Abspann an eine Folge von fünfundvierzig. Neunzig Prozent liegen dann
|
||||||
|
/// mitten im Abspann, und die Folge gilt erst als gesehen, wenn man ihn
|
||||||
|
/// sich ganz ansieht. Die Schwelle dafür herunterzudrehen hiesse, jeden
|
||||||
|
/// anderen Titel zu früh abzuhaken.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Der_Abspann_zaehlt_als_Schluss()
|
||||||
|
{
|
||||||
|
var laenge = TimeSpan.FromMinutes(45);
|
||||||
|
var abspann = TimeSpan.FromMinutes(39);
|
||||||
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
||||||
|
|
||||||
|
var amAbspann = ProgressRule.Apply(abspann, laenge, regeln, abspann);
|
||||||
|
var davor = ProgressRule.Apply(abspann - TimeSpan.FromSeconds(30), laenge, regeln, abspann);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(amAbspann.Watched, Is.True, "ab dem Abspann ist die Handlung vorbei");
|
||||||
|
Assert.That(davor.Watched, Is.False, "eine halbe Minute davor noch nicht");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Marke hinter der Schwelle verschiebt nichts nach hinten.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Sonst machte eine falsch erkannte Marke aus einem Titel, der bei
|
||||||
|
/// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig
|
||||||
|
/// ist — und niemand sähe, warum.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Eine_spaete_Marke_schiebt_nichts_nach_hinten()
|
||||||
|
{
|
||||||
|
var laenge = TimeSpan.FromMinutes(45);
|
||||||
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
||||||
|
|
||||||
|
var ergebnis = ProgressRule.Apply(
|
||||||
|
TimeSpan.FromMinutes(41), laenge, regeln, abspannAb: TimeSpan.FromMinutes(44));
|
||||||
|
|
||||||
|
Assert.That(ergebnis.Watched, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Marke bleibt alles, wie es war.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Abspann_gilt_der_Anteil()
|
||||||
|
{
|
||||||
|
var laenge = TimeSpan.FromMinutes(45);
|
||||||
|
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
|
||||||
|
|
||||||
|
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(39), laenge, regeln).Watched, Is.False);
|
||||||
|
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(41), laenge, regeln).Watched, Is.True);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export type Film = {
|
|||||||
*
|
*
|
||||||
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
||||||
libraryId: string | null;
|
libraryId: string | null;
|
||||||
|
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||||
|
heroImage?: string | null;
|
||||||
};
|
};
|
||||||
export type Serie = {
|
export type Serie = {
|
||||||
id: string; title: string; year: number | null; overview: string | null;
|
id: string; title: string; year: number | null; overview: string | null;
|
||||||
@@ -112,6 +114,8 @@ export type Serie = {
|
|||||||
*
|
*
|
||||||
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
* Das Auffrischen hängt an der Mediathek, nicht am Titel. */
|
||||||
libraryId: string | null;
|
libraryId: string | null;
|
||||||
|
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||||
|
heroImage?: string | null;
|
||||||
};
|
};
|
||||||
/** Ein überspringbarer Abschnitt einer Folge. */
|
/** Ein überspringbarer Abschnitt einer Folge. */
|
||||||
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
|
export type Marke = { kind: 'intro' | 'credits'; startSeconds: number; endSeconds: number };
|
||||||
@@ -146,6 +150,8 @@ export type Angefangen = {
|
|||||||
publishAt?: string | null;
|
publishAt?: string | null;
|
||||||
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
|
/** Das breite Bild der Serie — für den Aufmacher, nicht für die Kachel. */
|
||||||
seriesBackdropImage?: string | null;
|
seriesBackdropImage?: string | null;
|
||||||
|
/** Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt. */
|
||||||
|
heroImage?: string | null;
|
||||||
};
|
};
|
||||||
export type Bibliothek = {
|
export type Bibliothek = {
|
||||||
id: string; name: string;
|
id: string; name: string;
|
||||||
@@ -897,6 +903,8 @@ export type Bildauswahl = {
|
|||||||
current: {
|
current: {
|
||||||
poster: string | null; backdrop: string | null; logo: string | null;
|
poster: string | null; backdrop: string | null; logo: string | null;
|
||||||
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
|
posterPinned: boolean; backdropPinned: boolean; logoPinned: boolean;
|
||||||
|
/** Die fürs Karussell gewählten Bilder, höchstens fünf. */
|
||||||
|
heroes?: string[] | null;
|
||||||
};
|
};
|
||||||
candidates: Bildkandidat[];
|
candidates: Bildkandidat[];
|
||||||
};
|
};
|
||||||
@@ -904,6 +912,16 @@ export type Bildauswahl = {
|
|||||||
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
|
export const bildauswahl = (art: 'movies' | 'series', id: string) =>
|
||||||
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
|
anfrage<Bildauswahl>(`/${art}/${id}/artwork`);
|
||||||
|
|
||||||
|
/** Setzt die Auswahl fürs Aufmacher-Karussell — alle auf einmal.
|
||||||
|
*
|
||||||
|
* Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
|
||||||
|
* sondern die ganze Auswahl ersetzt. Vorhandene gehen als
|
||||||
|
* Zwischenspeicherschlüssel mit, neue als Adresse — der Server
|
||||||
|
* unterscheidet das selbst. */
|
||||||
|
export const aufmacherSetzen = (art: 'movies' | 'series', id: string, urls: string[]) =>
|
||||||
|
anfrage<void>(`/${art}/${id}/artwork/aufmacher`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ urls }) });
|
||||||
|
|
||||||
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
|
export const bildSetzen = (art: 'movies' | 'series', id: string, kind: string, url: string) =>
|
||||||
anfrage<void>(`/${art}/${id}/artwork`,
|
anfrage<void>(`/${art}/${id}/artwork`,
|
||||||
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
|
{ method: 'PUT', body: JSON.stringify({ kind, url }) });
|
||||||
|
|||||||
+124
-2
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, type Bildkandidat,
|
bildAdresse, bildauswahl, bildSetzen, bildHochladen, bildFreigeben, aufmacherSetzen,
|
||||||
|
type Bildkandidat,
|
||||||
} from './api';
|
} from './api';
|
||||||
import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util';
|
import { knopf, fehlerzeile, meldung, hinweisZeigen } from './util';
|
||||||
import { icons } from './icons';
|
import { icons } from './icons';
|
||||||
@@ -8,6 +9,13 @@ import { zuschneiden } from './bildzuschnitt';
|
|||||||
|
|
||||||
type Bildart = 'poster' | 'backdrop' | 'logo';
|
type Bildart = 'poster' | 'backdrop' | 'logo';
|
||||||
|
|
||||||
|
/** Wie viele Bilder das Karussell höchstens je Titel durchwechselt.
|
||||||
|
*
|
||||||
|
* Muss zu `Aufmacherwahl.Hoechstens` auf dem Server passen. Mehr hiesse,
|
||||||
|
* dass ein einzelnes kaum noch vorkommt: bei zehn Bildern und fünf Stunden
|
||||||
|
* Fenster sähe man dasselbe zweimal im Monat. */
|
||||||
|
const AUFMACHER_HOECHSTENS = 5;
|
||||||
|
|
||||||
/** Was jede Bildart ist — und in welcher Form sie hochgeladen wird.
|
/** Was jede Bildart ist — und in welcher Form sie hochgeladen wird.
|
||||||
*
|
*
|
||||||
* **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein
|
* **`zuschnitt: null` beim Schriftzug, und das ist kein Versehen.** Ein
|
||||||
@@ -93,7 +101,7 @@ export function bilderflaeche(
|
|||||||
koerper.innerHTML = '<div class="spinner"></div>';
|
koerper.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
|
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
|
||||||
let offen: Bildart = 'poster';
|
let offen: Bildart | 'aufmacher' = 'poster';
|
||||||
|
|
||||||
void laden();
|
void laden();
|
||||||
|
|
||||||
@@ -258,6 +266,120 @@ export function bilderflaeche(
|
|||||||
buehne.appendChild(abschnitt);
|
buehne.appendChild(abschnitt);
|
||||||
bloecke.set(bildart, abschnitt);
|
bloecke.set(bildart, abschnitt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
aufmacherreiter(auswahl, reiter, buehne);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der vierte Reiter: mehrere Bilder fürs Karussell.
|
||||||
|
*
|
||||||
|
* **Warum er nicht in der Schleife darüber steht.** Die drei anderen
|
||||||
|
* setzen genau ein Bild, lassen sich festsetzen, wieder freigeben und von
|
||||||
|
* der Platte hochladen. Hier geht es um eine Auswahl aus denselben
|
||||||
|
* Hintergründen — kein Festsetzen, kein Hochladen, dafür mehrere. In
|
||||||
|
* dieselbe Schleife gezwängt wäre jede zweite Zeile ein `if`.
|
||||||
|
*/
|
||||||
|
function aufmacherreiter(
|
||||||
|
auswahl: Awaited<ReturnType<typeof bildauswahl>>,
|
||||||
|
reiter: HTMLElement, buehne: HTMLElement,
|
||||||
|
): void {
|
||||||
|
const gewaehlt = [...(auswahl.current.heroes ?? [])];
|
||||||
|
const passende = auswahl.candidates.filter(k => k.kind === 'backdrop');
|
||||||
|
|
||||||
|
const knopfReiter = document.createElement('button');
|
||||||
|
knopfReiter.type = 'button';
|
||||||
|
knopfReiter.className = 'bildreiter-knopf' + (offen === 'aufmacher' ? ' an' : '');
|
||||||
|
knopfReiter.setAttribute('role', 'tab');
|
||||||
|
knopfReiter.setAttribute('aria-selected', String(offen === 'aufmacher'));
|
||||||
|
knopfReiter.innerHTML = '<span>Aufmacher</span>'
|
||||||
|
+ `<span class="winzig">${gewaehlt.length}/${AUFMACHER_HOECHSTENS}</span>`;
|
||||||
|
knopfReiter.onclick = () => { offen = 'aufmacher'; void laden(); };
|
||||||
|
reiter.appendChild(knopfReiter);
|
||||||
|
|
||||||
|
if (offen !== 'aufmacher') return;
|
||||||
|
|
||||||
|
const abschnitt = document.createElement('section');
|
||||||
|
abschnitt.className = 'bildblock';
|
||||||
|
|
||||||
|
const kopfzeile = document.createElement('div');
|
||||||
|
kopfzeile.className = 'knopfreihe';
|
||||||
|
const u = document.createElement('h3');
|
||||||
|
u.textContent = 'Aufmacher';
|
||||||
|
const hinweis = document.createElement('p');
|
||||||
|
hinweis.className = 'winzig';
|
||||||
|
hinweis.textContent = `Bis zu ${AUFMACHER_HOECHSTENS} Bilder. Das Karussell wechselt alle `
|
||||||
|
+ 'paar Stunden zwischen ihnen — Plakat und Hintergrund bleiben, wie sie sind.';
|
||||||
|
kopfzeile.appendChild(u);
|
||||||
|
abschnitt.append(kopfzeile, hinweis);
|
||||||
|
|
||||||
|
if (passende.length === 0 && gewaehlt.length === 0) {
|
||||||
|
const leer = document.createElement('p');
|
||||||
|
leer.className = 'winzig';
|
||||||
|
leer.textContent = 'Nichts zur Auswahl.';
|
||||||
|
abschnitt.appendChild(leer);
|
||||||
|
buehne.appendChild(abschnitt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitter = document.createElement('div');
|
||||||
|
gitter.className = 'bildgitter';
|
||||||
|
gitter.style.setProperty('--verhaeltnis', '16 / 9');
|
||||||
|
|
||||||
|
// Die gewählten zuerst, in ihrer Reihenfolge — so sieht man auf einen
|
||||||
|
// Blick, was das Karussell zeigt.
|
||||||
|
gewaehlt.forEach(schluessel => gitter.appendChild(
|
||||||
|
aufmacherkachel(bildAdresse(schluessel)!, true, () => speichern(
|
||||||
|
gewaehlt.filter(x => x !== schluessel)))));
|
||||||
|
|
||||||
|
passende.forEach(k => gitter.appendChild(
|
||||||
|
aufmacherkachel(k.thumbUrl, false, () => {
|
||||||
|
if (gewaehlt.length >= AUFMACHER_HOECHSTENS) {
|
||||||
|
hinweisZeigen(`Mehr als ${AUFMACHER_HOECHSTENS} gehen nicht. Nimm erst eines weg.`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return speichern([...gewaehlt, k.url]);
|
||||||
|
})));
|
||||||
|
|
||||||
|
abschnitt.appendChild(gitter);
|
||||||
|
buehne.appendChild(abschnitt);
|
||||||
|
|
||||||
|
async function speichern(neu: string[]): Promise<void> {
|
||||||
|
await aufmacherSetzen(art, id, neu);
|
||||||
|
beiAenderung?.();
|
||||||
|
await laden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Kachel im Aufmacher-Reiter — angekreuzt oder nicht. */
|
||||||
|
function aufmacherkachel(
|
||||||
|
adresse: string, drin: boolean, tun: () => Promise<void>,
|
||||||
|
): HTMLElement {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'bildwahl' + (drin ? ' an' : '');
|
||||||
|
b.title = drin ? 'Aus dem Karussell nehmen' : 'Ins Karussell aufnehmen';
|
||||||
|
|
||||||
|
const bild = document.createElement('img');
|
||||||
|
bild.src = adresse;
|
||||||
|
bild.alt = '';
|
||||||
|
bild.loading = 'lazy';
|
||||||
|
b.appendChild(bild);
|
||||||
|
|
||||||
|
if (drin) {
|
||||||
|
const marke = document.createElement('span');
|
||||||
|
marke.className = 'bildwahl-marke';
|
||||||
|
marke.innerHTML = icons.haken;
|
||||||
|
b.appendChild(marke);
|
||||||
|
}
|
||||||
|
|
||||||
|
b.onclick = async () => {
|
||||||
|
b.disabled = true;
|
||||||
|
try { await tun(); }
|
||||||
|
catch (fehler) {
|
||||||
|
b.after(fehlerzeile(meldung(fehler)));
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function kachelFuer(
|
function kachelFuer(
|
||||||
|
|||||||
+6
-2
@@ -450,7 +450,11 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein
|
|||||||
id: eintrag.id,
|
id: eintrag.id,
|
||||||
titel: eintrag.title,
|
titel: eintrag.title,
|
||||||
schriftzug: eintrag.logoImage,
|
schriftzug: eintrag.logoImage,
|
||||||
hintergrund: eintrag.backdropImage ?? '',
|
// **Das Aufmacherbild sticht den Hintergrund.** Wer mehrere Aufnahmen
|
||||||
|
// ausgesucht hat, bekommt hier die, die gerade gilt — der Server
|
||||||
|
// entscheidet das, damit Web und App dasselbe zeigen. Ohne Auswahl
|
||||||
|
// bleibt es beim Hintergrund.
|
||||||
|
hintergrund: eintrag.heroImage ?? eintrag.backdropImage ?? '',
|
||||||
jahr: eintrag.year,
|
jahr: eintrag.year,
|
||||||
laufzeit: eintrag.runtimeMinutes,
|
laufzeit: eintrag.runtimeMinutes,
|
||||||
freigabe: eintrag.contentRating,
|
freigabe: eintrag.contentRating,
|
||||||
@@ -483,7 +487,7 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
|
|||||||
// einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für
|
// einer Folge ihr eigenes Standbild — richtig für die Kachel, falsch für
|
||||||
// eine Fläche über den halben Bildschirm: wer der Serie ein Bild
|
// eine Fläche über den halben Bildschirm: wer der Serie ein Bild
|
||||||
// ausgesucht hat, fand hier ein anderes vor.
|
// ausgesucht hat, fand hier ein anderes vor.
|
||||||
hintergrund: eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
|
hintergrund: eintrag.heroImage ?? eintrag.seriesBackdropImage ?? eintrag.backdropImage ?? '',
|
||||||
jahr: eintrag.year,
|
jahr: eintrag.year,
|
||||||
laufzeit: null,
|
laufzeit: null,
|
||||||
freigabe: null,
|
freigabe: null,
|
||||||
|
|||||||
+21
-5
@@ -1499,15 +1499,23 @@ html[data-aufmacher="voll"] {
|
|||||||
**Kurz und steil, nicht lang und flach.** Hier stand 55 % Deckung auf
|
**Kurz und steil, nicht lang und flach.** Hier stand 55 % Deckung auf
|
||||||
halber Höhe und durchsichtig erst bei 85 % — von einem starken Aufmacher
|
halber Höhe und durchsichtig erst bei 85 % — von einem starken Aufmacher
|
||||||
war dadurch vor allem Grau zu sehen. Der Text sitzt unten links; genau
|
war dadurch vor allem Grau zu sehen. Der Text sitzt unten links; genau
|
||||||
dort muss es tragen, und darüber nicht. */
|
dort muss es tragen, und darüber nicht.
|
||||||
|
|
||||||
|
**Zweite Runde, und zwar nach unten.** Auch kurz und steil war es noch
|
||||||
|
zu dunkel: der Verlauf von links legte sich bis zur Mitte des Bildes und
|
||||||
|
nahm ihm die Farbe, und der von unten begann sein Grau schon auf
|
||||||
|
Zweidritteln. Jetzt trägt der untere das Gewicht — dort steht die
|
||||||
|
Schrift —, und der von links ist nur noch ein Hauch über dem linken
|
||||||
|
Drittel. Dass Schrift auf einem hellen Bild lesbar bleibt, übernimmt
|
||||||
|
ihr eigener Schattenwurf, nicht eine Decke über dem ganzen Aufmacher. */
|
||||||
.hero-buehne::after {
|
.hero-buehne::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute; inset: 0;
|
position: absolute; inset: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .70) 16%,
|
linear-gradient(to top, var(--canvas) 0%, rgba(11, 11, 13, .62) 12%,
|
||||||
rgba(11, 11, 13, .25) 38%, transparent 62%),
|
rgba(11, 11, 13, .16) 30%, transparent 48%),
|
||||||
linear-gradient(to right, rgba(11, 11, 13, .85) 0%, rgba(11, 11, 13, .30) 30%,
|
linear-gradient(to right, rgba(11, 11, 13, .55) 0%, rgba(11, 11, 13, .12) 22%,
|
||||||
transparent 55%);
|
transparent 38%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-inhalt {
|
.hero-inhalt {
|
||||||
@@ -1537,6 +1545,14 @@ html[data-aufmacher="voll"] {
|
|||||||
font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em;
|
font-size: var(--t-hero); font-weight: 700; letter-spacing: -.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* **Der Schatten trägt, nicht die Decke.** Seit der Verlauf heller ist,
|
||||||
|
kann das Bild hinter der Schrift hell sein. Ein Schattenwurf am Text
|
||||||
|
kostet nichts und wirkt genau dort, wo er gebraucht wird — eine dunklere
|
||||||
|
Decke über dem ganzen Aufmacher wirkte überall. */
|
||||||
|
.hero-titel,
|
||||||
|
.hero-meta,
|
||||||
|
.hero-text { text-shadow: 0 1px 4px rgba(0, 0, 0, .85), 0 0 18px rgba(0, 0, 0, .55); }
|
||||||
|
|
||||||
.hero-meta { font-size: var(--t-normal); color: var(--text-dim); }
|
.hero-meta { font-size: var(--t-normal); color: var(--text-dim); }
|
||||||
|
|
||||||
/* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die
|
/* Drei Zeilen, dann Schluss — eine volle Inhaltsangabe gehört auf die
|
||||||
|
|||||||
Reference in New Issue
Block a user