Files
Flaro/Flixy/Views/Home/HeroCarousel.swift
T
ScarriffleandClaude Opus 5 873b8a4405 Volle Höhe, eine Kante statt eines Streifens
Der Aufmacher nimmt jetzt achtundachtzig Prozent der sichtbaren Höhe, statt
sich seine Höhe aus 16:9 auszurechnen. Ein Aufmacher, der ein Drittel hoch ist,
sieht aus wie ein breites Vorschaubild; die zwölf Prozent Rest sind Absicht,
dort lugt die erste Reihe herein und sagt, dass es weitergeht. Auf schmalen
Geräten bleibt es beim Hochformat.

`FlixyArtwork` kann dafür den Rahmen ausfüllen, den es bekommt. Das
Seitenverhältnis bleibt trotzdem wichtig — danach werden die Bilder beim Server
angefordert.

Und `glassEffect` ist raus. Er zeichnet an seiner Kante einen Lichtsaum, und
über einem hellen Aufmacher wurde daraus ein heller Balken von mehreren Punkten
Breite neben der Leiste — kein Rand, sondern ein Streifen. Von einer Kante war
gleichzeitig nichts zu erkennen.

Von Hand sind es drei Lagen: ein trübes Material, ein Schleier gegen die
durchziehende Startseite, und ein Haarstrich, der oben links aufhellt und unten
rechts nachlässt. Dazu ein Schatten — was schwebt, wirft einen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:12:42 +02:00

215 lines
9.4 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
@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. Die zwölf Prozent, die übrig bleiben,
// sind Absicht — dort lugt die erste Reihe herein und sagt, 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.88))
// **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.
.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, !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
}
}
}