import SwiftUI struct MovieDetailView: 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).font(TvFonts.body).foregroundStyle(TvColors.danger).padding() } else if let movie { ScrollView { VStack(alignment: .leading, spacing: 0) { DetailHero( backdrop: movie.backdropUrl ?? movie.posterUrl, logo: movie.logoUrl, title: movie.title ) VStack(alignment: .leading, spacing: 22) { metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres) HStack(spacing: 20) { if let mediaFileId = movie.preferredMediaFileId { Button { playId = mediaFileId } label: { Label("Afspelen", systemImage: "play.fill") } .buttonStyle(TvPrimaryButtonStyle()) } Button { 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 ) } } label: { Label( app.favorites.contains(type: "movie", id: movie.id) ? "In watchlist" : "Watchlist", systemImage: app.favorites.contains(type: "movie", id: movie.id) ? "bookmark.fill" : "bookmark" ) } .buttonStyle(TvSecondaryButtonStyle()) Button { Task { try? await app.api.markWatched(movieId: movie.id) } } label: { Label("Gezien", systemImage: "checkmark.circle") } .buttonStyle(TvSecondaryButtonStyle()) } .focusSection() if let overview = movie.overview, !overview.isEmpty { Text(overview) .font(TvFonts.body) .foregroundStyle(TvColors.textDim) .frame(maxWidth: 1100, alignment: .leading) } peopleSection(cast: movie.cast, director: movie.director) if let files = movie.mediaFiles, files.count > 1 { Text("Versies") .font(TvFonts.headline) .foregroundStyle(TvColors.text) ForEach(files) { file in Button { playId = file.id } label: { HStack(spacing: 16) { Text(file.label ?? file.resolution ?? "Versie") .font(TvFonts.body) .foregroundStyle(TvColors.text) if let res = file.resolution { MetaChip(text: res) } if file.nodeOnline == false { Text("offline").foregroundStyle(TvColors.danger).font(TvFonts.caption) } Spacer() Image(systemName: "play.circle.fill") .font(.system(size: 32)) .foregroundStyle(TvColors.accent) } .padding(20) .background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } .buttonStyle(FocusableCardStyle()) } } } .padding(.horizontal, 56) .padding(.top, 12) .padding(.bottom, 48) } } .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(TvColors.bg.ignoresSafeArea()) .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 SeriesDetailView: 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).font(TvFonts.body).foregroundStyle(TvColors.danger).padding() } else if let series { ScrollView { VStack(alignment: .leading, spacing: 0) { DetailHero( backdrop: series.backdropUrl ?? series.posterUrl, logo: series.logoUrl, title: series.title ) VStack(alignment: .leading, spacing: 22) { metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres) Button { 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 watchlist" : "Watchlist", systemImage: app.favorites.contains(type: "series", id: series.id) ? "bookmark.fill" : "bookmark" ) } .buttonStyle(TvSecondaryButtonStyle()) if let overview = series.overview, !overview.isEmpty { Text(overview) .font(TvFonts.body) .foregroundStyle(TvColors.textDim) .frame(maxWidth: 1100, alignment: .leading) } peopleSection(cast: series.cast, director: series.director) if let seasons = series.seasons, !seasons.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in Button { withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx } } label: { Text("Seizoen \(season.seasonNumber)") .font(TvFonts.caption) .foregroundStyle(seasonIndex == idx ? .white : TvColors.textDim) .padding(.horizontal, 22) .padding(.vertical, 12) .background(seasonIndex == idx ? TvColors.accent : TvColors.surface, in: Capsule()) } .buttonStyle(FocusableCardStyle()) } } } .focusSection() let episodes = seasons[safe: seasonIndex]?.episodes ?? [] LazyVStack(spacing: 14) { ForEach(episodes) { ep in episodeRow(ep, seriesTitle: series.title) } } .focusSection() } } .padding(.horizontal, 56) .padding(.top, 12) .padding(.bottom, 48) } } .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: target.logoUrl ) .environmentObject(app) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(TvColors.bg.ignoresSafeArea()) .task { await load() } } @ViewBuilder private func episodeRow(_ ep: Episode, seriesTitle: String) -> some View { EpisodeRowButton( ep: ep, seriesTitle: seriesTitle, seriesBackdrop: series?.backdropUrl, seriesLogo: series?.logoUrl ) { play in playTarget = play } } 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 private let height: CGFloat = 560 var body: some View { ZStack(alignment: .bottomLeading) { RemoteImage(url: backdrop, alignment: .top, role: .backdrop) .frame(maxWidth: .infinity) .frame(height: height) .clipped() LinearGradient( colors: [.clear, .black.opacity(0.15), TvColors.bg.opacity(0.9), TvColors.bg], startPoint: .top, endPoint: .bottom ) VStack(alignment: .leading, spacing: 12) { if let logo, !logo.isEmpty { TitleLogo(url: logo, maxWidth: 480, maxHeight: 100) } else { Text(title) .font(TvFonts.hero) .foregroundStyle(.white) .shadow(color: .black.opacity(0.5), radius: 10, y: 4) .lineLimit(2) } } .padding(.horizontal, 56) .padding(.bottom, 28) } .frame(height: height) } } private struct EpisodeRowButton: View { @EnvironmentObject private var app: AppModel let ep: Episode let seriesTitle: String let seriesBackdrop: String? let seriesLogo: String? let onPlay: (EpisodePlay) -> Void private var title: String { (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)" } private var playable: Bool { ep.mediaFileId.map { !$0.isEmpty } ?? false } var body: some View { Button { guard let mediaFileId = ep.mediaFileId, playable else { return } onPlay( EpisodePlay( mediaFileId: mediaFileId, episodeId: ep.id, title: "\(seriesTitle) · \(title)", startMs: ep.resumeMs, logoUrl: seriesLogo ) ) } label: { EpisodeRowLabel( ep: ep, title: title, playable: playable, seriesBackdrop: seriesBackdrop ) } .buttonStyle(FocusableCardStyle(scale: 1.03)) .disabled(!playable) .contextMenu { Button("Markeer als gezien") { Task { try? await app.api.markWatched(episodeId: ep.id) } } } } } private struct EpisodeRowLabel: View { let ep: Episode let title: String let playable: Bool let seriesBackdrop: String? @Environment(\.isFocused) private var isFocused private var endsAtText: String? { guard isFocused, let remaining = EndsAt.remainingMs( runtimeMin: ep.runtime, positionMs: ep.positionMs, completed: ep.completed ) else { return nil } let text = EndsAt.formatEndsAt(remainingMs: remaining) return text.isEmpty ? nil : text } var body: some View { HStack(spacing: 20) { ZStack(alignment: .bottom) { RemoteImage(url: ep.stillUrl ?? seriesBackdrop, alignment: .top, role: .backdrop) .frame(width: 220, height: 124) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .opacity(ep.completed == true ? 0.55 : 1) if ep.progressFraction > 0.02 && ep.completed != true { ProgressBar(fraction: ep.progressFraction, height: 5) .padding(8) } } VStack(alignment: .leading, spacing: 8) { Text("\(ep.episodeNumber ?? 0). \(title)") .font(TvFonts.headline) .foregroundStyle(playable ? TvColors.text : TvColors.textDim) .lineLimit(2) HStack(spacing: 14) { if let runtime = ep.runtime { Text("\(runtime) min") .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) } if let endsAtText { Text(endsAtText) .font(TvFonts.caption) .foregroundStyle(Color(red: 1.0, green: 0.84, blue: 0.2)) } } .animation(.easeOut(duration: 0.15), value: isFocused) if let overview = ep.overview, !overview.isEmpty { Text(overview) .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) .lineLimit(2) } } Spacer() if playable { Image(systemName: "play.circle.fill") .font(.system(size: 40)) .foregroundStyle(TvColors.accent) } } .padding(16) .background( (isFocused ? TvColors.surface : TvColors.bgElevated), in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) .overlay { if isFocused { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(TvColors.accent.opacity(0.7), lineWidth: 2) } } } } private struct EpisodePlay: Identifiable { let id = UUID() let mediaFileId: String let episodeId: String let title: String let startMs: Int64 let logoUrl: String? } @ViewBuilder private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View { HStack(spacing: 12) { if let year { MetaChip(text: "\(year)") } if let runtime { MetaChip(text: "\(runtime) min") } if let rating { MetaChip(text: "★ \(rating)") } ForEach((genres ?? []).prefix(5), id: \.self) { MetaChip(text: $0) } } } @ViewBuilder private func peopleSection(cast: [String]?, director: [String]?) -> some View { if let director, !director.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Regie").font(TvFonts.caption).foregroundStyle(TvColors.textDim) Text(director.joined(separator: ", ")) .font(TvFonts.body) .foregroundStyle(TvColors.text) } } if let cast, !cast.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Cast").font(TvFonts.caption).foregroundStyle(TvColors.textDim) Text(cast.prefix(10).joined(separator: ", ")) .font(TvFonts.body) .foregroundStyle(TvColors.text) .frame(maxWidth: 1100, alignment: .leading) } } } private struct IdentifiedString: Identifiable { let id: String } private extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } }