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 } 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 : 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 } } }