import SwiftUI import FlixyDesign import PlexKit /// Eine Mediathek zum Durchblättern. struct LibraryView: View { let library: PlexLibrary @Environment(FlixySession.self) private var session @AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid @State private var items: [PlexItem] = [] @State private var state: LoadState = .loading @State private var query = LibraryQuery() @State private var page: PlexPage? @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 = LibraryQuery(sort: query.sort) } } } } else { contents } } } .background(Color.flixyCanvas) .navigationTitle(library.title) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .sheet(isPresented: $showsFilters) { LibraryFilterSheet(query: $query, libraryKind: library.kind) } .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .task(id: library.key) { 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), spacing: FlixySpacing.regular)], spacing: FlixySpacing.loose ) { ForEach(items) { item in NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { PosterCard( title: item.title, subtitle: item.cardSubtitle, artworkURL: session.server?.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) } } } private var list: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(items) { item in NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { LibraryListRow(item: item) } .buttonStyle(.plain) .task { await loadMoreIfNeeded(reaching: item) } Divider().overlay(Color.flixySeparator) } } if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { 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: .topBarTrailing) { Button { layout = layout == .grid ? .list : .grid } label: { Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2") } .accessibilityLabel(Text("library.toggleLayout")) } SearchToolbarItem() } // MARK: - Laden private func reload() async { guard let server = session.server else { return } state = .loading do { let first = try await server.items(inLibrary: library, query: query) items = first.items page = first state = .loaded(()) } catch is CancellationError { return } catch { state = .failed(message: PlexErrorText.describe(error)) } } /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. private func loadMoreIfNeeded(reaching item: PlexItem) async { guard let server = session.server, 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.ratingKey == item.ratingKey }) else { return } isLoadingMore = true defer { isLoadingMore = false } do { let next = try await server.items( inLibrary: library, query: query, offset: page.nextOffset ) // 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(\.ratingKey)) items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) }) 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 } } } enum LibraryLayout: String, CaseIterable { case grid, list } /// Eine Zeile in der Listenansicht. private struct LibraryListRow: View { let item: PlexItem @Environment(FlixySession.self) private var session var body: some View { HStack(spacing: FlixySpacing.regular) { FlixyArtwork( url: session.server?.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) } }