stremio/apps/ios/VMCTV/UI/HomeView.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

265 lines
10 KiB
Swift

import SwiftUI
struct HomeView: View {
@EnvironmentObject private var app: AppModel
@State private var home: HomePayload?
@State private var error: String?
@State private var loading = true
@State private var playTarget: HomePlayTarget?
private var heroItems: [MediaItem] {
guard let home else { return [] }
var seen = Set<String>()
var out: [MediaItem] = []
let candidates = (home.spotlight ?? [])
+ home.continueWatching
+ home.shelves.flatMap(\.items)
for item in candidates {
if seen.insert(item.id).inserted {
out.append(item)
}
if out.count >= 8 { break }
}
return out
}
var body: some View {
Group {
if loading && home == nil {
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error, home == nil {
VStack(spacing: 20) {
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).multilineTextAlignment(.center)
Button("Opnieuw") { Task { await load() } }.buttonStyle(TvPrimaryButtonStyle())
}
} else if let home {
ScrollView(.vertical, showsIndicators: false) {
// LazyVStack: alleen zichtbare planken laden posters (anders 8+ rijen tegelijk = traag).
LazyVStack(alignment: .leading, spacing: 28) {
if !heroItems.isEmpty {
HomeHeroCarousel(items: heroItems) { item in
playTarget = HomePlayTarget(item: item)
}
}
if !home.continueWatching.isEmpty {
TvShelfSection(
title: "Verder kijken",
items: home.continueWatching,
showContinueActions: true,
onReload: { Task { await load() } }
)
}
ForEach(home.shelves) { shelf in
if !shelf.items.isEmpty {
TvShelfSection(title: shelf.title, items: shelf.items, onReload: { Task { await load() } })
}
}
}
.padding(.bottom, 60)
}
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
}
}
.background(TvColors.bg.ignoresSafeArea())
.task { await load() }
.fullScreenCover(item: $playTarget) { target in
MediaRouter(item: target.item)
.environmentObject(app)
}
}
private func load() async {
loading = home == nil
error = nil
do {
home = try await app.api.home()
if let favs = home?.favorites {
app.favorites.replaceAll(favs)
} else {
await app.favorites.refresh(using: app.api)
}
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}
private struct HomePlayTarget: Identifiable {
var id: String { item.id }
let item: MediaItem
}
struct HomeHeroCarousel: View {
let items: [MediaItem]
let onPlay: (MediaItem) -> Void
@State private var index = 0
@State private var kenBurns = false
private let rotate = Timer.publish(every: 7, on: .main, in: .common).autoconnect()
private let heroHeight: CGFloat = 720
private var item: MediaItem {
items[index % max(items.count, 1)]
}
var body: some View {
ZStack(alignment: .bottomLeading) {
GeometryReader { geo in
RemoteImage(url: item.backdropUrl ?? item.posterUrl, alignment: .top, role: .backdrop)
.frame(width: geo.size.width, height: heroHeight * (kenBurns ? 1.06 : 1.0), alignment: .top)
.frame(width: geo.size.width, height: heroHeight, alignment: .top)
.clipped()
.id(item.id)
.transition(.opacity)
.animation(.easeInOut(duration: 14), value: kenBurns)
}
.frame(height: heroHeight)
LinearGradient(
colors: [
Color.black.opacity(0.25),
.clear,
.clear,
TvColors.bg.opacity(0.55),
TvColors.bg
],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 18) {
Text(item.type.uppercased() == "SERIES" ? "SERIE" : "FILM")
.font(.system(size: 18, weight: .bold, design: .rounded))
.tracking(2)
.foregroundStyle(TvColors.accent)
if let logo = item.logoUrl, !logo.isEmpty {
TitleLogo(url: logo, maxWidth: 560, maxHeight: 120)
} else {
Text(item.title)
.font(TvFonts.hero)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.6), radius: 12, y: 4)
.lineLimit(2)
}
if let year = item.year {
Text("\(year)")
.font(TvFonts.body)
.foregroundStyle(.white.opacity(0.8))
}
HStack(spacing: 20) {
Button { onPlay(item) } label: {
Label("Afspelen", systemImage: "play.fill")
}
.buttonStyle(TvPrimaryButtonStyle())
NavigationLink(value: item) {
Label("Meer info", systemImage: "info.circle")
}
.buttonStyle(TvSecondaryButtonStyle())
if items.count > 1 {
HStack(spacing: 8) {
ForEach(0..<items.count, id: \.self) { i in
Circle()
.fill(i == index % items.count ? Color.white : Color.white.opacity(0.35))
.frame(width: 10, height: 10)
}
}
.padding(.leading, 12)
}
}
}
.padding(.horizontal, 56)
.padding(.bottom, 56)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: heroHeight)
.onAppear {
kenBurns = false
withAnimation(.easeInOut(duration: 14).repeatForever(autoreverses: true)) {
kenBurns = true
}
}
.onReceive(rotate) { _ in
guard items.count > 1 else { return }
withAnimation(.easeInOut(duration: 0.85)) {
index = (index + 1) % items.count
}
kenBurns = false
withAnimation(.easeInOut(duration: 14).repeatForever(autoreverses: true)) {
kenBurns = true
}
}
.onChange(of: items.map(\.id)) { _, _ in
index = 0
}
}
}
struct TvShelfSection: View {
@EnvironmentObject private var app: AppModel
let title: String
let items: [MediaItem]
var showContinueActions: Bool = false
var onReload: (() -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
.padding(.horizontal, 48)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .center, spacing: 26) {
ForEach(items) { item in
NavigationLink(value: item) {
PosterCard(item: item, width: 168)
}
// Lichtere scale: voorkomt knippen in scroll + dubbele scale met PosterCard-border.
.buttonStyle(FocusableCardStyle(scale: 1.06))
.contextMenu {
if showContinueActions || item.progressId != nil {
if let pid = item.progressId {
Button("Verwijderen uit verder kijken") {
Task {
try? await app.api.dismissContinue(progressId: pid)
onReload?()
}
}
}
Button("Markeer als gezien") {
Task {
let t = item.type.lowercased()
if t == "episode" {
try? await app.api.markWatched(episodeId: item.id)
} else {
try? await app.api.markWatched(movieId: item.id)
}
if let pid = item.progressId {
try? await app.api.dismissContinue(progressId: pid)
}
onReload?()
}
}
}
}
}
}
.padding(.horizontal, 48)
// Ruimte voor focus-scale zodat covers niet bovenin wegvallen.
.padding(.vertical, 36)
}
.scrollClipDisabled()
.focusSection()
}
}
}