Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
359 lines
16 KiB
Swift
359 lines
16 KiB
Swift
import SwiftUI
|
|
import HaloDesign
|
|
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(\.haloAccent) private var accent
|
|
@Environment(HaloSession.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
|
|
}
|
|
|
|
/// Der Aufmacher — ein einziger, der sein Bild wechselt.
|
|
///
|
|
/// **Nicht zwei übereinander.** So war es, und jeder der beiden brachte
|
|
/// seinen eigenen Verlauf nach unten mit; zwei halbdurchsichtige Verläufe
|
|
/// ergeben zusammen keinen ganzen. Gemessen an derselben Stelle im Bild:
|
|
/// im Ruhezustand 68, in der Mitte des Wechsels 137 — sichtbar als
|
|
/// Aufhellen genau dort, wo der Titel steht. Jetzt wechselt nur das Bild,
|
|
/// Verlauf und Text liegen einmal darüber.
|
|
@ViewBuilder
|
|
private var heroSection: some View {
|
|
if items.indices.contains(currentIndex) {
|
|
HeroSection(
|
|
item: items[currentIndex],
|
|
outgoing: fadingID.flatMap { kennung in
|
|
items.first { $0.id == kennung }
|
|
},
|
|
crossfade: cover,
|
|
incomingOffset: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide)
|
|
)
|
|
// Ohne eigene Identität je Titel: die Ansicht **bleibt**, und nur
|
|
// ihre Angaben wechseln. Eine neue Identität hiesse, sie würde
|
|
// eingefügt und entfernt — und SwiftUI blendete sie dabei ein,
|
|
// zusätzlich zur Überblendung, die hier gemeint ist.
|
|
}
|
|
}
|
|
|
|
private var currentIndex: Int {
|
|
items.firstIndex { $0.id == shownID } ?? 0
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: HaloSpacing.regular) {
|
|
heroSection
|
|
// 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 rechnet sich der Aufmacher
|
|
// seine Höhe aus der Breite aus — ein Viertel breiter als hoch.
|
|
.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 { HaloPlatform.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, HaloSpacing.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(HaloSpacing.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 ? HaloSpacing.regular : HaloSpacing.snug) {
|
|
ForEach(items.indices, id: \.self) { position in
|
|
Capsule()
|
|
.fill(position == currentIndex ? accent : Color.haloTextMuted.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, HaloSpacing.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
|
|
}
|
|
}
|
|
}
|