import SwiftUI struct HomeScreen: View { @EnvironmentObject private var app: AppModel @State private var home: HomePayload? @State private var error: String? @State private var loading = true private var heroItems: [MediaItem] { guard let home else { return [] } var list = home.spotlight ?? [] if list.isEmpty { list = Array(home.continueWatching.prefix(6)) } if list.isEmpty { list = Array(home.shelves.flatMap(\.items).prefix(8)) } var seen = Set() return list.filter { seen.insert($0.id).inserted } } 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: 12) { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) Button("Opnieuw") { Task { await load() } }.buttonStyle(McPrimaryButtonStyle()) } .padding() } else if let home { ScrollView { VStack(alignment: .leading, spacing: 0) { if !heroItems.isEmpty { HeroCarousel(items: heroItems) } VStack(alignment: .leading, spacing: 22) { if !app.favorites.mediaItems.isEmpty { ShelfSection(title: "Watchlist", items: app.favorites.mediaItems) } if !home.continueWatching.isEmpty { ShelfSection(title: "Verder kijken", items: home.continueWatching) } ForEach(home.shelves) { shelf in if !shelf.items.isEmpty { ShelfSection(title: shelf.title, items: shelf.items) } } } .padding(.top, 18) .padding(.bottom, 28) } .frame(maxWidth: .infinity, alignment: .leading) } } } .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "MEDIA CLUSTER") } ToolbarItem(placement: .topBarTrailing) { Button { app.tab = .account } label: { Text(ProfileAvatars.emoji(for: app.store.profileAvatarId)) .font(.title3) } } } .toolbarBackground(.hidden, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .task { await load() } .refreshable { await load() } } 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 } } } struct HeroCarousel: View { let items: [MediaItem] @State private var index = 0 @State private var pulse = false private let heroHeight: CGFloat = 300 var body: some View { ZStack(alignment: .bottom) { TabView(selection: $index) { ForEach(Array(items.enumerated()), id: \.element.id) { i, item in NavigationLink(value: item) { HeroSlide(item: item, active: i == index, pulse: pulse) } .buttonStyle(.plain) .tag(i) } } .tabViewStyle(.page(indexDisplayMode: .never)) .frame(maxWidth: .infinity) .frame(height: heroHeight) VStack(spacing: 0) { Spacer(minLength: 0) LinearGradient( colors: [.clear, McColors.bg], startPoint: .top, endPoint: .bottom ) .frame(height: 64) .allowsHitTesting(false) if items.count > 1 { HStack(spacing: 6) { ForEach(items.indices, id: \.self) { i in Capsule() .fill(i == index ? Color.white : Color.white.opacity(0.35)) .frame(width: i == index ? 18 : 6, height: 5) .animation(.spring(response: 0.35), value: index) } } .padding(.bottom, 8) } } .frame(height: heroHeight) .allowsHitTesting(false) } .frame(maxWidth: .infinity) .frame(height: heroHeight) .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } .onAppear { withAnimation(.easeInOut(duration: 10).repeatForever(autoreverses: true)) { pulse = true } } .task(id: items.count) { guard items.count > 1 else { return } while !Task.isCancelled { try? await Task.sleep(nanoseconds: 5_500_000_000) guard !Task.isCancelled else { break } withAnimation(.easeInOut(duration: 0.55)) { index = (index + 1) % items.count } } } } } struct HeroSlide: View { let item: MediaItem let active: Bool let pulse: Bool var body: some View { GeometryReader { geo in ZStack(alignment: .bottomLeading) { RemoteImage(url: item.backdropUrl ?? item.posterUrl) .frame(width: geo.size.width, height: geo.size.height) .scaleEffect(active && pulse ? 1.05 : 1.0) .clipped() LinearGradient( colors: [ .black.opacity(0.35), .clear, .black.opacity(0.15), .black.opacity(0.78) ], startPoint: .top, endPoint: .bottom ) VStack(alignment: .leading, spacing: 10) { Text(item.type.uppercased() == "SERIES" ? "SERIE" : "FILM") .font(.system(size: 11, weight: .bold, design: .rounded)) .tracking(1.6) .foregroundStyle(McColors.accent) if let logo = item.logoUrl, !logo.isEmpty { TitleLogo(url: logo, maxWidth: geo.size.width * 0.7, maxHeight: 56) } else { Text(item.title) .font(.system(size: 28, weight: .black, design: .rounded)) .foregroundStyle(.white) .shadow(color: .black.opacity(0.55), radius: 8, y: 2) .lineLimit(2) } HStack(spacing: 10) { Label("Bekijken", systemImage: "play.fill") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.black) .padding(.horizontal, 16) .padding(.vertical, 10) .background(.white, in: Capsule()) if let year = item.year { Text("\(year)") .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.85)) } } } .padding(.horizontal, 20) .padding(.bottom, 36) .frame(maxWidth: .infinity, alignment: .leading) } } } } struct ShelfSection: View { let title: String let items: [MediaItem] var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(McFonts.headline) .foregroundStyle(McColors.text) .padding(.horizontal, 16) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(items) { item in NavigationLink(value: item) { PosterCard(item: item) } .buttonStyle(.plain) } } .padding(.horizontal, 16) } } .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } } }