import SwiftUI // MARK: - List struct EventsScreen: View { @EnvironmentObject private var app: AppModel @State private var events: [ScheduleEvent] = [] @State private var loading = true @State private var refreshing = false @State private var error: String? @State private var providerFilter: String? @State private var now = Date() @State private var path = NavigationPath() private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() private let autoRefresh = Timer.publish(every: 75, on: .main, in: .common).autoconnect() private var filtered: [ScheduleEvent] { guard let providerFilter, !providerFilter.isEmpty else { return events } return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame } } private var liveEvents: [ScheduleEvent] { filtered.filter { EventHelpers.isLive($0, now: now) } .sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) } } private var upcomingSections: [(String, [ScheduleEvent])] { EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) } return upcoming.isEmpty ? nil : (label, upcoming) } } private var providers: [String] { Array(Set(events.map(\.provider))).sorted() } var body: some View { NavigationStack(path: $path) { content .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "EVENTS") } } .toolbarBackground(McColors.bg, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .navigationDestination(for: String.self) { eventId in EventDetailScreen(eventId: eventId) .environmentObject(app) } } .task { await refresh() } .onReceive(clock) { now = $0 } .onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } } } @ViewBuilder private var content: some View { VStack(spacing: 0) { if providers.count > 1 { providerChips } if loading && events.isEmpty { ProgressView("Events laden…") .tint(.white) .foregroundStyle(McColors.textDim) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error, events.isEmpty { VStack(spacing: 12) { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) Button("Opnieuw") { Task { await refresh() } } .buttonStyle(McPrimaryButtonStyle()) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if filtered.isEmpty { ContentUnavailableView( providerFilter != nil ? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))" : "Geen events in de komende dagen", systemImage: "calendar" ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { if !liveEvents.isEmpty { Section { ForEach(liveEvents) { ev in Button { path.append(ev.id) } label: { EventRowView(event: ev, now: now) } .listRowBackground(McColors.bgElevated) .listRowSeparatorTint(Color.white.opacity(0.06)) } } header: { Text("Nu live") .font(McFonts.headline) .foregroundStyle(McColors.text) .textCase(nil) } } ForEach(upcomingSections, id: \.0) { label, dayEvents in Section { ForEach(dayEvents) { ev in Button { path.append(ev.id) } label: { EventRowView(event: ev, now: now) } .listRowBackground(McColors.bgElevated) .listRowSeparatorTint(Color.white.opacity(0.06)) } } header: { Text(label) .font(McFonts.headline) .foregroundStyle(McColors.text) .textCase(nil) } } } .listStyle(.plain) .scrollContentBackground(.hidden) .refreshable { await refresh(isPull: true) } } } } private var providerChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { chip(label: "Alles", active: providerFilter == nil) { providerFilter = nil } ForEach(providers, id: \.self) { p in chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) { providerFilter = p } } } .padding(.horizontal, 16) .padding(.vertical, 8) } } private func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(McFonts.caption) .foregroundStyle(active ? .white : McColors.textDim) .padding(.horizontal, 14) .padding(.vertical, 8) .background(active ? McColors.accent : McColors.surface, in: Capsule()) } .buttonStyle(.plain) } private func refresh(isPull: Bool = false, silent: Bool = false) async { if isPull { refreshing = true } if !silent && events.isEmpty { loading = true } do { let payload = try await app.api.listScheduleEvents() events = EventHelpers.enrichImages(payload.events) error = nil } catch { if events.isEmpty { self.error = error.localizedDescription } } loading = false refreshing = false } } // MARK: - Detail struct EventDetailScreen: View { @EnvironmentObject private var app: AppModel @Environment(\.dismiss) private var dismiss let eventId: String @State private var event: ScheduleEvent? @State private var siblings: [ScheduleEvent] = [] @State private var error: String? @State private var now = Date() @State private var playInfo: IptvPlayInfo? @State private var playError: String? private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() var body: some View { Group { if let event { detailBody(event) } else if error != nil { VStack(spacing: 12) { Text(error ?? "Fout").foregroundStyle(McColors.danger) Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle()) } } else { ProgressView("Laden…").tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(McColors.bg.ignoresSafeArea()) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .task { await load() } .onReceive(clock) { date in now = date Task { await refreshEvent() } } .fullScreenCover(item: Binding( get: { playInfo.map { EventPlayItem(info: $0) } }, set: { playInfo = $0?.info } )) { item in if let url = URL(string: item.info.streamUrl) { let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:)) PlayerScreen(request: .live( url: url, title: item.info.name, fallback: fallback, format: item.info.format, drm: item.info.drm, watchSessionId: item.info.watchSessionId, watchKind: "event", watchId: eventId )) .environmentObject(app) } } .alert("Afspelen mislukt", isPresented: Binding( get: { playError != nil }, set: { if !$0 { playError = nil } } )) { Button("OK", role: .cancel) { playError = nil } } message: { Text(playError ?? "") } } private func detailBody(_ ev: ScheduleEvent) -> some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 0) { hero(ev) VStack(alignment: .leading, spacing: 10) { Text(ev.name) .font(McFonts.title) .foregroundStyle(.white) .lineLimit(3) let meta = EventHelpers.detailMeta(ev) if !meta.isEmpty { Text(meta) .font(McFonts.body) .foregroundStyle(Color(white: 0.8)) .lineLimit(2) } let window = EventHelpers.detailTimeWindow(ev, now: now) if !window.isEmpty { Text(window) .font(McFonts.headline) .foregroundStyle(McColors.text) } if let subtitle = ev.subtitle, !subtitle.isEmpty, subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { Text(subtitle) .font(McFonts.body) .foregroundStyle(McColors.textDim) .lineLimit(3) } if let description = ev.description, !description.isEmpty { Text(description) .font(McFonts.body) .foregroundStyle(Color(white: 0.91)) .lineSpacing(4) } if let langs = ev.audioLanguages, !langs.isEmpty { Text("Audio") .font(McFonts.caption) .foregroundStyle(Color(white: 0.56)) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(langs.prefix(8), id: \.self) { lang in Text(lang.uppercased()) .font(McFonts.caption) .foregroundStyle(Color(white: 0.86)) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4)) } } } } } .padding(.horizontal, 16) .padding(.vertical, 14) } } stickyBar(ev) } } private func hero(_ ev: ScheduleEvent) -> some View { ZStack(alignment: .bottomLeading) { RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) .frame(height: 220) .frame(maxWidth: .infinity) .clipped() .overlay { LinearGradient( stops: [ .init(color: .black.opacity(0.2), location: 0), .init(color: .clear, location: 0.45), .init(color: McColors.bg.opacity(0.95), location: 1), ], startPoint: .top, endPoint: .bottom ) } HStack { Button { dismiss() } label: { Text("Terug") .font(McFonts.caption) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4)) } Spacer() if let logo = ev.logoUrl, !logo.isEmpty { RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false) .frame(width: 96, height: 36) } } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) EventStatusBadge(event: ev, now: now) .padding(16) } .frame(height: 220) } private func stickyBar(_ ev: ScheduleEvent) -> some View { HStack(spacing: 10) { if ev.isPlayable { Button { Task { await play(ev) } } label: { Text("Kijk live") .frame(maxWidth: .infinity) } .buttonStyle(McPrimaryButtonStyle()) Button("Terug") { dismiss() } .buttonStyle(McSecondaryButtonStyle()) } else { Button {} label: { Text(EventHelpers.ctaLabel(ev, now: now)) .frame(maxWidth: .infinity) } .buttonStyle(McSecondaryButtonStyle()) .disabled(true) .opacity(0.55) Button("Terug") { dismiss() } .buttonStyle(McPrimaryButtonStyle()) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(McColors.bgElevated.ignoresSafeArea(edges: .bottom)) } private func load() async { do { let list = try await app.api.listScheduleEvents() siblings = EventHelpers.enrichImages(list.events) let raw = try await app.api.getScheduleEvent(eventId: eventId) event = EventHelpers.enrichSingle(raw, siblings: siblings) error = nil } catch { self.error = error.localizedDescription } } private func refreshEvent() async { guard let raw = try? await app.api.getScheduleEvent(eventId: eventId) else { return } event = EventHelpers.enrichSingle(raw, siblings: siblings) } private func play(_ ev: ScheduleEvent) async { do { playInfo = try await app.api.scheduleEventPlay(eventId: ev.id) } catch { playError = error.localizedDescription } } } private struct EventPlayItem: Identifiable { var id: String { info.streamId.isEmpty ? info.name : info.streamId } let info: IptvPlayInfo } // MARK: - Row private struct EventRowView: View { let event: ScheduleEvent let now: Date var body: some View { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(white: 0.1)) .frame(width: 112, height: 63) if let art = EventHelpers.artUrl(event) { RemoteImage(url: art, contentMode: .fill) .frame(width: 112, height: 63) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } else { Text(EventHelpers.prettyProvider(event.provider)) .font(.system(size: 11, weight: .medium)) .foregroundStyle(McColors.textDim) } } .frame(width: 112, height: 63) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { EventStatusBadge(event: event, now: now, compact: true) Text(EventHelpers.shortTime(event, now: now)) .font(McFonts.caption) .foregroundStyle(McColors.textDim) .lineLimit(1) } Text(event.name) .font(McFonts.headline) .foregroundStyle(McColors.text) .lineLimit(2) Text( [EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)] .compactMap { $0 } .filter { !$0.isEmpty } .uniqued() .joined(separator: " · ") ) .font(McFonts.caption) .foregroundStyle(McColors.textDim) .lineLimit(1) } Spacer(minLength: 0) } .padding(.vertical, 4) } } private struct EventStatusBadge: View { let event: ScheduleEvent let now: Date var compact: Bool = false var body: some View { let (label, color) = EventHelpers.statusVisual(event, now: now) Text(label) .font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded)) .foregroundStyle(.white) .padding(.horizontal, compact ? 8 : 10) .padding(.vertical, compact ? 3 : 5) .background(color, in: RoundedRectangle(cornerRadius: 4)) } } // MARK: - Helpers enum EventHelpers { static let zone = TimeZone(identifier: "Europe/Amsterdam") ?? .current private static let streamLabel = try! NSRegularExpression(pattern: #"^stream\s*\d+"#, options: .caseInsensitive) static func isLive(_ event: ScheduleEvent, now: Date) -> Bool { if event.phaseValue == "live" || event.isLive == true { return true } guard let start = parse(event.startLocal, event.start), let stop = parse(event.stopLocal, event.stop) else { return false } return now >= start && now < stop } static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? { guard let start = parse(event.startLocal, event.start) else { return nil } let m = Int(start.timeIntervalSince(now) / 60) return m } static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? { guard let stop = parse(event.stopLocal, event.stop) else { return nil } let m = Int(stop.timeIntervalSince(now) / 60) return m } static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) { if isLive(event, now: now) { return ("LIVE", McColors.accent) } if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 { return ("Straks · over \(m) min", Color(red: 0.9, green: 0.49, blue: 0.13)) } return ("Gepland", Color(white: 0.23)) } static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String { if isLive(event, now: now) { return "Stream start binnenkort" } if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 { return "Start over \(m) min" } guard let start = parse(event.startLocal, event.start) else { return "Meer info" } return "Start om \(timeFmt.string(from: start))" } static func shortTime(_ event: ScheduleEvent, now: Date) -> String { if isLive(event, now: now) { if let left = minutesUntilEnd(event, now: now), left > 0 { return "Nog \(left) min" } return "Nu" } if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 { return "Over \(m) min" } guard let start = parse(event.startLocal, event.start) else { return "" } let cal = Calendar.current var c = cal c.timeZone = zone let today = c.startOfDay(for: now) let day = c.startOfDay(for: start) if day == today { return "Vandaag \(timeFmt.string(from: start))" } if day == c.date(byAdding: .day, value: 1, to: today) { return "Morgen \(timeFmt.string(from: start))" } return dayTimeFmt.string(from: start) } static func detailTimeWindow(_ event: ScheduleEvent, now: Date) -> String { guard let start = parse(event.startLocal, event.start) else { return "" } let stop = parse(event.stopLocal, event.stop) let range: String = { if let stop { return "\(timeFmt.string(from: start)) – \(timeFmt.string(from: stop))" } return timeFmt.string(from: start) }() if isLive(event, now: now) { return range } var c = Calendar.current c.timeZone = zone let today = c.startOfDay(for: now) let day = c.startOfDay(for: start) if day == today { return "Vandaag · \(range)" } if day == c.date(byAdding: .day, value: 1, to: today) { return "Morgen · \(range)" } if let stop { return "\(dayTimeFmt.string(from: start)) – \(timeFmt.string(from: stop))" } return dayTimeFmt.string(from: start) } static func displayChannel(_ event: ScheduleEvent) -> String? { let raw = event.channel?.nilIfBlank ?? event.network?.nilIfBlank guard let raw, !isStreamLabel(raw) else { return nil } return raw } static func detailMeta(_ event: ScheduleEvent) -> String { [ prettyProvider(event.provider), event.category?.nilIfBlank, event.eventType?.nilIfBlank, displayChannel(event), ] .compactMap { $0 } .uniqued() .joined(separator: " · ") } static func artUrl(_ event: ScheduleEvent) -> String? { if let u = event.imageLandscape?.nilIfBlank { return u } let imgs = event.images ?? [] if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) { return u } if let u = imgs.first(where: { looksLikeArtwork($0) }) { return u } if let u = event.imagePortrait?.nilIfBlank { return u } return imgs.first { !$0.isEmpty && !$0.localizedCaseInsensitiveContains("assets/channels") } } static func backdropUrl(_ event: ScheduleEvent) -> String? { if let u = event.imageBackdrop?.nilIfBlank { return u } if let u = (event.images ?? []).first(where: { $0.localizedCaseInsensitiveContains("hero-16x9") }) { return u } return artUrl(event) } static func prettyProvider(_ raw: String) -> String { switch raw.lowercased() { case "viaplay": return "Viaplay" case "ziggosport": return "Ziggo Sport" case "espn": return "ESPN" case "appletv": return "Apple TV" case "prime_hbo": return "Prime / HBO" case "prime": return "Prime Video" case "disneyplus": return "Disney+" case "eyecons": return "Eyecons" case "odido": return "Odido" default: return raw.replacingOccurrences(of: "_", with: " ").capitalized } } static func groupByDay(_ events: [ScheduleEvent]) -> [(String, [ScheduleEvent])] { var c = Calendar.current c.timeZone = zone let today = c.startOfDay(for: Date()) var groups: [(String, [ScheduleEvent])] = [] var index: [String: Int] = [:] for e in events { guard let start = parse(e.startLocal, e.start) else { continue } let day = c.startOfDay(for: start) let label: String if day == today { label = "Vandaag" } else if day == c.date(byAdding: .day, value: 1, to: today) { label = "Morgen" } else if day == c.date(byAdding: .day, value: 2, to: today) { label = "Overmorgen" } else { label = fullDayFmt.string(from: start) } if let i = index[label] { groups[i].1.append(e) } else { index[label] = groups.count groups.append((label, [e])) } } return groups } static func enrichImages(_ events: [ScheduleEvent]) -> [ScheduleEvent] { var donors: [String: ScheduleEvent] = [:] for e in events { if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue } for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue } donors[key] = e } } return events.map { enrichSingle($0, siblings: events, donors: donors) } } static func enrichSingle( _ e: ScheduleEvent, siblings: [ScheduleEvent], donors: [String: ScheduleEvent]? = nil ) -> ScheduleEvent { let cleanedChannel = e.channel.flatMap { isStreamLabel($0) ? nil : $0 } let cleanedNetwork = e.network.flatMap { isStreamLabel($0) ? nil : $0 } if e.imageLandscape != nil || !(e.images ?? []).isEmpty { return e.withImages(channel: cleanedChannel, network: cleanedNetwork) } let map: [String: ScheduleEvent] = donors ?? { var built: [String: ScheduleEvent] = [:] for sib in siblings { if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue } for key in [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) { if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue } built[key] = sib } } return built }() var donor: ScheduleEvent? for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { if let hit = map[key], hit.id != e.id { donor = hit break } } guard let donor else { return e.withImages(channel: cleanedChannel, network: cleanedNetwork) } return e.withImages( landscape: e.imageLandscape ?? donor.imageLandscape, backdrop: e.imageBackdrop ?? donor.imageBackdrop, portrait: e.imagePortrait ?? donor.imagePortrait, logo: e.logoUrl ?? donor.logoUrl, imgs: (e.images?.isEmpty == false ? e.images : donor.images), channel: cleanedChannel, network: cleanedNetwork ) } private static func donorScore(_ e: ScheduleEvent) -> Int { var s = 0 if e.imageLandscape != nil { s += 20 } if e.imageBackdrop != nil { s += 10 } if (e.images ?? []).contains(where: { $0.localizedCaseInsensitiveContains("thumb") || $0.localizedCaseInsensitiveContains("akamaized.net/live") }) { s += 30 } if e.logoUrl != nil { s += 5 } s += min((e.images ?? []).count, 5) if e.provider.caseInsensitiveCompare("ziggosport") == .orderedSame { s += 8 } if e.isPlayable { s += 3 } return s } private static func normalizeChannel(_ value: String?) -> String? { let s = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) ?? "" return s.isEmpty ? nil : s } private static func isStreamLabel(_ value: String) -> Bool { let s = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return false } let range = NSRange(s.startIndex.. Bool { let u = url.trimmingCharacters(in: .whitespacesAndNewlines) if u.isEmpty { return false } let lower = u.lowercased() if lower.contains("assets/channels") { return false } if lower.contains("channels-logo") { return false } if lower.contains("/logos/") && lower.hasSuffix(".png") { return false } if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")), !lower.contains("boxart"), !lower.contains("hero-") { return false } if lower.hasSuffix(".svg") { return false } return true } static func parse(_ local: String?, _ utc: String) -> Date? { let raw = local?.nilIfBlank ?? utc.nilIfBlank guard let raw else { return nil } let isoFrac = ISO8601DateFormatter() isoFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let d = isoFrac.date(from: raw) { return d } let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] if let d = iso.date(from: raw) { return d } let withZ = raw.hasSuffix("Z") || raw.contains("+") ? raw : "\(raw)Z" return iso.date(from: withZ) ?? isoFrac.date(from: withZ) } private static let timeFmt: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "nl_NL") f.timeZone = zone f.dateFormat = "HH:mm" return f }() private static let dayTimeFmt: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "nl_NL") f.timeZone = zone f.dateFormat = "EEE d MMM · HH:mm" return f }() private static let fullDayFmt: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "nl_NL") f.timeZone = zone f.dateFormat = "EEEE d MMMM" return f }() } private extension String { var nilIfBlank: String? { let t = trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } } private extension Array where Element: Hashable { func uniqued() -> [Element] { var seen = Set() return filter { seen.insert($0).inserted } } }