import SwiftUI enum RemoteImageRole { /// Shelf/grid posters (~168pt breed) → TMDB w342 case poster /// Hero / detail backdrop → TMDB w1280 i.p.v. original case backdrop /// Title logo → w500 case logo /// Ongewijzigd case raw } struct RemoteImage: View { let url: String? var contentMode: ContentMode = .fill var opaqueBackground: Bool = true /// Bij `.fill`: voorkomt dat gezichten/logo’s bovenin worden weggeknipt. var alignment: Alignment = .center var role: RemoteImageRole = .poster var body: some View { Group { if let u = Self.tunedURL(url, role: role) { AsyncImage(url: u) { phase in switch phase { case .success(let image): image .resizable() .aspectRatio(contentMode: contentMode) case .failure: if opaqueBackground { placeholder } else { Color.clear } case .empty: if opaqueBackground { placeholder.overlay { ProgressView().tint(.white) } } else { Color.clear } @unknown default: if opaqueBackground { placeholder } else { Color.clear } } } } else if opaqueBackground { placeholder } else { Color.clear } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) .clipped() .background(opaqueBackground ? TvColors.surface : Color.clear) } private var placeholder: some View { Rectangle().fill(TvColors.surface) } /// Zelfde look op TV, veel kleinere downloads (esp. home met veel posters + hero). static func tunedURL(_ raw: String?, role: RemoteImageRole) -> URL? { guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return nil } if s.contains("image.tmdb.org/t/p/") { switch role { case .poster: s = s .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w342/") .replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w342/") .replacingOccurrences(of: "/t/p/w500/", with: "/t/p/w342/") case .backdrop: s = s .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w1280/") .replacingOccurrences(of: "/t/p/w1920/", with: "/t/p/w1280/") case .logo: s = s .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w500/") .replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w500/") case .raw: break } } return URL(string: s) } } struct TitleLogo: View { let url: String? var maxWidth: CGFloat = 420 var maxHeight: CGFloat = 120 var body: some View { RemoteImage(url: url, contentMode: .fit, opaqueBackground: false, role: .logo) .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) .shadow(color: .black.opacity(0.55), radius: 16, y: 6) } } struct BrandLogo: View { var size: CGFloat = 36 var body: some View { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) .fill( LinearGradient( colors: [TvColors.accent, TvColors.accent.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: size, height: size) .shadow(color: TvColors.accent.opacity(0.45), radius: 12, y: 3) Text("V") .font(.system(size: size * 0.55, weight: .black, design: .rounded)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 0) { Text("VMC") .font(.system(size: size * 0.55, weight: .black, design: .rounded)) .foregroundStyle(TvColors.text) Text("Media Cluster") .font(.system(size: size * 0.28, weight: .medium, design: .rounded)) .foregroundStyle(TvColors.textDim) } } } } struct PosterCard: View { let item: MediaItem var width: CGFloat = 180 var showTitle: Bool = true @Environment(\.isFocused) private var isFocused var body: some View { VStack(alignment: .leading, spacing: showTitle ? 10 : 0) { ZStack(alignment: .bottom) { RemoteImage(url: item.posterUrl, alignment: .top, role: .poster) .frame(width: width, height: width * 1.5) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) if let pos = item.positionMs, let dur = item.durationMs, dur > 0, pos > 0 { ProgressBar(fraction: min(CGFloat(pos) / CGFloat(dur), 1), height: 5) .padding(8) } } .overlay(alignment: .topTrailing) { if let badge = item.badge, !badge.isEmpty { Text(badge) .font(.system(size: 16, weight: .bold)) .padding(.horizontal, 10) .padding(.vertical, 5) .background(TvColors.accent, in: Capsule()) .foregroundStyle(.white) .padding(10) } } .overlay { if isFocused { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(TvColors.accent, lineWidth: 3.5) .shadow(color: TvColors.accent.opacity(0.7), radius: 18, y: 0) .allowsHitTesting(false) } } .shadow(color: isFocused ? TvColors.accent.opacity(0.45) : .black.opacity(0.35), radius: isFocused ? 24 : 8, y: isFocused ? 12 : 4) .animation(.easeOut(duration: 0.2), value: isFocused) if showTitle { Text(item.title) .font(TvFonts.caption) .foregroundStyle(isFocused ? TvColors.text : TvColors.textDim) .lineLimit(2) .frame(width: width, alignment: .leading) } } .frame(width: width, alignment: .leading) } } struct ProgressBar: View { let fraction: CGFloat var height: CGFloat = 6 var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.25)) Capsule() .fill(TvColors.accent) .frame(width: max(0, geo.size.width * max(0, min(fraction, 1)))) } } .frame(height: height) } } enum EndsAt { static func formatEndsAt(remainingMs: Int64) -> String { guard remainingMs >= 30_000 else { return "" } let end = Date().addingTimeInterval(Double(remainingMs) / 1000.0) let formatter = DateFormatter() formatter.locale = Locale(identifier: "nl_NL") formatter.dateFormat = "HH:mm" return "Eindigt om \(formatter.string(from: end))" } /// Resterende speelduur op basis van runtime (minuten) + voortgang, zoals Android TV. static func remainingMs(runtimeMin: Int?, positionMs: Int64?, completed: Bool?) -> Int64? { guard let runtimeMin, runtimeMin > 0, completed != true else { return nil } let total = Int64(runtimeMin) * 60_000 let remaining = max(0, total - max(0, positionMs ?? 0)) return remaining >= 30_000 ? remaining : nil } static func formatTime(_ ms: Int64) -> String { let total = max(0, Int(ms / 1000)) let h = total / 3600 let m = (total % 3600) / 60 let s = total % 60 if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } return String(format: "%d:%02d", m, s) } } struct MediaRouter: View { let item: MediaItem var body: some View { switch item.type.lowercased() { case "movie": MovieDetailView(movieId: item.id) case "series", "show": SeriesDetailView(seriesId: item.id) case "episode": if let mediaFileId = item.mediaFileId { PlayLauncher( mediaFileId: mediaFileId, title: item.title, mediaType: "episode", movieId: nil, episodeId: item.id, seriesId: item.seriesId, startMs: item.positionMs ?? 0, logoUrl: item.logoUrl ) } else { Text("Niet beschikbaar") .font(TvFonts.title) .foregroundStyle(TvColors.textDim) } default: MovieDetailView(movieId: item.id) } } } struct MetaChip: View { let text: String var body: some View { Text(text) .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) .padding(.horizontal, 16) .padding(.vertical, 8) .background(TvColors.surfaceSoft, in: Capsule()) } }