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>
320 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|