Auf dem iPad beschneidet der TabView seinen Inhalt, auf dem Mac nicht. Gemessen: unter der Leiste war dort gar kein Bild — die Fläche blieb gleichmäßig dunkel, während der Aufmacher daneben bei 60 lag. Der negative Rand, mit dem der Aufmacher unter die Leiste greifen sollte, wurde schlicht abgeschnitten, und übrig blieb der schwarze Spalt dazwischen. Also weg damit. Die Leiste steht in einem HStack neben dem Inhalt: kein negativer Rand, keine Maske, kein Sicherheitsrand je Bildschirm, keine Sonderfälle für geschobene Bildschirme. Die Leiste hat ihren Abstand zu drei Seiten und zum Inhalt keinen — dort schließt er unmittelbar an. Die Startseite hat keine Navigationsleiste mehr. Sie war leer und belegte trotzdem ihre Höhe; der Aufmacher fing dadurch tiefer an als die Leiste daneben, und genau diese Kante sah man. Auf den geschobenen Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg. Und Wischen auf dem Aufmacher schluckt das Scrollen nicht mehr. `minimumDistance: 0` ließ die Geste sofort greifen — wer nach oben wischte, bei dem passierte nichts. Jetzt vierundzwanzig Punkte und nur, wenn es waagerecht gemeint ist; das Anhalten des Wechsels übernimmt eine eigene Tippgeste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.3 KiB
Swift
164 lines
6.3 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)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
}
|