Der Aufmacher auf dem iPhone hat nicht das Hintergrundbild geholt,
sondern das **Plakat**. Dafür gab es einen Grund, und er stimmte: ein
16:9-Bild in einem 2:3-Rahmen ist ein Streifen aus der Mitte.
Nur war der Rahmen das Problem, nicht das Bild. Ein Plakat trägt den
Titel selbst, und darüber legt der Aufmacher noch den freigestellten
Schriftzug — derselbe Name zweimal, einmal gesetzt und einmal gemalt.
**Jetzt ein Querbild in einem 4:3-Rahmen.** 16:9 ginge nicht: das sind
auf 440 Punkten Breite 248 Punkte Höhe, und darin sollen Schriftzug,
Zeile, ein Satz Beschreibung und zwei Knöpfe stehen. Sie standen schon
einmal übereinander, das ist der dokumentierte Grund für das Hochformat.
4:3 gibt 330 Punkte — gemessen passt der Text hinein; dafür fehlt dem
Bild links und rechts zusammen ein Viertel.
**Die Detailseite bleibt hochkant.** Dort hat man sich schon entschieden;
das Plakat ist das Bild dieses Films, und du wolltest den Kopf der Seite
ausdrücklich höher. Beide Seiten lasen bisher aus **derselben**
Funktion — eine Änderung für den Aufmacher hätte die Detailseite von 660
auf 330 Punkte gestutzt, ohne dass irgendwo ein Fehler aufgetaucht wäre.
`HeroArtwork` kennt deshalb jetzt den Verwendungszweck:
HeroArtwork.aspectRatio(.detailHeader, compact: …) // 2:3
HeroArtwork.aspectRatio(compact: …) // 4:3 bzw. 16:9
Maße, Verhältnis und Reihenfolge der Bildpfade hängen alle an **einer**
privaten Abfrage — sie müssen zusammenpassen, sonst beschneidet der
Server anders als der Rahmen.
Die Maße schmal: 1200×900 statt 1920×1080. Der Rahmen ist dort höchstens
1320 Bildpunkte breit; das spart auf einer Mobilfunkverbindung ein
Drittel.
Gemessen im Simulator (iPhone 17 Pro Max): Aufmacher 4:3, Text passt,
Detailseite unverändert hochkant. iOS und tvOS bauen, 476 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
359 lines
16 KiB
Swift
359 lines
16 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
|
///
|
|
/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wechselt der Aufmacher alle
|
|
/// paar Sekunden; die Punkte darunter zeigen, wie viele es sind und wo man
|
|
/// gerade ist.
|
|
///
|
|
/// **Übergeblendet, nicht durchgeblättert.** Vorher lag hier ein einrastender
|
|
/// Streifen, der die Titel wie Buchseiten seitwärts durchschob. Das hatte eine
|
|
/// Nebenwirkung, die nichts mit dem Blättern zu tun schien: der Aufmacher füllt
|
|
/// die Fläche unter der Seitenleiste, indem er seinen eigenen Rand gespiegelt
|
|
/// weiterzeichnet. Steht das Bild still, fällt das niemandem auf. Schiebt es
|
|
/// sich dagegen seitwärts, wandern das echte und das gespiegelte Stück
|
|
/// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem
|
|
/// Bildschirm.
|
|
///
|
|
/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und legt sich
|
|
/// dabei über das alte. Nichts wandert weit genug, als dass ein Versatz
|
|
/// auffiele.
|
|
///
|
|
/// **Es wird nicht gekreuzt, sondern überdeckt.** Beim Kreuzblenden sind
|
|
/// beide Bilder gleichzeitig halbdurchsichtig — und dazwischen scheint der
|
|
/// Untergrund durch, was als helles Aufblitzen des vorigen Titels zu sehen
|
|
/// war. Hier bleibt das alte Bild voll deckend liegen, bis das neue darüber
|
|
/// undurchsichtig geworden ist; erst dann verschwindet es. Zu jedem Zeitpunkt
|
|
/// ist die Fläche gedeckt.
|
|
struct HeroCarousel: View {
|
|
let items: [MediaItem]
|
|
|
|
/// Wie lange ein Titel stehen bleibt.
|
|
///
|
|
/// Lang genug, um Titel, Beschreibung und Knöpfe zu lesen — bei drei
|
|
/// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat.
|
|
private static let dwell: Duration = .seconds(7)
|
|
|
|
/// Wie weit das Bild dabei rutscht.
|
|
///
|
|
/// Klein gehalten: es soll eine Richtung andeuten, nicht eine Strecke
|
|
/// zurücklegen. Alles darüber sieht wieder nach Blättern aus.
|
|
private static let slide: CGFloat = 32
|
|
|
|
@State private var shownID: String?
|
|
/// Der Titel, der noch darunter liegt und gleich verschwindet.
|
|
///
|
|
/// Er wird voll deckend gezeigt, nicht ausgeblendet — das Ausblenden
|
|
/// übernimmt das neue Bild, indem es ihn zudeckt.
|
|
@State private var fadingID: String?
|
|
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
|
@State private var cover: CGFloat = 1
|
|
/// Ob noch von selbst weitergewechselt wird.
|
|
///
|
|
/// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat,
|
|
/// fährt selbst — sonst wechselt der Aufmacher genau in der Sekunde, in
|
|
/// der man nach „Abspielen" greift, und man startet den falschen Film.
|
|
@State private var isAdvancing = true
|
|
/// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll
|
|
/// das Bild auch von der anderen Seite hereinkommen sehen.
|
|
@State private var isMovingForward = true
|
|
/// Ob der Zeiger über dem Aufmacher liegt — dann erscheinen die Pfeile.
|
|
@State private var isPointerInside = false
|
|
/// Ob er über den Punkten liegt — dann werden sie gross genug zum Treffen.
|
|
@State private var isHoveringDots = false
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
@Environment(TopChrome.self) private var chrome
|
|
@Environment(HomeLayoutSettings.self) private var layout
|
|
|
|
/// Wie weit der Aufmacher nach oben unter die Insel greift.
|
|
///
|
|
/// Der Inhalt beginnt unter der Insel — das ist für Reihen und Listen
|
|
/// richtig, für den Aufmacher aber nicht: er ist das Bild, auf dem die
|
|
/// Insel schweben soll, und ein Bild, das an ihrer Unterkante anfängt,
|
|
/// lässt darüber einen schwarzen Streifen stehen, den nie etwas füllt.
|
|
///
|
|
/// Also nimmt er sich den Platz zurück: um genau diese Strecke höher und
|
|
/// um genau diese Strecke größer. Unten ändert sich dadurch nichts, die
|
|
/// Reihen bleiben, wo sie waren.
|
|
///
|
|
/// Auf dem iPhone null — dort schwebt nichts über dem Inhalt.
|
|
private var overlap: CGFloat {
|
|
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) {
|
|
heroSection
|
|
// Die Höhe: fast das ganze Fenster.
|
|
//
|
|
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
|
|
// breites Vorschaubild — einer über die ganze Höhe dagegen wie die
|
|
// einzige Seite der App: von den Reihen war nichts mehr zu sehen,
|
|
// ohne zu scrollen. Zweiundachtzig Prozent lassen die erste Reihe
|
|
// gerade hereinlugen, und damit weiß man, dass es weitergeht.
|
|
//
|
|
// Auf einem schmalen Gerät nicht: dort rechnet sich der Aufmacher
|
|
// seine Höhe aus der Breite aus — ein Viertel breiter als hoch.
|
|
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : layout.heroHeight, extra: overlap))
|
|
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
|
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
|
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
|
// greift, blieb dann rechts genau so viel schwarz übrig, wie er
|
|
// links gewonnen hatte — auf dem Mac ein Streifen von gut zweihundert
|
|
// Punkten.
|
|
// **Nicht** beschneiden: der Aufmacher greift bewusst nach links
|
|
// über seinen Bereich hinaus, damit sein Bild unter der
|
|
// Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
|
|
// und übrig blieb ein schwarzer Streifen neben der Leiste.
|
|
//
|
|
// Für den Platz ist es auch nicht nötig: das Hereinrutschen läuft
|
|
// über `offset`, und das verschiebt nur, was gezeichnet wird.
|
|
// Wischen blättert weiter, jede Berührung hält das Wechseln an —
|
|
// auch die, die nur ein Antippen wird. Gleichzeitig, damit weder
|
|
// dem Wischen noch den Knöpfen darunter etwas weggenommen wird.
|
|
// Wischen blättert weiter — aber erst ab einem ordentlichen Weg
|
|
// und nur, wenn es waagerecht gemeint ist.
|
|
//
|
|
// Vorher stand hier `minimumDistance: 0`. Damit griff die Geste
|
|
// sofort und nahm der Seite das senkrechte Scrollen weg: wer auf
|
|
// dem Aufmacher nach oben wischte, bei dem passierte einfach
|
|
// nichts. Vierundzwanzig Punkte lassen dem Scrollen den Vortritt.
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 24)
|
|
.onEnded { value in
|
|
let width = value.translation.width
|
|
guard abs(width) > abs(value.translation.height), abs(width) > 40 else { return }
|
|
isAdvancing = false
|
|
step(by: width < 0 ? 1 : -1)
|
|
}
|
|
)
|
|
// Das Weiterwechseln hält an, sobald jemand etwas antippt. Getrennt
|
|
// von der Wischgeste, weil ein Tippen kein Ziehen ist — und weil
|
|
// eine Geste, die schon bei null Punkten greift, das Scrollen
|
|
// schluckt.
|
|
.simultaneousGesture(TapGesture().onEnded { isAdvancing = false })
|
|
// **Nur auf dem Mac.** Dort gibt es einen Zeiger, und Wischen ist
|
|
// mit dem Trackpad zwar möglich, aber nicht naheliegend — man
|
|
// sieht dem Aufmacher nicht an, dass er weitergeht. Auf dem iPad
|
|
// und dem iPhone wischt man ohnehin, und zwei Knöpfe ständen nur
|
|
// im Bild.
|
|
.overlay { if isMac, items.count > 1 { arrows } }
|
|
|
|
if items.count > 1 { indicator }
|
|
}
|
|
// Zieht den ganzen Aufmacher unter die Insel. Zusammen mit der
|
|
// zusätzlichen Höhe oben hebt sich das unten auf: er beginnt am
|
|
// Bildschirmrand und endet, wo er vorher endete.
|
|
.padding(.top, -overlap)
|
|
.task(id: shownID) { await advance() }
|
|
.onChange(of: items.map(\.id), initial: true) { _, ids in
|
|
// Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
|
|
// gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn.
|
|
if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first }
|
|
}
|
|
}
|
|
|
|
private var isMac: Bool { FlixyPlatform.isMac }
|
|
|
|
/// Die beiden Pfeile links und rechts.
|
|
///
|
|
/// Zurückhaltend: sie sollen im Bild liegen, ohne es zu beherrschen.
|
|
/// Deshalb erscheinen sie erst, wenn der Zeiger über dem Aufmacher ist —
|
|
/// vorher gibt es nichts zu bedienen, und ein Knopf, der dauerhaft über
|
|
/// einem Filmplakat klebt, ist ein Fremdkörper.
|
|
private var arrows: some View {
|
|
HStack {
|
|
arrow(symbol: "chevron.left", direction: -1)
|
|
Spacer()
|
|
arrow(symbol: "chevron.right", direction: 1)
|
|
}
|
|
.padding(.horizontal, FlixySpacing.regular)
|
|
.opacity(isPointerInside ? 1 : 0)
|
|
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
|
.onContinuousHover { phase in
|
|
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
|
}
|
|
}
|
|
|
|
private func arrow(symbol: String, direction: Int) -> some View {
|
|
Button {
|
|
isAdvancing = false
|
|
step(by: direction)
|
|
} label: {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(FlixySpacing.regular)
|
|
.background(.black.opacity(0.45), in: .circle)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(direction > 0 ? "hero.next" : "hero.previous"))
|
|
}
|
|
|
|
/// Die Punkte darunter — auf dem Mac auch ein Bedienelement.
|
|
///
|
|
/// **Warum sie wachsen.** Sechs Punkte hoch trifft niemand, und drei
|
|
/// Punkte breit schon gar nicht. Unter dem Zeiger werden sie deshalb
|
|
/// grösser, sobald er in ihrer Nähe ist — dieselbe Bewegung, mit der das
|
|
/// iPad seine Fensterknöpfe unter dem Finger vergrössert. Dass etwas
|
|
/// wächst, wenn man sich nähert, heisst: hier kann man etwas tun.
|
|
///
|
|
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
|
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
|
private var indicator: some View {
|
|
HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) {
|
|
ForEach(items.indices, id: \.self) { position in
|
|
Capsule()
|
|
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
|
|
.frame(
|
|
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
|
height: isEnlarged ? 12 : 6
|
|
)
|
|
.contentShape(.rect)
|
|
.onTapGesture {
|
|
guard isMac else { return }
|
|
isAdvancing = false
|
|
jump(to: position)
|
|
}
|
|
}
|
|
}
|
|
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
|
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
.contentShape(.rect)
|
|
.onContinuousHover { phase in
|
|
guard isMac else { return }
|
|
if case .active = phase { isHoveringDots = true } else { isHoveringDots = false }
|
|
}
|
|
.animation(.snappy(duration: 0.25), value: currentIndex)
|
|
.animation(.snappy(duration: 0.2), value: isEnlarged)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
private var isEnlarged: Bool { isMac && isHoveringDots }
|
|
|
|
/// Springt unmittelbar zu einem Titel.
|
|
private func jump(to position: Int) {
|
|
guard position != currentIndex, items.indices.contains(position) else { return }
|
|
isMovingForward = position > currentIndex
|
|
fadingID = shownID
|
|
shownID = items[position].id
|
|
cover = 0
|
|
|
|
withAnimation(.easeInOut(duration: 0.55)) {
|
|
cover = 1
|
|
} completion: {
|
|
fadingID = nil
|
|
}
|
|
}
|
|
|
|
/// Einen Titel weiter oder zurück.
|
|
private func step(by direction: Int) {
|
|
guard items.count > 1 else { return }
|
|
let next = (currentIndex + direction + items.count) % items.count
|
|
|
|
isMovingForward = direction > 0
|
|
fadingID = shownID
|
|
shownID = items[next].id
|
|
cover = 0
|
|
|
|
withAnimation(.easeInOut(duration: 0.55)) {
|
|
cover = 1
|
|
} completion: {
|
|
// Erst wenn oben nichts mehr durchscheint, darf unten etwas
|
|
// verschwinden. Andersherum gäbe es genau den Moment, in dem man
|
|
// durch beide hindurch auf den Untergrund sieht.
|
|
fadingID = nil
|
|
}
|
|
}
|
|
|
|
private func advance() async {
|
|
guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return }
|
|
|
|
// Das nächste Bild schon holen, solange das aktuelle noch steht. Ohne
|
|
// das blendet der Aufmacher auf eine graue Fläche über und das Bild
|
|
// erscheint erst danach — beim Blättern fiel das nicht auf, beim
|
|
// Überblenden sofort.
|
|
prefetchNeighbours()
|
|
|
|
try? await Task.sleep(for: Self.dwell)
|
|
guard !Task.isCancelled, isAdvancing else { return }
|
|
step(by: 1)
|
|
}
|
|
|
|
private func prefetchNeighbours() {
|
|
guard let backend = session.backend, !items.isEmpty else { return }
|
|
let item = items[(currentIndex + 1) % items.count]
|
|
let compact = sizeClass == .compact
|
|
|
|
// Hintergrundbild und Schriftzug zusammen: fehlt der Schriftzug beim
|
|
// Überblenden, steht dort erst der gesetzte Titel und das Logo springt
|
|
// eine Sekunde später hinterher — zwei Bewegungen, wo eine sein soll.
|
|
let urls = [
|
|
HeroArtwork.backdropURL(for: item, backend: backend, compact: compact),
|
|
HeroArtwork.logoURL(for: item, backend: backend),
|
|
]
|
|
|
|
for url in urls.compactMap(\.self) {
|
|
Task { _ = await ArtworkLoader.shared.image(for: url) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gibt dem Aufmacher einen Anteil an der sichtbaren Höhe — oder gar nichts.
|
|
///
|
|
/// Eigener Baustein, weil `containerRelativeFrame` sich nicht abschalten lässt:
|
|
/// es wirkt, sobald es dasteht. Ein `if` in der Ansicht brächte den beiden
|
|
/// Zweigen außerdem verschiedene Identitäten ein, und der Aufmacher würde beim
|
|
/// Drehen neu geladen statt mitzuwandern.
|
|
private struct HeroHeight: ViewModifier {
|
|
let fraction: CGFloat?
|
|
/// Was über den Anteil hinaus dazukommt: der Platz unter der Insel, den
|
|
/// der Aufmacher mitfüllt, weil er nach oben unter sie greift.
|
|
let extra: CGFloat
|
|
|
|
func body(content: Content) -> some View {
|
|
if let fraction {
|
|
content.containerRelativeFrame(.vertical) { height, _ in height * fraction + extra }
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|