**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>
124 lines
5.6 KiB
Swift
124 lines
5.6 KiB
Swift
import Foundation
|
||
import FlaroDesign
|
||
import MediaKit
|
||
|
||
/// Woher der Aufmacher seine Bilder nimmt.
|
||
///
|
||
/// **Liegt bei den Modellen, obwohl der Name nach Ansicht klingt.** Hier steht
|
||
/// keine Zeile SwiftUI, nur die Frage, welche Adresse in welcher Grösse
|
||
/// angefordert wird — und die beantwortet der Fernseher genauso wie das
|
||
/// Telefon.
|
||
///
|
||
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
|
||
/// haben:
|
||
///
|
||
/// Erstens wurde das Hintergrundbild in 900×1200 angefordert — hochkant — und
|
||
/// dann in einem 16:9-Rahmen gezeigt. Zu sehen war davon ein schmaler Streifen
|
||
/// aus der Mitte; vom Bild fehlte oben und unten je ein Drittel. Die Maße
|
||
/// richten sich jetzt nach dem Rahmen, in dem das Bild landet.
|
||
///
|
||
/// Zweitens holt das Karussell das nächste Bild vor, solange das aktuelle noch
|
||
/// steht. Weicht dessen Adresse auch nur in einer Zahl ab, ist es eine andere
|
||
/// Adresse, der Zwischenspeicher trifft daneben und der Aufmacher blendet auf
|
||
/// eine graue Fläche über.
|
||
enum HeroArtwork {
|
||
/// Wofür das Bild gebraucht wird.
|
||
///
|
||
/// **Auf einem breiten Bildschirm macht das keinen Unterschied** — dort
|
||
/// ist beides ein 16:9-Hintergrundbild über die ganze Breite. Schmal
|
||
/// gehen die beiden auseinander, und zwar aus einem inhaltlichen Grund:
|
||
///
|
||
/// Im **Karussell** sucht man noch. Dort soll eine Szene stehen, kein
|
||
/// Plakat: das Plakat trägt den Titel selbst, und darüber liegt noch der
|
||
/// freigestellte Schriftzug — derselbe Name zweimal.
|
||
///
|
||
/// Auf einer **Detailseite** hat man sich entschieden. Dort ist das
|
||
/// Plakat das Bild dieses Films, und der Kopf der Seite darf die Höhe
|
||
/// nehmen, die ein Plakat braucht.
|
||
enum Placement {
|
||
/// Der Aufmacher auf der Startseite.
|
||
case carousel
|
||
/// Der Kopf einer Detailseite.
|
||
case detailHeader
|
||
}
|
||
|
||
/// Ob der Rahmen hochkant ist.
|
||
///
|
||
/// Die eine Stelle, an der die Entscheidung fällt — Maße, Verhältnis und
|
||
/// Reihenfolge der Bildpfade hängen alle daran, und sie müssen
|
||
/// zusammenpassen.
|
||
private static func isPortrait(_ placement: Placement, compact: Bool) -> Bool {
|
||
compact && placement == .detailHeader
|
||
}
|
||
|
||
/// Die Maße, in denen das Bild angefordert wird.
|
||
///
|
||
/// Im Seitenverhältnis des Rahmens, in dem es landet — sonst schneidet der
|
||
/// Server zu, und man sieht einen Streifen aus der Mitte.
|
||
static func size(_ placement: Placement = .carousel, compact: Bool) -> (width: Int, height: Int) {
|
||
if isPortrait(placement, compact: compact) { return (600, 900) }
|
||
// Schmal landet das Querbild in einem 4:3-Rahmen von höchstens 440
|
||
// Punkten Breite — bei dreifacher Auflösung rund 1320 Bildpunkte.
|
||
// Zwölfhundert ist genug und spart gegenüber 1920 ein Drittel.
|
||
return compact ? (1200, 900) : (1920, 1080)
|
||
}
|
||
|
||
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
||
if isPortrait(placement, compact: compact) { return FlaroAspectRatio.poster }
|
||
return compact ? FlaroAspectRatio.heroCompact : FlaroAspectRatio.hero
|
||
}
|
||
|
||
static func backdropURL(
|
||
for item: MediaItem,
|
||
backend: any MediaBackend,
|
||
placement: Placement = .carousel,
|
||
compact: Bool
|
||
) -> URL? {
|
||
backdropURLs(for: item, backend: backend, placement: placement, compact: compact).first
|
||
}
|
||
|
||
/// Alle Kandidaten, vom besten zum schlechtesten.
|
||
///
|
||
/// **Warum mehrere und nicht einer.** Eine Folge hat selten ein eigenes
|
||
/// Querformatbild, die Staffel manchmal, die Serie fast immer. Bisher
|
||
/// wurde die erste Adresse genommen, die sich **zusammensetzen** liess —
|
||
/// und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus.
|
||
/// Gab es keines, blieb die Fläche grau, obwohl eine Ebene höher eines
|
||
/// gelegen hätte. Genau so sahen Folgen im Aufmacher aus.
|
||
///
|
||
/// Die Reihenfolge steht hier, ausprobiert wird sie beim Laden.
|
||
static func backdropURLs(
|
||
for item: MediaItem,
|
||
backend: any MediaBackend,
|
||
placement: Placement = .carousel,
|
||
compact: Bool
|
||
) -> [URL] {
|
||
let size = size(placement, compact: compact)
|
||
let pfade: [String?] = isPortrait(placement, compact: compact)
|
||
// Hochkant: das Plakat der Folge, dann das der Staffel, dann das
|
||
// der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist quer
|
||
// und verliert hochkant die Hälfte.
|
||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||
// **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>()
|
||
return pfade
|
||
.compactMap(\.self)
|
||
.filter { gesehen.insert($0).inserted }
|
||
.compactMap { backend.artworkURL(path: $0, width: size.width, height: size.height) }
|
||
}
|
||
|
||
/// Der freigestellte Schriftzug — ohne Bild-Transcoder.
|
||
///
|
||
/// Er ist ein PNG mit durchsichtigem Grund, und der Transcoder liefert
|
||
/// JPEG. Aus dem Schriftzug würde sonst ein schwarzer Kasten.
|
||
static func logoURL(for item: MediaItem, backend: any MediaBackend) -> URL? {
|
||
backend.rawImageURL(path: item.clearLogoPath)
|
||
}
|
||
}
|