Files
Flaro/Packages/FlaroKit/Sources/FlaroDesign/Components/HeroBanner.swift
T
ScarriffleandClaude Opus 5 94458ef41c Fünf Knöpfe in einer Reihe wurden auf dem Telefon zu Säulen
In einem `HStack` teilen sich die Knöpfe die Breite. Abspielen, Von vorn,
Herunterladen und zwei Sprachknöpfe passten auf einem Telefon nicht
nebeneinander — heraus kamen Säulen aus einzelnen Buchstaben, senkrecht
untereinander. Lesbar war nichts mehr; angetippt hätte man trotzdem das
Richtige.

`Flusszeile` bricht um, statt zu quetschen: was nicht mehr hinpasst,
kommt in die nächste Zeile. Zwei Zeilen sind kein Mangel, sie sind, was
dort hinpasst — und ein Knopf, der auf schmalen Geräten verschwindet,
wäre eine zweite Oberfläche, die gepflegt werden will.

Kein `ViewThatFits`: das wählt zwischen fertigen Entwürfen, und die
müsste man für jede Zahl von Knöpfen von Hand hinschreiben. Hier wird
gerechnet.

Dazu bleibt die Beschriftung eines Sprachknopfs einzeilig. Umbrochen wird
die Reihe, nicht das Wort darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 23:20:57 +02:00

306 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 = 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)
}
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
// Reihe von Säulen aus einzelnen Buchstaben.
Flusszeile {
actions
}
.padding(.top, FlaroSpacing.tight)
}
.padding(.horizontal, FlaroSpacing.screenMargin * scale)
.padding(.bottom, FlaroSpacing.loose * scale)
}
}