Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,358 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Aufmacher oben auf der Startseite.
|
||||
struct HeroSection: View {
|
||||
let item: MediaItem
|
||||
/// Der Titel, dessen Bild gerade verschwindet — nur im Karussell gesetzt.
|
||||
var outgoing: MediaItem?
|
||||
/// Wie weit das neue Bild schon deckt.
|
||||
var crossfade: CGFloat = 1
|
||||
/// Wie weit es dabei hereinrutscht.
|
||||
var incomingOffset: CGFloat = 0
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: metadata,
|
||||
summary: item.summary,
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
} ?? [],
|
||||
outgoingArtworkURLs: session.backend.flatMap { backend in
|
||||
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
|
||||
} ?? [],
|
||||
outgoingTitle: outgoing?.rowTitle,
|
||||
outgoingMetadata: outgoing.flatMap(Self.metadata(for:)),
|
||||
outgoingLogoURL: session.backend.flatMap { backend in
|
||||
outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) }
|
||||
},
|
||||
crossfade: crossfade,
|
||||
incomingOffset: incomingOffset,
|
||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||
// Auf dem iPhone 4:3 statt 16:9: hochkant ließ 16:9 dem Text so
|
||||
// wenig Platz, dass Schriftzug und Beschreibung übereinanderlagen.
|
||||
// Beschnitten statt hochkant — das Bild bleibt ein Querbild.
|
||||
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
|
||||
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
|
||||
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
|
||||
// vorschreibt, und darüber und darunter bliebe der halbe
|
||||
// Bildschirm leer.
|
||||
fillsAvailableSpace: sizeClass != .compact
|
||||
) {
|
||||
Button {
|
||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||
} label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||
Label("hero.details", systemImage: "info.circle")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||
private var playLabel: LocalizedStringKey {
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||
}
|
||||
|
||||
private var metadata: String? { Self.metadata(for: item) }
|
||||
|
||||
/// Die Zeile unter dem Titel.
|
||||
///
|
||||
/// `static`, weil sie während eines Wechsels auch für den weichenden
|
||||
/// Titel gebraucht wird — und der ist nicht `item`.
|
||||
static func metadata(for item: MediaItem) -> String? {
|
||||
var pieces: [String] = []
|
||||
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||
}
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||
if let rating = item.contentRating { pieces.append(rating) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
///
|
||||
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
|
||||
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
|
||||
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
||||
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
||||
struct HomeView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@State private var feed = HomeFeed()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch feed.state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
}
|
||||
|
||||
case .loaded:
|
||||
let hubs = feed.rows(for: layout)
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
} description: {
|
||||
Text("home.empty.description")
|
||||
}
|
||||
} else {
|
||||
content(hubs: hubs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
||||
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
||||
// ohnehin mehr.
|
||||
.navigationTitle("")
|
||||
.haloInlineTitle()
|
||||
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
||||
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
||||
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
||||
// Aufmacher.
|
||||
.haloClearToolbarBackground()
|
||||
// Gar keine Navigationsleiste auf der Startseite.
|
||||
//
|
||||
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
||||
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
|
||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
||||
.refreshable { await feed.load(from: session.backend) }
|
||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||
.environment(\.haloItemChanged, { await feed.load(from: session.backend) })
|
||||
}
|
||||
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(row: hub)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let row: MediaRowGroup
|
||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||
var opensDetails: Bool = false
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
|
||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||
private var landscapeWidth: CGFloat { 220 * layout.cardScale }
|
||||
/// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate
|
||||
/// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck.
|
||||
///
|
||||
/// Beide mit demselben Faktor: ihr Verhältnis zueinander soll stimmen,
|
||||
/// egal wie groß es insgesamt eingestellt ist.
|
||||
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text(row.title)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: HaloSpacing.regular) {
|
||||
ForEach(row.items) { item in
|
||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
||||
// bleibt an beliebiger Stelle stehen.
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
|
||||
/// Szenenbild beim Weiterschauen, sonst das Plakat.
|
||||
///
|
||||
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat —
|
||||
/// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als
|
||||
/// Nächstes sehen will, sucht nach Plakaten.
|
||||
@ViewBuilder
|
||||
private func card(for item: MediaItem) -> some View {
|
||||
if row.isContinueWatching {
|
||||
LandscapeCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.rowArtworkPath,
|
||||
width: 440,
|
||||
height: 248
|
||||
),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
.frame(width: landscapeWidth)
|
||||
} else {
|
||||
PosterCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.posterArtworkPath,
|
||||
width: 300,
|
||||
height: 450
|
||||
),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
.frame(width: posterWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user