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 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.flixyAccent) private var accent @Environment(FlixySession.self) private var session @Environment(\.horizontalSizeClass) private var sizeClass 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)) } } // 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 ist der Aufmacher hochkant // und rechnet sich seine Höhe aus der Breite aus. .modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.82)) // **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 }) if items.count > 1 { indicator } } .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 indicator: some View { HStack(spacing: FlixySpacing.snug) { ForEach(items.indices, id: \.self) { position in Capsule() .fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4)) .frame(width: position == currentIndex ? 18 : 6, height: 6) } } .animation(.snappy(duration: 0.25), value: currentIndex) .accessibilityHidden(true) } /// 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? func body(content: Content) -> some View { if let fraction { content.containerRelativeFrame(.vertical) { height, _ in height * fraction } } else { content } } }