stremio/apps/ios/VMCTV/UI/BrowseView.swift
Jos Vooges | STH ac951617cb Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.
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.
2026-09-18 23:34:42 +02:00

237 lines
8.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}