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 /// 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 @State private var logo: PlatformImage? public init( title: String, metadata: String? = nil, metadataContent: AnyView? = nil, summary: String? = nil, artworkURLs: [URL], outgoingArtworkURLs: [URL] = [], 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.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. @ViewBuilder private func titleView(scale: CGFloat) -> some View { Group { if let logo = availableLogo { 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. plainTitle(scale: scale) } } .task(id: logoURL) { guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return } logo = await ArtworkLoader.shared.image(for: logoURL) } } /// 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? { if let logo { return logo } guard let logoURL else { return nil } return ArtworkLoader.cachedImage(for: logoURL) } private func plainTitle(scale: CGFloat) -> some View { Text(title) .font(.system(size: 34 * scale, weight: .bold)) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) } 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) VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) { titleView(scale: scale) 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) .frame( width: geometry.size.width, height: geometry.size.height, alignment: .bottomLeading ) } } } }