Der Aufmacher hellte beim Wechsel auf — zwei Verläufe ergeben keinen

Beim Wechsel im Karussell war der Verlauf nach unten für einen Moment
weg: das Bild stand bis unten hell da, und erst nach dem Übergang kam das
Abdunkeln zurück.

**Der Grund steckt in der Form der Überblendung.** Das Karussell legte
zwei vollständige Aufmacher übereinander und blendete den oberen ein. Nur
bringt jeder von beiden seinen eigenen Verlauf mit — und zwei
halbdurchsichtige Verläufe ergeben zusammen keinen ganzen.

Gemessen, an derselben Stelle im Bild, über einen auf sechs Sekunden
gestreckten Wechsel:

    Ruhezustand        68 .. 75
    Mitte des Wechsels      137        ← fast doppelt so hell

Zwei naheliegende Reparaturen habe ich davor versucht und verworfen,
weil die Messung sie widerlegt hat: dem weichenden Aufmacher die
Animation zu nehmen (wurde schlechter, 152) und beide Ebenen
gegenläufig zu blenden (brachte einen dunklen Einbruch auf 35 dazu).
Beide behandelten das Symptom, nicht die Form.

**Jetzt wechselt nur das Bild.** `HeroBanner` nimmt zusätzlich die
weichende Bildadresse entgegen und blendet **innen** über; Verlauf,
Schriftzug und Knöpfe liegen einmal darüber und ändern sich dabei nicht.
Damit kann der Verlauf gar nicht mehr schwächer werden — nicht weil es
jetzt richtig gerechnet wird, sondern weil es nur noch einen gibt.

Nachgemessen, derselbe Wechsel:

    vorher   68 .. 154
    jetzt    63 ..  75

Die Aufmacher-Ansicht behält dabei ihre Identität, statt je Titel eine
neue zu bekommen: eine neue hiesse Einfügen und Entfernen, und SwiftUI
blendete sie dann zusätzlich ein — noch eine Überblendung über der
gemeinten.

iOS und tvOS bauen, 476 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 11:11:22 +02:00
co-authored by Claude Opus 5
parent a665d79e6d
commit 63c664e5a9
4 changed files with 4960 additions and 4895 deletions
File diff suppressed because it is too large Load Diff
+27 -14
View File
@@ -87,26 +87,39 @@ struct HeroCarousel: View {
sizeClass == .compact ? 0 : chrome.inset sizeClass == .compact ? 0 : chrome.inset
} }
/// Der Aufmacher — ein einziger, der sein Bild wechselt.
///
/// **Nicht zwei übereinander.** So war es, und jeder der beiden brachte
/// seinen eigenen Verlauf nach unten mit; zwei halbdurchsichtige Verläufe
/// ergeben zusammen keinen ganzen. Gemessen an derselben Stelle im Bild:
/// im Ruhezustand 68, in der Mitte des Wechsels 137 — sichtbar als
/// Aufhellen genau dort, wo der Titel steht. Jetzt wechselt nur das Bild,
/// Verlauf und Text liegen einmal darüber.
@ViewBuilder
private var heroSection: some View {
if items.indices.contains(currentIndex) {
HeroSection(
item: items[currentIndex],
outgoing: fadingID.flatMap { kennung in
items.first { $0.id == kennung }
},
crossfade: cover,
incomingOffset: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide)
)
// Ohne eigene Identität je Titel: die Ansicht **bleibt**, und nur
// ihre Angaben wechseln. Eine neue Identität hiesse, sie würde
// eingefügt und entfernt — und SwiftUI blendete sie dabei ein,
// zusätzlich zur Überblendung, die hier gemeint ist.
}
}
private var currentIndex: Int { private var currentIndex: Int {
items.firstIndex { $0.id == shownID } ?? 0 items.firstIndex { $0.id == shownID } ?? 0
} }
var body: some View { var body: some View {
VStack(spacing: FlixySpacing.regular) { VStack(spacing: FlixySpacing.regular) {
ZStack { heroSection
// Unten: der vorige Titel, unverändert deckend.
if let fadingID, let previous = items.first(where: { $0.id == fadingID }) {
HeroSection(item: previous)
}
// Oben: der neue, der ihn zudeckt und dabei hereinrutscht.
if items.indices.contains(currentIndex) {
HeroSection(item: items[currentIndex])
.id(items[currentIndex].id)
.opacity(cover)
.offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
}
}
// Die Höhe: fast das ganze Fenster. // Die Höhe: fast das ganze Fenster.
// //
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein // Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
+11
View File
@@ -5,6 +5,12 @@ import MediaKit
/// Der Aufmacher oben auf der Startseite. /// Der Aufmacher oben auf der Startseite.
struct HeroSection: View { struct HeroSection: View {
let item: MediaItem let item: MediaItem
/// Der Titel, dessen Bild gerade verschwindet — nur im Karussell gesetzt.
var outgoing: MediaItem?
/// Wie weit das neue Bild schon deckt.
var crossfade: CGFloat = 1
/// Wie weit es dabei hereinrutscht.
var incomingOffset: CGFloat = 0
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@@ -20,6 +26,11 @@ struct HeroSection: View {
artworkURLs: session.backend.map { artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
} ?? [], } ?? [],
outgoingArtworkURLs: session.backend.flatMap { backend in
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
} ?? [],
crossfade: crossfade,
incomingOffset: incomingOffset,
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) }, logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass // Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
// Schriftzug und Beschreibung übereinanderlagen. // Schriftzug und Beschreibung übereinanderlagen.
@@ -18,6 +18,24 @@ public struct HeroBanner<Actions: View>: View {
private let metadataContent: AnyView? private let metadataContent: AnyView?
private let summary: String? private let summary: String?
private let artworkURLs: [URL] 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 /// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner /// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht. /// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
@@ -35,6 +53,9 @@ public struct HeroBanner<Actions: View>: View {
metadataContent: AnyView? = nil, metadataContent: AnyView? = nil,
summary: String? = nil, summary: String? = nil,
artworkURLs: [URL], artworkURLs: [URL],
outgoingArtworkURLs: [URL] = [],
crossfade: CGFloat = 1,
incomingOffset: CGFloat = 0,
logoURL: URL? = nil, logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier // 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts // 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
@@ -49,6 +70,9 @@ public struct HeroBanner<Actions: View>: View {
self.metadataContent = metadataContent self.metadataContent = metadataContent
self.summary = summary self.summary = summary
self.artworkURLs = artworkURLs self.artworkURLs = artworkURLs
self.outgoingArtworkURLs = outgoingArtworkURLs
self.crossfade = crossfade
self.incomingOffset = incomingOffset
self.logoURL = logoURL self.logoURL = logoURL
self.aspectRatio = aspectRatio self.aspectRatio = aspectRatio
self.fillsAvailableSpace = fillsAvailableSpace self.fillsAvailableSpace = fillsAvailableSpace
@@ -67,6 +91,24 @@ public struct HeroBanner<Actions: View>: View {
min(max(width / 834, 1), 1.7) 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. /// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
/// ///
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der /// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
@@ -127,7 +169,7 @@ public struct HeroBanner<Actions: View>: View {
} }
public var body: some View { public var body: some View {
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace) bild
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in // Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die // Schwarz: sonst entsteht genau dort, wo der Aufmacher in die