Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
237 lines
8.3 KiB
Swift
237 lines
8.3 KiB
Swift
import SwiftUI
|
||
|
||
struct BrowseView: View {
|
||
@EnvironmentObject private var app: AppModel
|
||
let kind: String
|
||
let title: String
|
||
|
||
@State private var shelfPrefs: [ShelfPref] = []
|
||
@State private var catalogId: String = ""
|
||
@State private var sort: SortMode = .title
|
||
@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
|
||
}
|
||
|
||
private var showSortTools: Bool {
|
||
switch catalogId {
|
||
case "recent-movies", "recent-series": return false
|
||
default: return true
|
||
}
|
||
}
|
||
|
||
private let cols = [
|
||
GridItem(.adaptive(minimum: 168, maximum: 200), spacing: 24)
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
catalogBar
|
||
headerBar
|
||
gridContent
|
||
}
|
||
.background(TvColors.bg.ignoresSafeArea())
|
||
.task {
|
||
if catalogId.isEmpty { catalogId = defaultCatalog }
|
||
await bootstrap()
|
||
}
|
||
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
|
||
}
|
||
|
||
private var catalogBar: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 14) {
|
||
ForEach(catalogChips, id: \.id) { chip in
|
||
let selected = catalogId == chip.id
|
||
Button {
|
||
guard catalogId != chip.id else { return }
|
||
catalogId = chip.id
|
||
if chip.id.contains("recent") || chip.id == "4k-movies" {
|
||
sort = .recent
|
||
} else {
|
||
sort = .title
|
||
}
|
||
Task { await reload(reset: true) }
|
||
} label: {
|
||
Text(chip.label)
|
||
.font(TvFonts.caption)
|
||
.foregroundStyle(selected ? .white : TvColors.text)
|
||
.padding(.horizontal, 22)
|
||
.padding(.vertical, 12)
|
||
.background(selected ? TvColors.accent : TvColors.surfaceSoft, in: Capsule())
|
||
}
|
||
.buttonStyle(FocusableCardStyle())
|
||
}
|
||
}
|
||
.padding(.horizontal, 48)
|
||
.padding(.vertical, 16)
|
||
}
|
||
.focusSection()
|
||
}
|
||
|
||
private var headerBar: some View {
|
||
HStack(spacing: 16) {
|
||
Text(pageTitle.isEmpty ? title : pageTitle)
|
||
.font(TvFonts.title)
|
||
.foregroundStyle(TvColors.text)
|
||
Text(total > 0 ? "\(total)" : (loading ? "…" : "0"))
|
||
.font(TvFonts.body)
|
||
.foregroundStyle(TvColors.textDim)
|
||
Spacer()
|
||
if showSortTools {
|
||
HStack(spacing: 10) {
|
||
sortButton("A–Z", selected: sort == .title) {
|
||
sort = .title
|
||
Task { await reload(reset: true) }
|
||
}
|
||
sortButton("Recent", selected: sort == .recent) {
|
||
sort = .recent
|
||
Task { await reload(reset: true) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 48)
|
||
.padding(.bottom, 12)
|
||
}
|
||
|
||
private func sortButton(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(TvFonts.caption)
|
||
.foregroundStyle(selected ? .black : TvColors.text)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 10)
|
||
.background(selected ? Color.white : TvColors.surface, in: Capsule())
|
||
}
|
||
.buttonStyle(FocusableCardStyle())
|
||
}
|
||
|
||
@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: 16) {
|
||
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger)
|
||
Button("Opnieuw") { Task { await reload(reset: true) } }.buttonStyle(TvPrimaryButtonStyle())
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if !loading && items.isEmpty {
|
||
Text("Geen titels")
|
||
.font(TvFonts.title)
|
||
.foregroundStyle(TvColors.textDim)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
ScrollView {
|
||
LazyVGrid(columns: cols, spacing: 28) {
|
||
ForEach(items) { item in
|
||
NavigationLink(value: item) {
|
||
PosterCard(item: item, width: 168, showTitle: true)
|
||
}
|
||
.buttonStyle(FocusableCardStyle())
|
||
.onAppear {
|
||
if item.id == items.last?.id {
|
||
Task { await loadMore() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 48)
|
||
.padding(.vertical, 28)
|
||
.padding(.bottom, 48)
|
||
if loadingMore {
|
||
ProgressView().tint(.white).padding(.bottom, 24)
|
||
}
|
||
}
|
||
.scrollClipDisabled()
|
||
.focusSection()
|
||
}
|
||
}
|
||
|
||
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)
|
||
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 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
|
||
)
|
||
let existing = Set(items.map(\.id))
|
||
items.append(contentsOf: page.items.filter { !existing.contains($0.id) })
|
||
loadingMore = false
|
||
} catch {
|
||
loadingMore = false
|
||
}
|
||
}
|
||
}
|