SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
9.1 KiB
Swift
251 lines
9.1 KiB
Swift
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<String>()
|
|
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: "Favorieten", 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) }
|
|
}
|
|
}
|