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
+67 -68
View File
@@ -9,10 +9,6 @@
"comment" : "A number in a list.",
"isCommentAutoGenerated" : true
},
"%@ GB": {
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated": true
},
"%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true,
@@ -25,22 +21,14 @@
}
}
},
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"4K" : {
"comment" : "A label for a 4K video.",
"isCommentAutoGenerated" : true
},
"Full HD": {
"comment": "A label for a media resolution.",
"isCommentAutoGenerated": true
},
"HD": {
"comment": "A label for a media resolution.",
"isCommentAutoGenerated": true
},
"SD": {
"comment": "A label for a 720p movie.",
"isCommentAutoGenerated": true
},
"account.hint" : {
"localizations" : {
"de" : {
@@ -1890,8 +1878,15 @@
}
}
},
"Full HD" : {
"comment" : "A label for a media resolution.",
"isCommentAutoGenerated" : true
},
"HD" : {
"comment" : "A label for a media resolution.",
"isCommentAutoGenerated" : true
},
"hero.details" : {
"extractionState": "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3253,23 +3248,6 @@
}
}
},
"liveTV.recordSeries": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Serie aufnehmen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Record series"
}
}
}
},
"liveTV.recording.inProgress" : {
"extractionState" : "manual",
"localizations" : {
@@ -3338,6 +3316,23 @@
}
}
},
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.runningNow" : {
"extractionState" : "stale",
"localizations" : {
@@ -4129,23 +4124,6 @@
}
}
},
"player.subtitleTracks": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale",
@@ -4164,6 +4142,23 @@
}
}
},
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : {
"extractionState" : "stale",
"localizations" : {
@@ -4248,23 +4243,6 @@
}
}
},
"quality.autoShort": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Auto"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Auto"
}
}
}
},
"quality.automatic" : {
"extractionState" : "stale",
"localizations" : {
@@ -4282,6 +4260,23 @@
}
}
},
"quality.autoShort" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
}
}
},
"quality.current" : {
"localizations" : {
"de" : {
@@ -4827,6 +4822,10 @@
}
}
},
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.forgetRecent" : {
"extractionState" : "manual",
"localizations" : {
+27 -14
View File
@@ -87,26 +87,39 @@ struct HeroCarousel: View {
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 {
items.firstIndex { $0.id == shownID } ?? 0
}
var body: some View {
VStack(spacing: FlixySpacing.regular) {
ZStack {
// 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))
}
}
heroSection
// Die Höhe: fast das ganze Fenster.
//
// 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.
struct HeroSection: View {
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(PlaybackCoordinator.self) private var playback
@@ -20,6 +26,11 @@ struct HeroSection: View {
artworkURLs: session.backend.map {
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) },
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
// Schriftzug und Beschreibung übereinanderlagen.
@@ -18,6 +18,24 @@ public struct HeroBanner<Actions: View>: View {
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
/// 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.
@@ -35,6 +53,9 @@ public struct HeroBanner<Actions: View>: View {
metadataContent: AnyView? = nil,
summary: String? = nil,
artworkURLs: [URL],
outgoingArtworkURLs: [URL] = [],
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
@@ -49,6 +70,9 @@ public struct HeroBanner<Actions: View>: View {
self.metadataContent = metadataContent
self.summary = summary
self.artworkURLs = artworkURLs
self.outgoingArtworkURLs = outgoingArtworkURLs
self.crossfade = crossfade
self.incomingOffset = incomingOffset
self.logoURL = logoURL
self.aspectRatio = aspectRatio
self.fillsAvailableSpace = fillsAvailableSpace
@@ -67,6 +91,24 @@ public struct HeroBanner<Actions: View>: View {
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.
///
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
@@ -127,7 +169,7 @@ public struct HeroBanner<Actions: View>: View {
}
public var body: some View {
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
bild
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die