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.
265 lines
10 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|