Files
Flaro/Flixy/Views/Library/LibraryView.swift
T
ScarriffleandClaude Opus 5 348ec96282 Eine misslungene Seite beendete das Nachladen für immer
Der Grund für „es sind wieder nicht alle Filme drin": im Fehlerfall stand
hier `page = nil`. Eine einzige misslungene Anfrage auf einer wackeligen
Leitung, und die Mediathek blieb endgültig bei sechzig Titeln stehen —
lautlos, ohne dass irgendetwas darauf hindeutete. Im Zug passiert das
zuverlässig.

Der Kommentar daneben begründete das Schweigen, und das war auch richtig:
ein Fehlerblatt mitten im Scrollen wäre störend. Nur hat er das
eigentliche Problem verdeckt — nicht die fehlende Meldung war schlimm,
sondern dass es danach keinen Weg zurück gab.

Jetzt bleibt die Seite erhalten, und unten erscheint eine Zeile zum
Weiterladen. Von selbst wird nach einem Fehlschlag **nicht** mehr
nachgefasst: sonst liefe bei jedem Scrollen eine Anfrage in dieselbe tote
Leitung.

Dazu der Knopf zum Neuladen in der Leiste, wie gewünscht. Ziehen von oben
gibt es auf dem Mac nicht, und in einem Raster ist es ohnehin kaum zu
treffen — wer den Verdacht hat, dass etwas fehlt, braucht einen Knopf
und keine Geste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:40:53 +02:00

320 lines
12 KiB
Swift

import SwiftUI
import FlixyDesign
import MediaKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
let library: MediaLibrary
@Environment(FlixySession.self) private var session
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var items: [MediaItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = MediaQuery()
@State private var page: MediaPage?
/// Ob der letzte Nachschub misslungen ist.
///
/// Dann steht unten eine Zeile zum Weiterversuchen: von selbst passiert
/// nichts mehr, weil die Kacheln, die das Nachladen auslösen, längst
/// erschienen sind und kein zweites Mal erscheinen.
@State private var loadMoreFailed = false
@State private var isLoadingMore = false
@State private var showsFilters = false
var body: some View {
Group {
switch state {
case .loading where items.isEmpty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message) where items.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await reload() } }
}
default:
if items.isEmpty {
ContentUnavailableView {
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
Button("library.clearFilters") {
query = MediaQuery(sort: query.sort)
}
}
}
} else {
contents
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(library.title)
.flixyInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind)
}
.task(id: library.id) { await reload() }
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.refreshable { await reload() }
}
@ViewBuilder
private var contents: some View {
switch layout {
case .grid: grid
case .list: list
}
}
private var grid: some View {
ScrollView {
LazyVGrid(
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlixySpacing.regular)],
spacing: FlixySpacing.loose
) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress
)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
}
}
.padding(FlixySpacing.screenMargin)
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
moreButton.padding(.bottom, FlixySpacing.section)
}
.flixyRevealsTopChrome()
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
LibraryListRow(item: item)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.flixySeparator)
}
}
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
moreButton.padding(.vertical, FlixySpacing.section)
}
.flixyRevealsTopChrome()
}
/// Der Weg weiter, wenn der Nachschub hängen geblieben ist.
@ViewBuilder
private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore {
VStack(spacing: FlixySpacing.snug) {
Text("library.loadMoreFailed")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
Button("library.loadMore") {
Task { await loadMore() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag
// in der Leiste unten, weil deren fünf Plätze für die Mediatheken
// gebraucht werden. Auf dem iPad steht sie in der Seitenleiste, und
// ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
.accessibilityLabel(Text("library.search"))
}
}
// **Neu laden.** Auf dem Mac und dem iPad gibt es kein Ziehen von
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
// Knopf und keine Geste.
ToolbarItem(placement: .flixyTrailing) {
Button {
Task { await reload() }
} label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel(Text("library.reload"))
.disabled(isLoadingMore)
}
ToolbarItem(placement: .flixyTrailing) {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("library.filters"))
}
ToolbarItem(placement: .flixyTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
}
.accessibilityLabel(Text("library.toggleLayout"))
}
}
// MARK: - Laden
private func reload() async {
guard let server = session.backend else { return }
state = .loading
do {
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
items = first.items
page = first
state = .loaded(())
} catch {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return }
state = .failed(message: ErrorText.describe(error))
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.id == item.id }),
// Nach einem Fehlschlag **nicht** von selbst: sonst läuft bei
// jedem Scrollen eine Anfrage in dieselbe tote Leitung. Von dort
// übernimmt der Knopf.
!loadMoreFailed
else { return }
await loadMore()
}
/// Holt die nächste Seite — von selbst oder auf Knopfdruck.
private func loadMore() async {
guard let server = session.backend,
let page, page.hasMore,
!isLoadingMore
else { return }
isLoadingMore = true
loadMoreFailed = false
defer { isLoadingMore = false }
do {
let next = try await server.items(
inLibrary: library,
query: query,
offset: page.nextOffset,
limit: 60
)
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
// und SwiftUI beschwert sich über doppelte Kennungen.
let known = Set(items.map(\.id))
items.append(contentsOf: next.items.filter { !known.contains($0.id) })
self.page = next
} catch {
// **Nicht `page = nil`.** Das stand hier, und es beendete das
// Nachladen **endgültig**: eine einzige misslungene Anfrage auf
// einer wackeligen Leitung, und die Mediathek blieb für immer bei
// sechzig Titeln stehen — lautlos, ohne dass irgendetwas darauf
// hindeutete. Genau so sieht „es sind nicht alle Filme drin" aus.
//
// Die Seite bleibt also, damit ein zweiter Versuch möglich ist.
// Ein Fehlerblatt mitten im Scrollen wäre trotzdem störend —
// stattdessen erscheint unten eine Zeile zum Weiterladen.
loadMoreFailed = true
}
}
}
enum LibraryLayout: String, CaseIterable {
case grid, list
}
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = item.cardSubtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
}