diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index 649668e..59e2682 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -29,6 +29,11 @@ struct HeroSection: View { outgoingArtworkURLs: session.backend.flatMap { backend in outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) } } ?? [], + outgoingTitle: outgoing?.rowTitle, + outgoingMetadata: outgoing.flatMap(Self.metadata(for:)), + outgoingLogoURL: session.backend.flatMap { backend in + outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) } + }, crossfade: crossfade, incomingOffset: incomingOffset, logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) }, @@ -74,7 +79,13 @@ struct HeroSection: View { item.watchProgress == nil ? "hero.play" : "hero.resume" } - private var metadata: String? { + private var metadata: String? { Self.metadata(for: item) } + + /// Die Zeile unter dem Titel. + /// + /// `static`, weil sie während eines Wechsels auch für den weichenden + /// Titel gebraucht wird — und der ist nicht `item`. + static func metadata(for item: MediaItem) -> String? { var pieces: [String] = [] if item.kind == .episode, let season = item.parentIndex, let episode = item.index { pieces.append(String(localized: "episode.short \(season) \(episode)")) diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 7997e67..7947de8 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -36,6 +36,14 @@ public struct HeroBanner: View { 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. @@ -54,6 +62,9 @@ public struct HeroBanner: View { 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, @@ -71,6 +82,7 @@ public struct HeroBanner: View { 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 @@ -125,31 +137,6 @@ public struct HeroBanner: View { /// 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 @@ -161,13 +148,6 @@ public struct HeroBanner: View { 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) { @@ -208,38 +188,33 @@ public struct HeroBanner: View { 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) + 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 + ) } - 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) + // 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) } - .padding(.horizontal, FlixySpacing.screenMargin * scale) - .padding(.bottom, FlixySpacing.loose * scale) .frame( width: geometry.size.width, height: geometry.size.height, @@ -247,5 +222,70 @@ public struct HeroBanner: View { ) } } + .task(id: logoURL) { + guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return } + logo = await ArtworkLoader.shared.image(for: logoURL) + } + } + + /// 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) } }