Der schwarze Streifen über der Navigation war kein gezeichneter Balken, sondern fehlender Inhalt: der gesamte Baum saß innerhalb des Sicherheitsrands. Deshalb erreichte kein noch so gut gesetzter Rand weiter unten die Fläche darüber — Inhalt kam dort nie hin. Jetzt reißt der HStack den Rand oben auf und gibt den Platz kontrolliert zurück: Seitenleiste und Insel bekommen ihren Abstand zur Statusleiste explizit, der Inhalt bekommt ihn als Sicherheitsrand. Der Aufmacher nimmt ihn sich wieder — um genau diese Strecke höher und um genau diese Strecke größer, sodass unten alles bleibt, wo es war. Dazu ein knapper Verlauf oben, damit Uhrzeit und Batterie auch über einem hellen Bild lesbar bleiben. Und die Leiste oben fährt beim Scrollen weg. Nur sie: der Sicherheitsrand darunter bleibt stehen, sonst ruckte die Seite bei jedem Ein- und Ausblenden unter dem Finger weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.4 KiB
Swift
165 lines
6.4 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 }
|
|
|
|
// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
|
// Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei
|
|
// denn, keiner hätte eines.
|
|
let withArtwork = unique.filter { $0.art != nil || $0.seriesArt != nil }
|
|
return Array((withArtwork.isEmpty ? unique : withArtwork).prefix(layout.heroCount))
|
|
}
|
|
|
|
private func load() async {
|
|
guard let backend = session.backend else { return }
|
|
state = await LoadState.load(keepingPrevious: state) {
|
|
try await backend.homeRows()
|
|
}
|
|
}
|
|
}
|