SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
7.1 KiB
Swift
193 lines
7.1 KiB
Swift
import SwiftUI
|
|
|
|
struct BrowseScreen: View {
|
|
@EnvironmentObject private var app: AppModel
|
|
let kind: String // "MOVIE" or "SERIES"
|
|
let title: String
|
|
|
|
@State private var home: HomePayload?
|
|
@State private var shelfPrefs: [ShelfPref] = []
|
|
@State private var mode: BrowseMode = .planks
|
|
@State private var items: [MediaItem] = []
|
|
@State private var total = 0
|
|
@State private var error: String?
|
|
@State private var loading = true
|
|
@State private var loadingMore = false
|
|
|
|
private enum BrowseMode: Hashable {
|
|
case planks
|
|
case catalog(String)
|
|
}
|
|
|
|
private var kindShelves: [ShelfRow] {
|
|
guard let home else { return [] }
|
|
return home.shelves.filter { shelf in
|
|
let k = (shelf.kind ?? "").uppercased()
|
|
if kind == "MOVIE" { return k == "MOVIE" || k.contains("MOVIE") }
|
|
return k == "SERIES" || k.contains("SERIES")
|
|
}
|
|
}
|
|
|
|
private var chips: [(label: String, mode: BrowseMode)] {
|
|
var list: [(String, BrowseMode)] = [("Planken", .planks)]
|
|
if kind == "MOVIE" {
|
|
list += [("Alles", .catalog("movies")), ("Recent", .catalog("recent-movies")), ("4K", .catalog("4k-movies"))]
|
|
} else {
|
|
list += [("Alles", .catalog("series")), ("Recent", .catalog("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
|
|
list.append((pref.name, .catalog(slug)))
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in
|
|
Button {
|
|
mode = chip.mode
|
|
Task { await activate(chip.mode) }
|
|
} label: {
|
|
Text(chip.label)
|
|
.font(McFonts.caption)
|
|
.foregroundStyle(mode == chip.mode ? .white : McColors.textDim)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(mode == chip.mode ? McColors.accent : McColors.surface, in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
content
|
|
}
|
|
.background(McColors.bg.ignoresSafeArea())
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
BrandHeader(subtitle: title.uppercased())
|
|
}
|
|
}
|
|
.toolbarBackground(McColors.bg, for: .navigationBar)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
.task { await bootstrap() }
|
|
.refreshable { await bootstrap() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if loading && home == nil && items.isEmpty {
|
|
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error, home == nil && items.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Text(error).foregroundStyle(McColors.danger)
|
|
Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle())
|
|
}
|
|
} else {
|
|
switch mode {
|
|
case .planks:
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
if kindShelves.isEmpty {
|
|
ContentUnavailableView("Geen planken", systemImage: "rectangle.stack", description: Text("Nog geen \(title.lowercased())-planken zichtbaar."))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 40)
|
|
} else {
|
|
ForEach(kindShelves) { shelf in
|
|
if !shelf.items.isEmpty {
|
|
ShelfSection(title: shelf.title, items: shelf.items)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
}
|
|
case .catalog:
|
|
ScrollView {
|
|
LazyVGrid(columns: cols, spacing: 14) {
|
|
ForEach(items) { item in
|
|
NavigationLink(value: item) {
|
|
PosterCard(item: item, width: 104)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onAppear {
|
|
if item.id == items.last?.id { Task { await loadMore() } }
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 24)
|
|
if loadingMore { ProgressView().tint(.white).padding() }
|
|
}
|
|
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func bootstrap() async {
|
|
loading = true
|
|
error = nil
|
|
do {
|
|
async let homeTask = app.api.home()
|
|
async let prefsTask = app.api.getShelfPrefs()
|
|
home = try await homeTask
|
|
shelfPrefs = (try? await prefsTask)?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
|
|
if case .catalog(let id) = mode {
|
|
await loadCatalog(id, reset: true)
|
|
}
|
|
loading = false
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
private func activate(_ mode: BrowseMode) async {
|
|
switch mode {
|
|
case .planks:
|
|
break
|
|
case .catalog(let id):
|
|
await loadCatalog(id, reset: true)
|
|
}
|
|
}
|
|
|
|
private func loadCatalog(_ id: String, reset: Bool) async {
|
|
if reset {
|
|
items = []
|
|
loading = true
|
|
}
|
|
do {
|
|
let page = try await app.api.browse(catalogId: id, skip: 0, take: 60)
|
|
total = page.total ?? page.items.count
|
|
items = page.items
|
|
loading = false
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
private func loadMore() async {
|
|
guard case .catalog(let id) = mode, !loadingMore, items.count < total else { return }
|
|
loadingMore = true
|
|
do {
|
|
let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60)
|
|
items.append(contentsOf: page.items)
|
|
loadingMore = false
|
|
} catch {
|
|
loadingMore = false
|
|
}
|
|
}
|
|
}
|