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() 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.. 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() } } }