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 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)) } } .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) // Das Bild wechselt, der Platz nicht: ohne das Beschneiden schöbe // das hereinrutschende Bild die Seite um seine Strecke breiter. .clipped() // 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. .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in isAdvancing = false } .onEnded { value in guard abs(value.translation.width) > 50 else { return } step(by: value.translation.width < 0 ? 1 : -1) } ) 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 else { return } let next = (currentIndex + 1) % items.count let item = items[next] // Dieselben Maße wie in `HeroSection` — eine andere Größe wäre eine // andere Adresse und der Zwischenspeicher träfe daneben. let backdrop = backend.artworkURL( path: item.art ?? item.seriesArt ?? item.thumb, width: 900, height: 1200 ) // Der Schriftzug gehört dazu. Fehlt er beim Überblenden, steht dort // erst der gesetzte Titel und das Logo springt eine Sekunde später // hinterher — zwei Bewegungen, wo eine sein soll. let logo = backend.rawImageURL(path: item.clearLogoPath) for url in [backdrop, logo].compactMap(\.self) { Task { _ = await ArtworkLoader.shared.image(for: url) } } } }