import SwiftUI /// Films / Series-bibliotheek — grid zoals Android Browse, met iOS-chips + letter-index. struct BrowseScreen: View { @EnvironmentObject private var app: AppModel let kind: String // "MOVIE" or "SERIES" let title: String @State private var shelfPrefs: [ShelfPref] = [] @State private var catalogId: String = "" @State private var sort: SortMode = .title @State private var activeLetter: String? @State private var pageTitle: String = "" @State private var items: [MediaItem] = [] @State private var total = 0 @State private var listOffset = 0 @State private var error: String? @State private var loading = true @State private var loadingMore = false private enum SortMode: String { case title case recent } private var defaultCatalog: String { kind == "MOVIE" ? "movies" : "series" } private var catalogChips: [(label: String, id: String)] { var list: [(String, String)] = [] var seen = Set() func append(_ label: String, _ id: String) { guard !seen.contains(id) else { return } seen.insert(id) list.append((label, id)) } if kind == "MOVIE" { append("Alles", "movies") append("Recent", "recent-movies") append("4K", "4k-movies") } else { append("Alles", "series") append("Recent", "recent-series") } for pref in shelfPrefs where pref.visible { let k = (pref.kind ?? "").uppercased() let match = kind == "MOVIE" ? (k == "MOVIE" || k.contains("MOVIE")) : (k == "SERIES" || k.contains("SERIES")) if match { let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId append(pref.name, slug) } } return list } /// A–Z tools alleen zinvol op titel-gebaseerde collecties (niet op “Recent”-catalogi). private var showSortTools: Bool { switch catalogId { case "recent-movies", "recent-series": return false default: return true } } private var showLetterIndex: Bool { showSortTools && sort == .title && !items.isEmpty } private let letters = ["#"] + (65...90).map { String(UnicodeScalar($0)!) } private let cols = [GridItem(.adaptive(minimum: 108), spacing: 10)] var body: some View { VStack(spacing: 0) { catalogBar headerBar HStack(alignment: .top, spacing: 0) { gridContent .frame(maxWidth: .infinity, maxHeight: .infinity) if showLetterIndex { letterIndex } } } .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: title.uppercased()) } } .toolbarBackground(McColors.bg, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .task { if catalogId.isEmpty { catalogId = defaultCatalog } await bootstrap() } .refreshable { await reload(reset: true) } .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } } private var catalogBar: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(catalogChips, id: \.id) { chip in let selected = catalogId == chip.id Button { guard catalogId != chip.id else { return } catalogId = chip.id activeLetter = nil if chip.id == "recent-movies" || chip.id == "recent-series" || chip.id == "4k-movies" { sort = .recent } else { sort = .title } Task { await reload(reset: true) } } label: { Text(chip.label) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(selected ? .white : McColors.text) .padding(.horizontal, 14) .padding(.vertical, 8) .background( selected ? McColors.accent : McColors.surfaceSoft, in: Capsule() ) .overlay( Capsule() .strokeBorder( selected ? Color.clear : McColors.textDim.opacity(0.45), lineWidth: 1 ) ) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 8) } .frame(minHeight: 52) } private var headerBar: some View { HStack(spacing: 10) { Text(pageTitle.isEmpty ? title : pageTitle) .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundStyle(McColors.text) .lineLimit(1) Text(total > 0 ? "\(total)" : (loading ? "…" : "0")) .font(.system(size: 16, weight: .medium, design: .rounded)) .foregroundStyle(McColors.textDim) Spacer(minLength: 8) if showSortTools { HStack(spacing: 0) { sortChip("A–Z", selected: sort == .title) { guard sort != .title else { return } sort = .title Task { await reload(reset: true) } } sortChip("Recent", selected: sort == .recent) { guard sort != .recent else { return } sort = .recent activeLetter = nil Task { await reload(reset: true) } } } .background(McColors.surface, in: Capsule()) } } .padding(.horizontal, 16) .padding(.bottom, 8) } private func sortChip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(selected ? .black : McColors.text) .padding(.horizontal, 14) .padding(.vertical, 8) .background(selected ? Color.white : Color.clear, in: Capsule()) } .buttonStyle(.plain) } @ViewBuilder private var gridContent: some View { if loading && items.isEmpty { ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error, items.isEmpty { VStack(spacing: 12) { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) Button("Opnieuw") { Task { await reload(reset: true) } } .buttonStyle(McPrimaryButtonStyle()) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !loading && items.isEmpty { ContentUnavailableView( "Geen titels", systemImage: "square.stack.3d.up.slash", description: Text("Deze collectie is leeg of nog niet gesynchroniseerd.") ) .foregroundStyle(McColors.textDim) } else { ScrollView { LazyVGrid(columns: cols, spacing: 12) { ForEach(items) { item in NavigationLink(value: item) { PosterCard(item: item, width: 108, showTitle: true) } .buttonStyle(.plain) .onAppear { if item.id == items.last?.id { Task { await loadMore() } } } } } .padding(.horizontal, 14) .padding(.bottom, 28) if loadingMore { ProgressView().tint(.white).padding(.bottom, 16) } } } } private var letterIndex: some View { GeometryReader { geo in VStack(spacing: 0) { ForEach(letters, id: \.self) { letter in Text(letter) .font(.system(size: 11, weight: activeLetter == letter ? .bold : .semibold, design: .rounded)) .foregroundStyle(activeLetter == letter ? McColors.accent : McColors.textDim) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(width: 22, height: geo.size.height) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let h = max(geo.size.height, 1) let idx = Int((value.location.y / h) * CGFloat(letters.count)) let clamped = min(max(idx, 0), letters.count - 1) let letter = letters[clamped] if activeLetter != letter { UISelectionFeedbackGenerator().selectionChanged() Task { await jump(to: letter) } } } ) } .frame(width: 22) .padding(.vertical, 4) .padding(.trailing, 2) } private func bootstrap() async { shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] await reload(reset: true) } private func reload(reset: Bool) async { if reset { loading = true error = nil } let id = catalogId.isEmpty ? defaultCatalog : catalogId let sortParam: String? = showSortTools ? sort.rawValue : nil do { let page = try await app.api.browse( catalogId: id, skip: 0, take: 60, sort: sortParam, letter: sort == .title ? activeLetter : nil ) pageTitle = page.title?.isEmpty == false ? page.title! : title total = page.total ?? page.items.count listOffset = page.offset ?? 0 items = page.items loading = false } catch { self.error = error.localizedDescription loading = false } } private func jump(to letter: String) async { activeLetter = letter sort = .title await reload(reset: true) } private func loadMore() async { guard !loadingMore, items.count + listOffset < total else { return } loadingMore = true let id = catalogId.isEmpty ? defaultCatalog : catalogId let sortParam: String? = showSortTools ? sort.rawValue : nil do { let page = try await app.api.browse( catalogId: id, skip: listOffset + items.count, take: 60, sort: sortParam, letter: nil ) let existing = Set(items.map(\.id)) items.append(contentsOf: page.items.filter { !existing.contains($0.id) }) loadingMore = false } catch { loadingMore = false } } }