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? private let summary: String? private let artworkURL: 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 private let actions: Actions public init( title: String, metadata: String? = nil, summary: String? = nil, artworkURL: URL?, 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, @ViewBuilder actions: () -> Actions ) { self.title = title self.metadata = metadata self.summary = summary self.artworkURL = artworkURL self.logoURL = logoURL self.aspectRatio = aspectRatio self.actions = actions() } /// Schriftzug, wenn vorhanden — sonst gesetzter Titel. /// /// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein /// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem /// freigestellten Logo würde sonst ein schwarzer Kasten. @ViewBuilder private var titleView: some View { if let logoURL { AsyncImage(url: logoURL) { phase in switch phase { case .success(let image): image .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, maxHeight: 76, alignment: .leading) default: // Solange geladen wird — und wenn es scheitert — bleibt es // beim gesetzten Titel. Ein leerer Platz wäre schlimmer // als eine Systemschrift. plainTitle } } .accessibilityLabel(Text(title)) } else { plainTitle } } private var plainTitle: some View { Text(title) .font(.flixyHero) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) } public var body: some View { FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) // Das Bild läuft über seinen Bereich hinaus — unter die // Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die // Ränder und zeichnet sie weich weiter, statt das Bild zu // verzerren; der Bildinhalt selbst bleibt, wo er ist, und mit ihm // Titel, Beschreibung und Knöpfe. // // Ohne das endete der Aufmacher an der Kante der Seitenleiste, und // beim Weiterwandern schob sich das nächste Bild an einer harten // Linie ins Bild. .backgroundExtensionEffect() .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: .bottomLeading) { VStack(alignment: .leading, spacing: FlixySpacing.snug) { titleView if let metadata { Text(metadata) .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) } if let summary { Text(summary) .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) .lineLimit(2) } HStack(spacing: FlixySpacing.regular) { actions } .padding(.top, FlixySpacing.tight) } .padding(.horizontal, FlixySpacing.screenMargin) .padding(.bottom, FlixySpacing.loose) } } }