diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index e296ae3..2d513b7 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2481,6 +2481,57 @@ } } }, + "library.loadMore" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere laden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load more" + } + } + } + }, + "library.loadMoreFailed" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Nachschub ist hängen geblieben — es fehlen noch Titel." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading more stalled — some titles are still missing." + } + } + } + }, + "library.reload" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu laden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reload" + } + } + } + }, "library.scan" : { "localizations" : { "de" : { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index e191caa..a92e241 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -17,6 +17,12 @@ struct LibraryView: View { @State private var state: LoadState = .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 @@ -101,6 +107,7 @@ struct LibraryView: View { .padding(FlixySpacing.screenMargin) if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) } + moreButton.padding(.bottom, FlixySpacing.section) } .flixyRevealsTopChrome() } @@ -120,10 +127,28 @@ struct LibraryView: View { } 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 @@ -139,6 +164,20 @@ struct LibraryView: View { } } + // **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 @@ -182,16 +221,28 @@ struct LibraryView: View { /// 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, - // 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 }) + !isLoadingMore else { return } isLoadingMore = true + loadMoreFailed = false defer { isLoadingMore = false } do { @@ -208,9 +259,16 @@ struct LibraryView: View { items.append(contentsOf: next.items.filter { !known.contains($0.id) }) self.page = next } catch { - // Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten - // im Scrollen wäre störender als ein paar fehlende Einträge. - self.page = nil + // **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 } } }