„Werner Beinhart" über dem Bild von John Wick. Von mir eingebaut, im
Commit, der die Überblendung umgestellt hat.
**Der Grund ist der Umbau selbst.** Vorher bekam der Aufmacher je Titel
eine neue Identität; sein Zustand begann damit jedes Mal von vorn. Seit
er bleibt und nur seine Angaben wechseln, überlebt auch der geladene
Schriftzug den Wechsel — und `availableLogo` gab ihn heraus, ohne zu
prüfen, ob er überhaupt zum aktuellen Titel gehört:
if let logo { return logo } // ← zu irgendeinem Film
Jetzt trägt der Zustand die Adresse mit, zu der er gehört, und wird nur
herausgegeben, wenn sie zur aktuellen passt. Passt sie nicht, gilt der
Zwischenspeicher — und wenn auch der nichts hat, der gesetzte Titel. Nie
der falsche Schriftzug.
**Dieselbe Falle steckte in `FlixyArtwork`**, nur unauffälliger: auch
dort hielt `loaded` ein Bild ohne Angabe, wozu es gehört, und `load()`
brach deswegen früh ab („ist ja schon eins da"). Solange jede Kachel ihre
eigene Identität hat, fällt das nicht auf. Der Aufmacher hat seit dem
Umbau keine mehr.
Die Regel dahinter gilt allgemein: **ein Zustand, der etwas
zwischenspeichert, muss sagen, wozu es gehört** — sonst gilt er für
alles.
Die Attrappe liefert jetzt Schriftzüge (`Logo`-Bildmarke). Ohne sie fällt
der Aufmacher auf den gesetzten Titel zurück, und dieser ganze Weg wäre
im Simulator gar nicht zu sehen — genau deshalb ist der Fehler erst beim
Nutzer aufgefallen.
iOS und tvOS bauen, 476 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
14 KiB
Swift
303 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
/// Das Aufmacher-Bild oben auf der Startseite.
|
|
///
|
|
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
|
|
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
|
|
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
|
|
public struct HeroBanner<Actions: View>: View {
|
|
private let title: String
|
|
private let metadata: String?
|
|
/// Eine Zeile, in der einzelne Teile anklickbar sind.
|
|
///
|
|
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
|
|
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
|
|
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
|
|
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
|
|
/// durch jeden Aufruf.
|
|
private let metadataContent: AnyView?
|
|
private let summary: String?
|
|
private let artworkURLs: [URL]
|
|
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
|
|
///
|
|
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
|
|
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
|
|
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
|
|
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
|
|
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
|
|
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
|
|
/// steht.
|
|
///
|
|
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
|
|
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
|
|
/// schwächer werden.
|
|
private let outgoingArtworkURLs: [URL]
|
|
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
|
private let crossfade: CGFloat
|
|
/// Wie weit das neue Bild dabei hereinrutscht.
|
|
private let incomingOffset: CGFloat
|
|
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
|
|
///
|
|
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
|
|
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
|
|
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
|
|
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
|
|
/// Aufmacher.
|
|
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
|
|
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
|
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
|
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
|
private let logoURL: URL?
|
|
private let aspectRatio: CGFloat
|
|
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
|
private let fillsAvailableSpace: Bool
|
|
private let actions: Actions
|
|
|
|
/// Der geladene Schriftzug — **mit der Adresse, zu der er gehört**.
|
|
///
|
|
/// **Ohne die Adresse wird er falsch.** Seit der Aufmacher seine
|
|
/// Identität über den Titelwechsel hinweg behält, überlebt auch dieser
|
|
/// Zustand ihn: der Schriftzug des vorigen Films stand dann über dem Bild
|
|
/// des neuen, bis der neue geladen war. „Werner Beinhart" über John Wick.
|
|
///
|
|
/// Ein Zustand, der eine andere Angabe zwischenspeichert, muss sagen,
|
|
/// wozu er gehört — sonst gilt er für alles.
|
|
@State private var loadedLogo: (url: URL, image: PlatformImage)?
|
|
|
|
public init(
|
|
title: String,
|
|
metadata: String? = nil,
|
|
metadataContent: AnyView? = nil,
|
|
summary: String? = nil,
|
|
artworkURLs: [URL],
|
|
outgoingArtworkURLs: [URL] = [],
|
|
outgoingTitle: String? = nil,
|
|
outgoingMetadata: String? = nil,
|
|
outgoingLogoURL: URL? = nil,
|
|
crossfade: CGFloat = 1,
|
|
incomingOffset: CGFloat = 0,
|
|
logoURL: URL? = nil,
|
|
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
|
|
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
|
|
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
|
// sehen.
|
|
aspectRatio: CGFloat = FlixyAspectRatio.hero,
|
|
fillsAvailableSpace: Bool = false,
|
|
@ViewBuilder actions: () -> Actions
|
|
) {
|
|
self.title = title
|
|
self.metadata = metadata
|
|
self.metadataContent = metadataContent
|
|
self.summary = summary
|
|
self.artworkURLs = artworkURLs
|
|
self.outgoingArtworkURLs = outgoingArtworkURLs
|
|
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
|
|
self.crossfade = crossfade
|
|
self.incomingOffset = incomingOffset
|
|
self.logoURL = logoURL
|
|
self.aspectRatio = aspectRatio
|
|
self.fillsAvailableSpace = fillsAvailableSpace
|
|
self.actions = actions()
|
|
}
|
|
|
|
/// Wie stark Schrift und Schriftzug mitwachsen.
|
|
///
|
|
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
|
|
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
|
|
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
|
|
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
|
|
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
|
|
/// Plakatschrift endet.
|
|
private func scale(for width: CGFloat) -> CGFloat {
|
|
min(max(width / 834, 1), 1.7)
|
|
}
|
|
|
|
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
|
|
///
|
|
/// Ohne Wechsel ist es genau ein `FlixyArtwork` wie zuvor: die zweite
|
|
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
|
|
@ViewBuilder
|
|
private var bild: some View {
|
|
if outgoingArtworkURLs.isEmpty {
|
|
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
|
} else {
|
|
ZStack {
|
|
FlixyArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
|
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
|
.opacity(crossfade)
|
|
.offset(x: incomingOffset)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
|
|
///
|
|
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
|
|
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
|
|
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
|
|
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
|
|
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
|
|
/// zwei Bewegungen, wo eine sein soll.
|
|
///
|
|
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
|
|
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
|
|
/// zum Aufmacher und macht mit, was der Aufmacher macht.
|
|
///
|
|
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
|
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
|
/// Kasten.
|
|
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
|
///
|
|
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
|
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
|
/// steht bis dahin der gesetzte Titel.
|
|
private var availableLogo: PlatformImage? {
|
|
guard let logoURL else { return nil }
|
|
if let loadedLogo, loadedLogo.url == logoURL { return loadedLogo.image }
|
|
return ArtworkLoader.cachedImage(for: logoURL)
|
|
}
|
|
|
|
public var body: some View {
|
|
bild
|
|
.overlay(alignment: .bottom) {
|
|
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
|
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
|
// erste Reihe übergeht, eine sichtbare Kante.
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .clear, location: 0),
|
|
.init(color: Color.flixyCanvas.opacity(0.75), location: 0.45),
|
|
.init(color: Color.flixyCanvas, location: 1),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.overlay(alignment: .top) {
|
|
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
|
|
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
|
|
// reicht. Über einem dunklen Aufmacher geht das gut; über
|
|
// einem hellen verschwinden sie.
|
|
//
|
|
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
|
|
// weit, wie die Insel baut, und läuft schnell aus. Ein
|
|
// großzügigerer deckte das Bild zu, und genau das Bild ist der
|
|
// Grund, warum es randlos sein soll.
|
|
LinearGradient(
|
|
colors: [.black.opacity(0.45), .clear],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 150)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.overlay(alignment: .bottomLeading) {
|
|
GeometryReader { geometry in
|
|
let scale = scale(for: geometry.size.width)
|
|
|
|
ZStack(alignment: .bottomLeading) {
|
|
// **Der weichende Text bleibt stehen, bis der neue
|
|
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
|
|
// keiner da: der neue blendet ein, der alte wäre
|
|
// schon weg. Er liegt über dem Verlauf — dort kostet
|
|
// er die Abdunklung nichts.
|
|
if let outgoing, crossfade < 1 {
|
|
inhalt(
|
|
title: outgoing.title,
|
|
metadata: outgoing.metadata,
|
|
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
|
|
scale: scale
|
|
)
|
|
}
|
|
|
|
// Der neue — mit demselben Einblenden und demselben
|
|
// Hereinrutschen wie das Bild darunter, damit beides
|
|
// als eine Bewegung erscheint.
|
|
inhalt(
|
|
title: title,
|
|
metadata: metadata,
|
|
logo: availableLogo,
|
|
scale: scale
|
|
)
|
|
.opacity(crossfade)
|
|
.offset(x: incomingOffset)
|
|
}
|
|
.frame(
|
|
width: geometry.size.width,
|
|
height: geometry.size.height,
|
|
alignment: .bottomLeading
|
|
)
|
|
}
|
|
}
|
|
.task(id: logoURL) {
|
|
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
|
if let bild = await ArtworkLoader.shared.image(for: logoURL) {
|
|
loadedLogo = (logoURL, bild)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
|
|
///
|
|
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
|
|
/// einmal für den weichenden und einmal für den kommenden Eintrag.
|
|
@ViewBuilder
|
|
private func inhalt(
|
|
title: String,
|
|
metadata: String?,
|
|
logo: PlatformImage?,
|
|
scale: CGFloat
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
|
|
if let logo {
|
|
Image(platform: logo)
|
|
.resizable()
|
|
.scaledToFit()
|
|
// Nach oben begrenzt: manche Schriftzüge sind fast
|
|
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
|
// hohes Logo die Knöpfe aus dem Bild.
|
|
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
|
.accessibilityLabel(Text(title))
|
|
} else {
|
|
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
|
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
|
// eine Systemschrift.
|
|
Text(title)
|
|
.font(.system(size: 34 * scale, weight: .bold))
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
if let metadataContent {
|
|
metadataContent
|
|
.font(.system(size: 12 * scale))
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
} else if let metadata {
|
|
Text(metadata)
|
|
.font(.system(size: 12 * scale))
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
|
|
if let summary {
|
|
Text(summary)
|
|
.font(.system(size: 12 * scale))
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.lineLimit(2)
|
|
// Ein Satz über die ganze Breite eines großen Fensters ist
|
|
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
|
|
// sind die Grenze, ab der Satzspiegel unangenehm werden.
|
|
.frame(maxWidth: 640 * scale, alignment: .leading)
|
|
}
|
|
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
actions
|
|
}
|
|
.padding(.top, FlixySpacing.tight)
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin * scale)
|
|
.padding(.bottom, FlixySpacing.loose * scale)
|
|
}
|
|
}
|