Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index. Co-authored-by: Cursor <cursoragent@cursor.com>
322 lines
12 KiB
Swift
322 lines
12 KiB
Swift
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<String>()
|
||
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
|
||
}
|
||
}
|
||
}
|