`AsyncImage` bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es erscheint. Beim Überblenden des Aufmachers blendete sich der Schriftzug des Films deshalb getrennt und später ein, während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren — zwei Bewegungen, wo eine sein soll. Jetzt lädt er über denselben Zwischenspeicher wie alle anderen Bilder, und das Karussell holt ihn zusammen mit dem Hintergrundbild vor. Beim Erscheinen liegt er bereit und macht mit, was der Aufmacher macht. Dazu ein kleines Hilfsprogramm, das die Fensterkennung eines laufenden Programms ausgibt, damit `screencapture -l` genau dessen Fenster fotografiert und sonst nichts vom Bildschirm. Das System-Python bringt PyObjC nicht mehr mit, und ohne Kennung gibt es keinen Weg dorthin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.7 KiB
Swift
156 lines
6.7 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 blendet dabei
|
|
/// über das alte. Nichts wandert weit genug, als dass ein Versatz auffiele.
|
|
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?
|
|
/// 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
|
|
|
|
/// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm
|
|
/// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand.
|
|
@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 {
|
|
if items.indices.contains(currentIndex) {
|
|
HeroSection(item: items[currentIndex])
|
|
.id(items[currentIndex].id)
|
|
.transition(.asymmetric(
|
|
insertion: .opacity.combined(
|
|
with: .offset(x: isMovingForward ? Self.slide : -Self.slide)
|
|
),
|
|
removal: .opacity.combined(
|
|
with: .offset(x: 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()
|
|
.animation(.easeInOut(duration: 0.55), value: shownID)
|
|
// 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
|
|
shownID = items[next].id
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|