import SwiftUI import UIKit struct MovieDetailScreen: View { @EnvironmentObject private var app: AppModel let movieId: String @State private var movie: MovieDetail? @State private var error: String? @State private var loading = true @State private var playId: String? var body: some View { Group { if loading { ProgressView().tint(.white) } else if let error { Text(error).foregroundStyle(McColors.danger).padding() } else if let movie { GeometryReader { geo in ScrollView { VStack(alignment: .leading, spacing: 0) { DetailHero( backdrop: movie.backdropUrl ?? movie.posterUrl, logo: movie.logoUrl, title: movie.title, width: geo.size.width ) VStack(alignment: .leading, spacing: 16) { metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres) HStack(spacing: 12) { if let mediaFileId = movie.preferredMediaFileId { Button { playId = mediaFileId } label: { Label("Afspelen", systemImage: "play.fill") .frame(maxWidth: .infinity) } .buttonStyle(McPrimaryButtonStyle()) } BookmarkToggleButton( isOn: app.favorites.contains(type: "movie", id: movie.id) ) { Task { _ = await app.favorites.toggle( type: "movie", id: movie.id, title: movie.title, year: movie.year, posterUrl: movie.posterUrl, backdropUrl: movie.backdropUrl, logoUrl: movie.logoUrl, api: app.api ) } } } if let overview = movie.overview, !overview.isEmpty { Text(overview) .font(McFonts.body) .foregroundStyle(McColors.textDim) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } peopleSection(cast: movie.cast, director: movie.director) if let files = movie.mediaFiles, !files.isEmpty { Text("Versies") .font(McFonts.headline) .foregroundStyle(McColors.text) ForEach(files) { file in versionRow(file) { playId = file.id } } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 28) .frame(width: geo.size.width, alignment: .leading) } } } .fullScreenCover(item: Binding( get: { playId.map { IdentifiedString(id: $0) } }, set: { playId = $0?.id } )) { item in PlayLauncher( mediaFileId: item.id, title: movie.title, mediaType: "movie", movieId: movie.id, episodeId: nil, seriesId: nil, startMs: 0, logoUrl: movie.logoUrl ) .environmentObject(app) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .task { await load() } } private func load() async { loading = true do { movie = try await app.api.movie(id: movieId) loading = false } catch { self.error = error.localizedDescription loading = false } } } struct SeriesDetailScreen: View { @EnvironmentObject private var app: AppModel let seriesId: String @State private var series: SeriesDetail? @State private var error: String? @State private var loading = true @State private var seasonIndex = 0 @State private var playTarget: EpisodePlay? var body: some View { Group { if loading { ProgressView().tint(.white) } else if let error { Text(error).foregroundStyle(McColors.danger).padding() } else if let series { GeometryReader { geo in ScrollView { VStack(alignment: .leading, spacing: 0) { DetailHero( backdrop: series.backdropUrl ?? series.posterUrl, logo: series.logoUrl, title: series.title, width: geo.size.width ) VStack(alignment: .leading, spacing: 16) { metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres) Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { _ = await app.favorites.toggle( type: "series", id: series.id, title: series.title, year: series.year, posterUrl: series.posterUrl, backdropUrl: series.backdropUrl, logoUrl: series.logoUrl, api: app.api ) } } label: { Label( app.favorites.contains(type: "series", id: series.id) ? "In favorieten" : "Bewaar in favorieten", systemImage: app.favorites.contains(type: "series", id: series.id) ? "bookmark.fill" : "bookmark" ) .frame(maxWidth: .infinity) } .buttonStyle(McSecondaryButtonStyle()) if let overview = series.overview, !overview.isEmpty { Text(overview) .font(McFonts.body) .foregroundStyle(McColors.textDim) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } peopleSection(cast: series.cast, director: series.director) if let seasons = series.seasons, !seasons.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in Button { withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx } } label: { Text("S\(season.seasonNumber)") .font(McFonts.caption) .foregroundStyle(seasonIndex == idx ? .white : McColors.textDim) .padding(.horizontal, 14) .padding(.vertical, 8) .background(seasonIndex == idx ? McColors.accent : McColors.surface, in: Capsule()) } .buttonStyle(.plain) } } } let episodes = seasons[safe: seasonIndex]?.episodes ?? [] LazyVStack(spacing: 12) { ForEach(episodes) { ep in episodeCard(ep, seriesTitle: series.title, width: geo.size.width - 32) } } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 28) .frame(width: geo.size.width, alignment: .leading) } } } .fullScreenCover(item: $playTarget) { target in PlayLauncher( mediaFileId: target.mediaFileId, title: target.title, mediaType: "episode", movieId: nil, episodeId: target.episodeId, seriesId: seriesId, startMs: target.startMs, logoUrl: series.logoUrl ) .environmentObject(app) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .task { await load() } } @ViewBuilder private func episodeCard(_ ep: Episode, seriesTitle: String, width: CGFloat) -> some View { let title = (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)" let playable = ep.mediaFileId.map { !$0.isEmpty } ?? false let watched = ep.completed == true let progress = ep.progressFraction Button { guard let mediaFileId = ep.mediaFileId, playable else { return } playTarget = EpisodePlay( mediaFileId: mediaFileId, episodeId: ep.id, title: "\(seriesTitle) · \(title)", startMs: ep.resumeMs ) } label: { HStack(alignment: .top, spacing: 12) { ZStack(alignment: .bottomTrailing) { RemoteImage(url: ep.stillUrl ?? series?.backdropUrl) .frame(width: 128, height: 72) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .opacity(watched ? 0.55 : 1) if watched { Image(systemName: "checkmark.circle.fill") .font(.system(size: 22)) .foregroundStyle(.white) .shadow(radius: 3) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if progress > 0.02 { GeometryReader { geo in VStack { Spacer() ZStack(alignment: .leading) { Rectangle() .fill(Color.white.opacity(0.35)) .frame(height: 3) Rectangle() .fill(McColors.accent) .frame(width: max(0, geo.size.width * progress), height: 3) } } } .frame(width: 128, height: 72) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } else if playable { Image(systemName: "play.circle.fill") .foregroundStyle(.white) .shadow(radius: 4) .padding(6) } } .frame(width: 128, height: 72) VStack(alignment: .leading, spacing: 4) { Text("\(ep.episodeNumber ?? 0). \(title)") .font(McFonts.headline) .foregroundStyle(watched ? McColors.textDim : (playable ? McColors.text : McColors.textDim)) .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) if let runtime = ep.runtime { Text("\(runtime) min") .font(McFonts.caption) .foregroundStyle(McColors.textDim) } if let overview = ep.overview, !overview.isEmpty { Text(overview) .font(McFonts.caption) .foregroundStyle(McColors.textDim) .lineLimit(3) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) } } } .padding(10) .frame(width: width, alignment: .leading) .background(McColors.bgElevated, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) .disabled(!playable) } private func load() async { loading = true do { series = try await app.api.series(id: seriesId) loading = false } catch { self.error = error.localizedDescription loading = false } } } struct DetailHero: View { let backdrop: String? let logo: String? let title: String let width: CGFloat var body: some View { ZStack(alignment: .bottomLeading) { RemoteImage(url: backdrop) .frame(width: width, height: 300) .clipped() LinearGradient( colors: [.clear, .black.opacity(0.2), McColors.bg.opacity(0.95), McColors.bg], startPoint: .top, endPoint: .bottom ) .frame(width: width, height: 300) VStack(alignment: .leading, spacing: 10) { if let logo, !logo.isEmpty { TitleLogo(url: logo, maxWidth: min(width - 40, 260), maxHeight: 64) } else { Text(title) .font(.system(size: 28, weight: .black, design: .rounded)) .foregroundStyle(.white) .shadow(color: .black.opacity(0.5), radius: 8, y: 3) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: width - 32, alignment: .leading) } } .padding(.horizontal, 16) .padding(.bottom, 18) } .frame(width: width, height: 300) .clipped() } } @ViewBuilder private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { if let year { MetaChip(text: "\(year)") } if let runtime { MetaChip(text: "\(runtime) min") } if let rating { MetaChip(text: "★ \(rating)") } ForEach(genres ?? [], id: \.self) { MetaChip(text: $0) } } } } @ViewBuilder private func peopleSection(cast: [String]?, director: [String]?) -> some View { if let director, !director.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Regie").font(McFonts.caption).foregroundStyle(McColors.textDim) Text(director.joined(separator: ", ")) .font(McFonts.body) .foregroundStyle(McColors.text) .frame(maxWidth: .infinity, alignment: .leading) } } if let cast, !cast.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Cast").font(McFonts.caption).foregroundStyle(McColors.textDim) Text(cast.prefix(8).joined(separator: ", ")) .font(McFonts.body) .foregroundStyle(McColors.text) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } } } @ViewBuilder private func versionRow(_ file: MediaFileInfo, onPlay: @escaping () -> Void) -> some View { Button(action: onPlay) { HStack(spacing: 12) { QualityBadgeRow(file: file) Image(systemName: "play.circle.fill") .font(.title2) .foregroundStyle(McColors.accent) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(McColors.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) } private struct IdentifiedString: Identifiable { let id: String } private struct EpisodePlay: Identifiable { let id = UUID() let mediaFileId: String let episodeId: String let title: String let startMs: Int64 } private extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } }