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 = .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, library: library) } .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() } // Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen. .environment(\.flixyItemChanged, { 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 QuickPlayTile(item: item) { PosterCard( title: item.title, subtitle: item.cardSubtitle, artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), progress: item.watchProgress ) } .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.. 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. Die Begründung dafür war lange, die fünf // Plätze würden für die Mediatheken gebraucht — seit die in einer // eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben // ist der bessere Grund: gesucht wird in einer Mediathek, und dort // steht der Knopf. Auf dem iPad steht die Suche 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")) } // **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg // in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr // gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen — // SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der // Reihe etwas steht, kann nur der entscheiden, der die Reihe // schreibt. if sizeClass == .compact { ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() } } } // 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) } }