Jeder Aufruf einer Mediathek war ein Serverabruf mit Ladekreis davor. Die Plakate lagen längst auf der Platte; was fehlte, war die Liste, die sagt, welche davon hierhergehören. Jetzt liegt die erste Seite als Abzug daneben: beim Aufrufen steht sie sofort da, der Server wird still nachgefragt, und man sieht in aller Regel nichts davon. Antwortet er nicht, bleibt der Abzug stehen — eine Liste von gestern ist im Zug brauchbarer als ein Fehlerbildschirm. Bewusst nur die erste Seite. Stünden 240 gespeicherte Titel da und der Server antwortet mit 60, müsste entweder die Liste vor den Augen schrumpfen oder der Versatz fürs Nachladen wäre falsch. Gespeichert wird ausserdem nur, was eine Kachel zeigt. Ein Titel aus einer Listenabfrage bringt jede Spur jeder Datei mit — im Raster steht davon nichts, und ausgepackt werden müsste es bei jedem Aufrufen, also genau da, wo der Abzug Zeit sparen soll. Die Mediathekenliste selbst wird nur als Rettungsanker gemerkt, nicht als Beschleuniger: sie sofort zu zeigen hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der Token noch gilt. Abmelden räumt alles weg. Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
16 KiB
Swift
407 lines
16 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 Nachladen 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) {
|
|
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
|
// `reload()` die Titel der vorigen für die eigenen und fragt den
|
|
// Speicher gar nicht erst.
|
|
items = []
|
|
page = nil
|
|
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) }
|
|
}
|
|
|
|
// Was noch kommt, steht schon da — in der Form, die es haben
|
|
// wird. Das beantwortet die Frage, die ein aufhörendes Raster
|
|
// offen lässt: war das alles?
|
|
ForEach(0..<pendingCount, id: \.self) { position in
|
|
PlaceholderCard(index: position)
|
|
}
|
|
}
|
|
.padding(FlixySpacing.screenMargin)
|
|
|
|
moreButton.padding(.bottom, FlixySpacing.section)
|
|
footerCount.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 pendingCount > 0 {
|
|
ProgressView()
|
|
.padding(.vertical, FlixySpacing.section)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
moreButton.padding(.vertical, FlixySpacing.section)
|
|
footerCount.padding(.bottom, FlixySpacing.section)
|
|
}
|
|
.flixyRevealsTopChrome()
|
|
}
|
|
|
|
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
|
///
|
|
/// Beide Server nennen die Gesamtzahl: Jellyfin als `TotalRecordCount`,
|
|
/// Plex als `totalSize`. Damit ist die Frage „war das alles?" beantwortbar,
|
|
/// statt dass man am Ende des Rasters rät.
|
|
///
|
|
/// Gedeckelt, weil zweihundert Platzhalter niemandem helfen: ein Dutzend
|
|
/// sagt „es geht weiter" genauso gut und hält die Liste flüssig.
|
|
private var pendingCount: Int {
|
|
guard !loadMoreFailed, let page, page.hasMore else { return 0 }
|
|
return min(page.totalCount - items.count, 12)
|
|
}
|
|
|
|
/// Die Zeile am Ende, wenn wirklich alles da ist.
|
|
///
|
|
/// Ohne sie bleibt der Zweifel: ein Raster, das aufhört, sieht genauso
|
|
/// aus wie eines, das noch lädt.
|
|
@ViewBuilder
|
|
private var footerCount: some View {
|
|
if let page, !page.hasMore, !items.isEmpty {
|
|
Text("library.allLoaded \(items.count)")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
/// Der Weg weiter, wenn das Nachladen 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
|
|
|
|
/// Lädt die erste Seite — erst aus dem Speicher, dann vom Server.
|
|
///
|
|
/// **Der gespeicherte Stand zuerst, ohne Ladekreis.** Die Plakate liegen
|
|
/// ohnehin schon auf der Platte; was fehlte, war die Liste, die sagt,
|
|
/// welche davon hierhergehören. Mit ihr steht die Mediathek sofort da.
|
|
///
|
|
/// **Dann der Server, stillschweigend.** Kein zweiter Ladekreis über einer
|
|
/// Liste, die schon zu sehen ist — die Titel werden ersetzt, sobald die
|
|
/// Antwort da ist, und in aller Regel sieht man davon nichts, weil sich
|
|
/// nichts geändert hat.
|
|
///
|
|
/// **Und wenn der Server nicht antwortet**, bleibt der gespeicherte Stand
|
|
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
|
/// Fehlerbildschirm.
|
|
private func reload() async {
|
|
guard let server = session.backend else { return }
|
|
let schlüssel = query.cacheKey
|
|
let kennung = server.identity
|
|
|
|
// Nur wenn nichts dasteht. Beim Herunterziehen und beim Filterwechsel
|
|
// ist das Gezeigte aktueller als jeder Abzug.
|
|
if items.isEmpty,
|
|
let stand = await LibraryCache.shared.snapshot(
|
|
server: kennung, library: library.id, queryKey: schlüssel
|
|
) {
|
|
items = stand.items
|
|
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
|
|
state = .loaded(())
|
|
} else if items.isEmpty {
|
|
state = .loading
|
|
}
|
|
|
|
do {
|
|
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
|
|
items = first.items
|
|
page = first
|
|
loadMoreFailed = false
|
|
state = .loaded(())
|
|
|
|
await LibraryCache.shared.store(
|
|
LibraryCache.Snapshot(
|
|
queryKey: schlüssel, items: first.items, totalCount: first.totalCount
|
|
),
|
|
server: kennung,
|
|
library: library.id
|
|
)
|
|
} catch {
|
|
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
|
|
// sich als Übertragungsfehler, nicht als Abbruch.
|
|
if Task.isCancelled { return }
|
|
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
|
|
// bleibt es. Den Fehler über eine brauchbare Liste zu legen
|
|
// nähme dem Nutzer etwas weg, statt ihm etwas zu sagen.
|
|
guard items.isEmpty else { 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 das Nachgeladene 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 Fehler-Sheet 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)
|
|
}
|
|
}
|