Drei Sachen am Karussell. Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und „Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests, weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann. Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine Adresse antwortet. Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man ohnehin, und zwei Knoepfe staenden nur im Bild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.0 KiB
Swift
178 lines
7.0 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
|
struct HomeView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(HomeLayoutSettings.self) private var layout
|
|
@State private var state: LoadState<[MediaRowGroup]> = .loading
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch 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 load() } }
|
|
}
|
|
|
|
case .loaded(let allRows):
|
|
let hubs = layout.apply(to: prepared(allRows))
|
|
if hubs.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("home.empty.title", systemImage: "film.stack")
|
|
} description: {
|
|
Text("home.empty.description")
|
|
}
|
|
} else {
|
|
content(hubs: hubs)
|
|
}
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
// 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("")
|
|
.flixyInlineTitle()
|
|
// 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.
|
|
.flixyClearToolbarBackground()
|
|
// 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 load() }
|
|
.refreshable { await load() }
|
|
}
|
|
|
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
|
let featured = heroItems(in: hubs)
|
|
if !featured.isEmpty {
|
|
HeroCarousel(items: featured)
|
|
}
|
|
|
|
ForEach(hubs) { hub in
|
|
MediaRow(row: hub)
|
|
}
|
|
}
|
|
.padding(.bottom, FlixySpacing.section)
|
|
}
|
|
.scrollIndicators(.never)
|
|
.flixyRevealsTopChrome()
|
|
}
|
|
|
|
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
|
|
///
|
|
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
|
|
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
|
|
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
|
|
/// Plex und Jellyfin gleichermaßen.
|
|
private func prepared(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
|
var rows = rows
|
|
|
|
if layout.hidesWatchedInLatest {
|
|
rows = rows.map { row in
|
|
guard row.id.hasPrefix("latest") else { return row }
|
|
return MediaRowGroup(
|
|
id: row.id,
|
|
title: row.title,
|
|
moreKey: row.moreKey,
|
|
items: row.items.filter { !$0.isWatched }
|
|
)
|
|
}
|
|
}
|
|
|
|
if layout.combinesContinueRows {
|
|
rows = combiningContinueRows(rows)
|
|
}
|
|
|
|
return rows.filter { !$0.items.isEmpty }
|
|
}
|
|
|
|
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
|
|
///
|
|
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
|
|
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
|
|
/// wieder ganz unten.
|
|
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
|
let continuing = rows.filter(\.isContinueWatching)
|
|
guard continuing.count > 1, let first = continuing.first else { return rows }
|
|
|
|
var seen = Set<String>()
|
|
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
|
|
|
|
var result: [MediaRowGroup] = []
|
|
for row in rows {
|
|
guard row.isContinueWatching else {
|
|
result.append(row)
|
|
continue
|
|
}
|
|
guard row.id == first.id else { continue }
|
|
result.append(MediaRowGroup(id: row.id, title: row.title, items: merged))
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Was oben groß gezeigt wird.
|
|
///
|
|
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
|
|
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
|
|
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
|
|
/// Schaufenster versteht, stellt auf Neuzugänge um.
|
|
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
|
|
let candidates: [MediaItem]
|
|
switch layout.heroSource {
|
|
case .continueFirst:
|
|
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
|
|
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
|
|
candidates = continuing + rest
|
|
case .latestOnly:
|
|
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
let unique = candidates.filter { seen.insert($0.id).inserted }
|
|
|
|
// **Gemischt, aber nicht bei jedem Blick.**
|
|
//
|
|
// Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
|
|
// Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
|
|
// hätte: das Los hing an der **Position** in der Liste, nicht am
|
|
// Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
|
|
// alle dahinter ein anderes Los — und der Aufmacher war nach jedem
|
|
// Start ein anderer, obwohl das Zeitfenster dasselbe war.
|
|
switch layout.heroSource {
|
|
case .continueFirst:
|
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
|
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
|
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
|
return Array(ordered.prefix(layout.heroCount))
|
|
case .latestOnly:
|
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
guard let backend = session.backend else { return }
|
|
state = await LoadState.load(keepingPrevious: state) {
|
|
try await backend.homeRows()
|
|
}
|
|
}
|
|
}
|