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>
88 lines
4.0 KiB
Swift
88 lines
4.0 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
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
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
var body: some View {
|
|
HeroBanner(
|
|
title: item.rowTitle,
|
|
metadata: metadata,
|
|
summary: item.summary,
|
|
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.
|
|
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
|
|
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
|
|
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
|
|
// vorschreibt, und darüber und darunter bliebe der halbe
|
|
// Bildschirm leer.
|
|
fillsAvailableSpace: sizeClass != .compact
|
|
) {
|
|
Button {
|
|
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
|
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
|
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
|
// deren eigene Kennung an den Player, und der scheiterte mit
|
|
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
|
} label: {
|
|
Label(playLabel, systemImage: "play.fill")
|
|
.font(.flixyCardTitle)
|
|
.padding(.horizontal, FlixySpacing.loose)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accent)
|
|
|
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
|
Label("hero.details", systemImage: "info.circle")
|
|
.font(.flixyCardTitle)
|
|
.padding(.horizontal, FlixySpacing.loose)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(Color.flixyTextSecondary)
|
|
}
|
|
}
|
|
|
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
|
private var playLabel: LocalizedStringKey {
|
|
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
|
}
|
|
|
|
private var metadata: String? {
|
|
var pieces: [String] = []
|
|
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
|
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
|
}
|
|
if let year = item.year { pieces.append(String(year)) }
|
|
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
|
if let rating = item.contentRating { pieces.append(rating) }
|
|
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
|
}
|
|
}
|