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: 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 = FlaroAspectRatio.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 `FlaroArtwork` wie zuvor: die zweite /// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt. @ViewBuilder private var bild: some View { if outgoingArtworkURLs.isEmpty { FlaroArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace) } else { ZStack { FlaroArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace) FlaroArtwork(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.flaroCanvas.opacity(0.75), location: 0.45), .init(color: Color.flaroCanvas, 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: FlaroSpacing.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.flaroTextPrimary) .lineLimit(2) } if let metadataContent { metadataContent .font(.system(size: 12 * scale)) .foregroundStyle(Color.flaroTextSecondary) } else if let metadata { Text(metadata) .font(.system(size: 12 * scale)) .foregroundStyle(Color.flaroTextSecondary) } if let summary { Text(summary) .font(.system(size: 12 * scale)) .foregroundStyle(Color.flaroTextSecondary) .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: FlaroSpacing.regular) { actions } .padding(.top, FlaroSpacing.tight) } .padding(.horizontal, FlaroSpacing.screenMargin * scale) .padding(.bottom, FlaroSpacing.loose * scale) } }