stremio/apps/ios/VMC/UI/HomeScreen.swift
Jos Vooges | STH 07901c1d0c Add watchlist, next-episode playback, and fix TV shelf reorder.
Probe-driven continue-watching advances to the next episode; Android TV gets next controls, red focus rings, and a Watchlist tab between Home and Films.
2026-09-12 23:50:07 +02:00

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: "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) }
}
}