Vier Dinge, die zusammengehören: Die Spiegelung ist weg. `backgroundExtensionEffect` füllt die Fläche unter der Leiste, indem er den Bildrand gespiegelt weiterzeichnet — und je größer die zu füllende Fläche wurde, desto mehr übernahm der Spiegel das Bild. Auf dem iPhone legte er sich ohne jede Seitenleiste über den Statusbalken. Jetzt läuft das Bild selbst weiter, über einen negativen Rand. Es endet dabei an der **Kante der Leiste**, nicht am Fensterrand. Der Unterschied sind zwölf Punkte, und die waren deutlich zu sehen: neben der schwebenden Leiste stand ein heller Streifen Bild, den nichts abdeckte. Das Seitenverhältnis im Karussell musste weichen. Es legte den Aufmacher auf die Breite des Inhaltsbereichs fest; weil er nach links unter die Leiste greift, blieb rechts genau so viel schwarz übrig, wie er links gewann. Und die Maße, in denen das Hintergrundbild angefordert wird, richten sich jetzt nach dem Rahmen, in dem es landet. Vorher wurden 900×1200 geholt — hochkant — und in einem 16:9-Rahmen gezeigt: zu sehen war ein Streifen aus der Mitte, oben und unten fehlte je ein Drittel. Dazu: auf schmalen Geräten ist der Aufmacher hochkant. In 16:9 blieben auf einem iPhone keine zweihundertzwanzig Punkte Höhe, und Schriftzug, Zeile, Beschreibung und zwei Knöpfe lagen übereinander. Und ein Schleier unter dem Glas der Leiste. Reines Glas zeigt alles, was dahinter vorbeizieht, und beim Scrollen zieht die ganze Startseite vorbei — aus den ruhigen Beschriftungen wurde ein unruhiger Brei. Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche zuzumauern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
8.5 KiB
Swift
194 lines
8.5 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?
|
|
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
|
|
|
|
@State private var logo: PlatformImage?
|
|
|
|
/// Wie weit die Navigationsleiste hereinragt.
|
|
///
|
|
/// Nur das **Bild** greift danach; Titel, Beschreibung und Knöpfe nicht.
|
|
/// Die liegen in einer Überlagerung, und die sitzt auf dem ungedehnten
|
|
/// Rahmen — sie stehen also von selbst da, wo auch die Reihen darunter
|
|
/// anfangen.
|
|
@Environment(\.flixyChromeInset) private var chromeInset
|
|
|
|
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()
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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 {
|
|
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
|
// Bis unter die Leiste und an den Fensterrand.
|
|
//
|
|
// Hier stand `backgroundExtensionEffect()`. Der spiegelt den
|
|
// Bildrand und zeichnet ihn weichgezeichnet weiter — und je größer
|
|
// die Fläche wurde, die er füllen musste, desto mehr übernahm der
|
|
// Spiegel das Bild. Über die Seitenleiste hinaus war er nicht mehr
|
|
// Fortsetzung, sondern Hauptsache; auf dem iPhone legte er sich
|
|
// ohne jede Seitenleiste über den Statusbalken.
|
|
//
|
|
// Ein negativer Rand tut dasselbe ehrlicher: das Bild **selbst**
|
|
// läuft weiter. Eine Fläche, ein Ausschnitt, keine Naht.
|
|
.padding(.leading, -chromeInset)
|
|
.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) {
|
|
GeometryReader { geometry in
|
|
let scale = scale(for: geometry.size.width)
|
|
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
|
|
titleView(scale: scale)
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|