stremio/apps/ios/VMC/UI/BrowseScreen.swift
Jos Vooges | STH 85da6f59ad Ship F1 hub on Android TV and harden iOS live/ClearKey playback.
Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
2026-09-22 21:52:23 +02:00

326 lines
12 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
/// 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 }
// NavigationStack re-runs .task after detail pop; don't wipe the grid
// or scroll position (same pattern as HomeScreen).
guard items.isEmpty else { return }
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
loadingMore = false
}
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
}
}
}