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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+358
View File
@@ -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
}
}
}
+99
View File
@@ -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: " · ")
}
}
+86
View File
@@ -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()
}
}
+81
View File
@@ -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)
}
}
}