import MediaKit import Observation /// Eine Mediathek als Modell: laden, nachladen, entdoppeln. /// /// **Der teuerste Brocken, den man hätte kopieren können.** Hier stecken der /// Abzug aus dem Zwischenspeicher, der Seitenversatz, die Entdoppelung gegen /// Plex-Serverscans und die Sperre nach einem Fehlschlag — und an mindestens /// zwei davon hat schon je ein Fehler geklebt, der sich als „es sind nicht /// alle Filme drin" äusserte und sonst nichts. Ein zweites Mal wären sie in /// einer Kopie zu finden gewesen. /// /// **Was das Nachladen auslöst, steht hier nicht.** Am Telefon ist es das /// Erscheinen einer Kachel, am Fernseher der Fokus. Die Regel selbst — /// `PagingWindow` — ist ein reiner Typ mit Tests. @MainActor @Observable final class LibraryFeed { let library: MediaLibrary private(set) var items: [MediaItem] = [] private(set) var state: LoadState = .loading private(set) var page: MediaPage? /// Ob der letzte Nachladeversuch misslungen ist. /// /// Dann passiert von selbst nichts mehr, und es braucht einen Knopf: die /// Kacheln, die das Nachladen auslösen, sind längst erschienen und /// erscheinen kein zweites Mal. private(set) var loadMoreFailed = false private(set) var isLoadingMore = false var query = MediaQuery() init(library: MediaLibrary) { self.library = library } /// Wie viele Platzhalter noch unter dem Geladenen stehen. var pendingCount: Int { guard let page else { return 0 } return max(0, page.totalCount - items.count) } func reload(from backend: (any MediaBackend)?) async { guard let server = backend else { return } let schlüssel = query.cacheKey let kennung = server.identity // Nur wenn nichts dasteht. Beim Aktualisieren 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 erste = try await server.items( inLibrary: library, query: query, offset: 0, limit: 60 ) items = erste.items page = erste loadMoreFailed = false state = .loaded(()) await LibraryCache.shared.store( LibraryCache.Snapshot( queryKey: schlüssel, items: erste.items, totalCount: erste.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)) } } func loadMore(from backend: (any MediaBackend)?) async { guard let server = backend, let page, !isLoadingMore, items.count < page.totalCount else { return } isLoadingMore = true loadMoreFailed = false defer { isLoadingMore = false } do { let nächste = try await server.items( inLibrary: library, query: query, offset: page.nextOffset, limit: 60 ) // **Anhängen, nie ersetzen.** 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. Und auf dem Fernseher hinge daran noch // mehr: würde `items` als Ganzes ersetzt, verlöre der Fokus mitten // im Scrollen seinen Halt und spränge an den Anfang. let bekannt = Set(items.map(\.id)) items.append(contentsOf: nächste.items.filter { !bekannt.contains($0.id) }) self.page = nächste } catch { // **Nicht `page = nil`.** Das stand hier einmal, 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. Genau so sieht „es sind // nicht alle Filme drin" aus. loadMoreFailed = true } } /// Ob der Eintrag an dieser Stelle nah genug am Ende liegt. /// /// Die Regel steht in `PagingWindow`; hier steht nur, woher die Zahlen /// kommen. **Was das auslöst**, entscheidet die Ansicht. func shouldLoadMore(at index: Int, columns: Int) -> Bool { guard !loadMoreFailed, !isLoadingMore, let page else { return false } return PagingWindow(columns: columns) .shouldLoad(focusedIndex: index, loaded: items.count, total: page.totalCount) } }