import SwiftUI // MARK: - Stadium palette (alleen Events) private enum EventsLook { static let neon = Color(red: 0.0, green: 0.91, blue: 0.627) // #00E8A0 static let neonSoft = Color(red: 0.0, green: 0.91, blue: 0.627).opacity(0.18) static let live = Color(red: 1.0, green: 0.176, blue: 0.333) // #FF2D55 static let liveSoft = Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.22) static let soon = Color(red: 1.0, green: 0.69, blue: 0.125) // #FFB020 static let deep = Color(red: 0.027, green: 0.078, blue: 0.063) // #071410 static let panel = Color(red: 0.071, green: 0.102, blue: 0.094) // #121A18 static let panelHot = Color(red: 0.102, green: 0.157, blue: 0.141) // #1A2824 } // MARK: - List struct EventsScreen: View { @EnvironmentObject private var app: AppModel @State private var events: [ScheduleEvent] = [] @State private var loading = true @State private var error: String? @State private var sportFilter: String? @State private var providerFilter: String? @State private var now = Date() @State private var path = NavigationPath() @State private var pulse = false 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 providerScoped: [ScheduleEvent] { guard let providerFilter, !providerFilter.isEmpty else { return events } return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame } } private var filtered: [ScheduleEvent] { guard let sportFilter, !sportFilter.isEmpty else { return providerScoped } return providerScoped.filter { $0.resolvedSportBucket == sportFilter } } private var liveEvents: [ScheduleEvent] { filtered.filter { EventHelpers.isLive($0, now: now) } .sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) } } private var sportChips: [(id: String, label: String)] { let present = Set(providerScoped.map(\.resolvedSportBucket)) return EventSport.order .filter { present.contains($0) } .map { ($0, EventSport.label(for: $0)) } } private var sportShelves: [(id: String, label: String, items: [ScheduleEvent])] { let upcoming = filtered.filter { !EventHelpers.isLive($0, now: now) } var byBucket: [String: [ScheduleEvent]] = [:] for e in upcoming { byBucket[e.resolvedSportBucket, default: []].append(e) } return EventSport.order.compactMap { bucket in guard let items = byBucket[bucket], !items.isEmpty else { return nil } return (bucket, EventSport.label(for: bucket), items) } } private var daySections: [(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) { ZStack { eventsBackground content } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "EVENTS") } } .toolbarBackground(EventsLook.deep.opacity(0.92), for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .navigationDestination(for: String.self) { eventId in EventDetailScreen(eventId: eventId) .environmentObject(app) } } .task { await refresh() } .onAppear { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { pulse = true } } .onReceive(clock) { now = $0 } .onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } } } private var eventsBackground: some View { ZStack { EventsLook.deep.ignoresSafeArea() RadialGradient( colors: [ EventsLook.neon.opacity(0.14), EventsLook.live.opacity(0.06), .clear, ], center: .topTrailing, startRadius: 20, endRadius: 420 ) .ignoresSafeArea() RadialGradient( colors: [EventsLook.neon.opacity(0.07), .clear], center: .bottomLeading, startRadius: 10, endRadius: 360 ) .ignoresSafeArea() } } @ViewBuilder private var content: some View { if loading && events.isEmpty { ProgressView("Sportagenda laden…") .tint(EventsLook.neon) .foregroundStyle(McColors.textDim) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error, events.isEmpty { VStack(spacing: 14) { Text(error) .foregroundStyle(McColors.danger) .multilineTextAlignment(.center) Button("Opnieuw") { Task { await refresh() } } .buttonStyle(McPrimaryButtonStyle()) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(alignment: .leading, spacing: 22) { headerBlock filterChips if filtered.isEmpty { emptyBlock } else { if !liveEvents.isEmpty { liveShelf } if sportFilter == nil { ForEach(sportShelves, id: \.id) { shelf in eventShelf( title: shelf.label, accent: EventsLook.neon, items: shelf.items, featured: false ) } } else { ForEach(daySections, id: \.0) { label, items in eventShelf( title: label, accent: EventsLook.neon.opacity(0.85), items: items, featured: false ) } } } } .padding(.bottom, 28) } .refreshable { await refresh(isPull: true) } } } private var headerBlock: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text("Sportagenda") .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text(subtitleLine) .font(McFonts.caption) .foregroundStyle(Color.white.opacity(0.55)) } Spacer(minLength: 8) if !liveEvents.isEmpty { HStack(spacing: 6) { Circle() .fill(EventsLook.live) .frame(width: 7, height: 7) .opacity(pulse ? 1 : 0.35) .shadow(color: EventsLook.live.opacity(0.8), radius: pulse ? 5 : 1) Text("\(liveEvents.count) LIVE") .font(.system(size: 11, weight: .bold, design: .rounded)) .foregroundStyle(.white) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(EventsLook.liveSoft, in: Capsule()) .overlay(Capsule().stroke(EventsLook.live.opacity(0.45), lineWidth: 1)) } } .padding(.horizontal, 16) .padding(.top, 8) } private var subtitleLine: String { if let sportFilter { return EventSport.label(for: sportFilter) } if let providerFilter { return EventHelpers.prettyProvider(providerFilter) } return "Live · vandaag · later" } private var filterChips: some View { VStack(alignment: .leading, spacing: 10) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { EventsChip( label: "Alles", active: sportFilter == nil, style: .sport ) { sportFilter = nil } ForEach(sportChips, id: \.id) { chip in EventsChip( label: chip.label, active: sportFilter == chip.id, style: .sport ) { sportFilter = chip.id } } } .padding(.horizontal, 16) } if providers.count > 1 { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { EventsChip( label: "Alle bronnen", active: providerFilter == nil, style: .provider ) { providerFilter = nil } ForEach(providers, id: \.self) { p in EventsChip( label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame, style: .provider ) { providerFilter = p } } } .padding(.horizontal, 16) } } } } private var liveShelf: some View { eventShelf( title: "Nu live", accent: EventsLook.live, items: liveEvents, featured: true ) } private func eventShelf( title: String, accent: Color, items: [ScheduleEvent], featured: Bool ) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(accent) .frame(width: 3, height: 16) Text(title) .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundStyle(.white) if featured { Text("NU") .font(.system(size: 10, weight: .heavy, design: .rounded)) .foregroundStyle(EventsLook.live) .padding(.horizontal, 7) .padding(.vertical, 3) .background(EventsLook.liveSoft, in: Capsule()) } Spacer() Text("\(items.count)") .font(McFonts.caption) .foregroundStyle(Color.white.opacity(0.35)) } .padding(.horizontal, 16) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(items) { ev in Button { path.append(ev.id) } label: { EventShelfCard(event: ev, now: now, featured: featured, pulse: pulse) } .buttonStyle(.plain) } } .padding(.horizontal, 16) } } } private var emptyBlock: some View { VStack(spacing: 10) { Image(systemName: "sportscourt") .font(.system(size: 36, weight: .light)) .foregroundStyle(EventsLook.neon.opacity(0.55)) Text( sportFilter != nil ? "Geen events in \(EventSport.label(for: sportFilter!))" : (providerFilter != nil ? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))" : "Geen events in de komende dagen") ) .font(McFonts.body) .foregroundStyle(McColors.textDim) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 48) .padding(.horizontal, 24) } private func refresh(isPull: Bool = false, silent: Bool = false) async { if !silent && events.isEmpty { loading = true } do { let payload = try await app.api.listScheduleEvents() events = EventHelpers.enrichImages(payload.events) error = nil // Drop stale filters if nothing left. if let sportFilter, !sportChips.contains(where: { $0.id == sportFilter }) { self.sportFilter = nil } if let providerFilter, !providers.contains(where: { $0.caseInsensitiveCompare(providerFilter) == .orderedSame }) { self.providerFilter = nil } } catch { if events.isEmpty { self.error = error.localizedDescription } } loading = false } } // MARK: - Chips private struct EventsChip: View { enum Style { case sport, provider } let label: String let active: Bool let style: Style let action: () -> Void var body: some View { Button(action: action) { Text(label) .font(.system(size: style == .sport ? 13 : 12, weight: .semibold, design: .rounded)) .foregroundStyle(foreground) .padding(.horizontal, style == .sport ? 14 : 12) .padding(.vertical, style == .sport ? 9 : 7) .background(background, in: Capsule()) .overlay( Capsule() .strokeBorder(border, lineWidth: active ? 1.2 : 0.5) ) } .buttonStyle(.plain) } private var foreground: Color { if active { return style == .sport ? Color(red: 0.02, green: 0.12, blue: 0.09) : .white } return Color.white.opacity(0.72) } private var background: Color { if active { return style == .sport ? EventsLook.neon : EventsLook.panelHot } return EventsLook.panel.opacity(0.9) } private var border: Color { if active { return style == .sport ? EventsLook.neon : EventsLook.neon.opacity(0.55) } return Color.white.opacity(0.08) } } // MARK: - Shelf card private struct EventShelfCard: View { let event: ScheduleEvent let now: Date var featured: Bool = false var pulse: Bool = false private var cardWidth: CGFloat { featured ? 268 : 196 } private var cardHeight: CGFloat { featured ? 151 : 110 } var body: some View { VStack(alignment: .leading, spacing: 8) { ZStack(alignment: .bottomLeading) { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(EventsLook.panel) if let art = EventHelpers.artUrl(event) { RemoteImage(url: art, contentMode: .fill) .frame(width: cardWidth, height: cardHeight) .clipped() } else { Text(EventHelpers.prettyProvider(event.provider)) .font(.system(size: 12, weight: .medium, design: .rounded)) .foregroundStyle(Color.white.opacity(0.4)) .frame(maxWidth: .infinity, maxHeight: .infinity) } if let logo = event.logoUrl?.nilIfBlank { HStack { Spacer(minLength: 0) TitleLogo(url: logo, maxWidth: 72, maxHeight: 22) .padding(.horizontal, 6) .padding(.vertical, 4) .background(Color.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .padding(8) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } LinearGradient( colors: [ .clear, .black.opacity(0.15), .black.opacity(0.82), ], startPoint: .top, endPoint: .bottom ) if featured { LinearGradient( colors: [EventsLook.live.opacity(0.55), .clear], startPoint: .top, endPoint: .center ) .frame(height: 36) .frame(maxHeight: .infinity, alignment: .top) } VStack(alignment: .leading, spacing: 6) { HStack { EventStatusBadge(event: event, now: now, compact: true, pulse: pulse) Spacer(minLength: 4) if featured && event.isPlayable { Text("KIJK NU") .font(.system(size: 10, weight: .heavy, design: .rounded)) .foregroundStyle(EventsLook.deep) .padding(.horizontal, 8) .padding(.vertical, 4) .background(EventsLook.neon, in: Capsule()) } } Text(EventHelpers.shortTime(event, now: now)) .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) .lineLimit(1) } .padding(10) } .frame(width: cardWidth, height: cardHeight) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder( featured ? EventsLook.live.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1 ) ) .shadow( color: featured ? EventsLook.live.opacity(0.22) : .black.opacity(0.35), radius: featured ? 12 : 6, y: 4 ) Text(event.name) .font(.system(size: featured ? 15 : 14, weight: .semibold, design: .rounded)) .foregroundStyle(.white) .lineLimit(2) .frame(width: cardWidth, alignment: .leading) HStack(spacing: 6) { Text(event.resolvedSportLabel) .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundStyle(EventsLook.neon) .padding(.horizontal, 7) .padding(.vertical, 3) .background(EventsLook.neonSoft, in: Capsule()) Text( [EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)] .compactMap { $0 } .filter { !$0.isEmpty } .uniqued() .joined(separator: " · ") ) .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(Color.white.opacity(0.45)) .lineLimit(1) } .frame(width: cardWidth, alignment: .leading) } .frame(width: cardWidth, alignment: .leading) } } // 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? @State private var pulse = false @State private var appeared = false @State private var playing = false 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: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 28)) .foregroundStyle(EventsLook.soon) Text(error ?? "Fout") .font(McFonts.body) .foregroundStyle(Color.white.opacity(0.8)) .multilineTextAlignment(.center) Button("Terug") { dismiss() } .buttonStyle(McPrimaryButtonStyle()) } .padding(24) } else { ProgressView("Laden…") .tint(EventsLook.neon) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(EventsLook.deep.ignoresSafeArea()) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .task { await load() } .onAppear { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { pulse = true } withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) { appeared = true } } .onReceive(clock) { date in now = date Task { await refreshEvent() } } .fullScreenCover(item: Binding( get: { guard let playInfo else { return nil } return EventPlayItem(info: playInfo) }, 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 { GeometryReader { geo in let w = geo.size.width let heroH = min(geo.size.height * 0.48, 380) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { cinematicHero(ev, width: w, height: heroH) VStack(alignment: .leading, spacing: 18) { metaStrip(ev) timeBlock(ev) descriptionBlock(ev) audioBlock(ev) } .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 24) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 18) } } .safeAreaInset(edge: .top, spacing: 0) { detailTopBar(ev) } .safeAreaInset(edge: .bottom, spacing: 0) { bottomCTA(ev, bottomInset: max(geo.safeAreaInsets.bottom, 8)) } .frame(width: w, height: geo.size.height) } } private func detailTopBar(_ ev: ScheduleEvent) -> some View { HStack(spacing: 12) { Button { dismiss() } label: { HStack(spacing: 6) { Image(systemName: "chevron.left") .font(.system(size: 14, weight: .bold)) Text("Events") .font(.system(size: 15, weight: .semibold, design: .rounded)) } .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 9) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1)) } .buttonStyle(.plain) .accessibilityLabel("Terug naar Events") Spacer(minLength: 0) if let provider = optionalProvider(ev) { Text(provider) .font(.system(size: 11, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) .padding(.horizontal, 12) .padding(.vertical, 7) .background(.black.opacity(0.35), in: Capsule()) .overlay(Capsule().stroke(Color.white.opacity(0.14), lineWidth: 1)) } } .padding(.horizontal, 16) .padding(.top, 6) .padding(.bottom, 8) .background( LinearGradient( colors: [EventsLook.deep.opacity(0.92), EventsLook.deep.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea(edges: .top) .allowsHitTesting(false) ) } private func cinematicHero(_ ev: ScheduleEvent, width: CGFloat, height: CGFloat) -> some View { ZStack(alignment: .bottomLeading) { RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) .frame(width: width, height: height) .clipped() .scaleEffect(appeared ? 1.0 : 1.08) .animation(.easeOut(duration: 0.9), value: appeared) // Side vignette + bottom fade into content LinearGradient( stops: [ .init(color: .black.opacity(0.35), location: 0), .init(color: .clear, location: 0.28), .init(color: EventsLook.deep.opacity(0.55), location: 0.72), .init(color: EventsLook.deep, location: 1), ], startPoint: .top, endPoint: .bottom ) .frame(width: width, height: height) LinearGradient( colors: [EventsLook.deep.opacity(0.55), .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: width * 0.45, height: height) .frame(maxWidth: .infinity, alignment: .leading) if EventHelpers.isLive(ev, now: now) { LinearGradient( colors: [EventsLook.live.opacity(0.45), .clear], startPoint: .top, endPoint: .bottom ) .frame(width: width, height: 90) .frame(maxHeight: .infinity, alignment: .top) .blendMode(.plusLighter) } VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { EventStatusBadge(event: ev, now: now, pulse: pulse) Text(ev.resolvedSportLabel.uppercased()) .font(.system(size: 11, weight: .heavy, design: .rounded)) .tracking(1.1) .foregroundStyle(EventsLook.deep) .padding(.horizontal, 10) .padding(.vertical, 5) .background(EventsLook.neon, in: Capsule()) .shadow(color: EventsLook.neon.opacity(0.35), radius: 8, y: 2) } if let logo = ev.logoUrl, !logo.isEmpty { TitleLogo(url: logo, maxWidth: min(width - 48, 240), maxHeight: 56) } else { Text(ev.name) .font(.system(size: 30, weight: .black, design: .rounded)) .foregroundStyle(.white) .shadow(color: .black.opacity(0.55), radius: 12, y: 4) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) } if let logo = ev.logoUrl, !logo.isEmpty { Text(ev.name) .font(.system(size: 17, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.88)) .lineLimit(2) } } .padding(.horizontal, 20) .padding(.bottom, 22) .frame(width: width, alignment: .leading) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 24) } .frame(width: width, height: height) .clipped() } private func bottomCTA(_ ev: ScheduleEvent, bottomInset: CGFloat) -> some View { VStack(spacing: 0) { LinearGradient( colors: [EventsLook.deep.opacity(0), EventsLook.deep.opacity(0.92), EventsLook.deep], startPoint: .top, endPoint: .bottom ) .frame(height: 28) .allowsHitTesting(false) Group { if ev.isPlayable { Button { Task { await play(ev) } } label: { HStack(spacing: 10) { if playing { ProgressView().tint(EventsLook.deep) } else { Image(systemName: "play.fill") .font(.system(size: 16, weight: .bold)) } Text(EventHelpers.isLive(ev, now: now) ? "Kijk live" : "Nu kijken") .font(.system(size: 17, weight: .bold, design: .rounded)) } .foregroundStyle(EventsLook.deep) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background( LinearGradient( colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)], startPoint: .topLeading, endPoint: .bottomTrailing ), in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) .shadow(color: EventsLook.neon.opacity(0.45), radius: 18, y: 6) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.22), lineWidth: 1) ) } .disabled(playing) .scaleEffect(appeared ? 1 : 0.94) .opacity(appeared ? 1 : 0) } else { Text(EventHelpers.ctaLabel(ev, now: now)) .font(.system(size: 15, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.08), lineWidth: 1) ) } } .padding(.horizontal, 20) .padding(.bottom, max(bottomInset, 10)) .background(EventsLook.deep) } } private func metaStrip(_ ev: ScheduleEvent) -> some View { let meta = EventHelpers.detailMeta(ev) return Group { if !meta.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(meta.split(separator: "·").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, id: \.self) { part in Text(part) .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.85)) .padding(.horizontal, 12) .padding(.vertical, 7) .background(EventsLook.panelHot, in: Capsule()) .overlay(Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1)) } } } } } } private func timeBlock(_ ev: ScheduleEvent) -> some View { let window = EventHelpers.detailTimeWindow(ev, now: now) return Group { if !window.isEmpty { HStack(spacing: 12) { Image(systemName: EventHelpers.isLive(ev, now: now) ? "dot.radiowaves.left.and.right" : "calendar") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(EventHelpers.isLive(ev, now: now) ? EventsLook.live : EventsLook.neon) .frame(width: 36, height: 36) .background( (EventHelpers.isLive(ev, now: now) ? EventsLook.liveSoft : EventsLook.neonSoft), in: RoundedRectangle(cornerRadius: 10, style: .continuous) ) VStack(alignment: .leading, spacing: 2) { Text(EventHelpers.isLive(ev, now: now) ? "Nu bezig" : "Speeltijd") .font(.system(size: 12, weight: .medium, design: .rounded)) .foregroundStyle(.white.opacity(0.45)) Text(window) .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundStyle(.white) } Spacer(minLength: 0) } .padding(14) .background(EventsLook.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.06), lineWidth: 1) ) } } } private func descriptionBlock(_ ev: ScheduleEvent) -> some View { VStack(alignment: .leading, spacing: 10) { if let subtitle = ev.subtitle, !subtitle.isEmpty, subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { Text(subtitle) .font(.system(size: 15, weight: .medium, design: .rounded)) .foregroundStyle(.white.opacity(0.55)) .lineLimit(3) } if let description = ev.description, !description.isEmpty { Text("Over dit event") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(EventsLook.neon.opacity(0.9)) Text(description) .font(McFonts.body) .foregroundStyle(.white.opacity(0.88)) .lineSpacing(5) } } } private func audioBlock(_ ev: ScheduleEvent) -> some View { Group { if let langs = ev.audioLanguages, !langs.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Audio") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.4)) FlowChips(items: langs.prefix(8).map { $0.uppercased() }) } } } } private func optionalProvider(_ ev: ScheduleEvent) -> String? { let p = ev.provider.trimmingCharacters(in: .whitespacesAndNewlines) return p.isEmpty ? nil : p.uppercased() } 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 { playing = true defer { playing = false } do { playInfo = try await app.api.scheduleEventPlay(eventId: ev.id) } catch { playError = error.localizedDescription } } } private struct FlowChips: View { let items: [String] var body: some View { FlexibleChipRow(items: items) } } /// Eenvoudige wrapping chip-rij zonder externe dependency. private struct FlexibleChipRow: View { let items: [String] private var rows: [[String]] { // Simpele wrap: max ~3 per rij op smalle schermen var result: [[String]] = [] var current: [String] = [] for item in items { current.append(item) if current.count >= 3 { result.append(current) current = [] } } if !current.isEmpty { result.append(current) } return result } var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(spacing: 8) { ForEach(row, id: \.self) { item in Text(item) .font(.system(size: 12, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) .padding(.horizontal, 12) .padding(.vertical, 7) .background(EventsLook.panelHot, in: Capsule()) .overlay(Capsule().stroke(EventsLook.neon.opacity(0.2), lineWidth: 1)) } } } } } } private struct EventPlayItem: Identifiable { var id: String { info.streamId.isEmpty ? info.name : info.streamId } let info: IptvPlayInfo } // MARK: - Badge private struct EventStatusBadge: View { let event: ScheduleEvent let now: Date var compact: Bool = false var pulse: Bool = false var body: some View { let (label, color) = EventHelpers.statusVisual(event, now: now) HStack(spacing: 5) { if EventHelpers.isLive(event, now: now) { Circle() .fill(.white) .frame(width: compact ? 5 : 6, height: compact ? 5 : 6) .opacity(pulse ? 1 : 0.4) } Text(label) .font(.system(size: compact ? 10 : 12, weight: .bold, design: .rounded)) .foregroundStyle(.white) } .padding(.horizontal, compact ? 7 : 10) .padding(.vertical, compact ? 3 : 5) .background(color, in: Capsule()) } } // 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 } return Int(start.timeIntervalSince(now) / 60) } static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? { guard let stop = parse(event.stopLocal, event.stop) else { return nil } return Int(stop.timeIntervalSince(now) / 60) } static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) { if isLive(event, now: now) { return ("LIVE", EventsLook.live) } if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 { return ("Straks · \(m)m", EventsLook.soon) } return ("Gepland", Color.white.opacity(0.18)) } 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, looksLikeArtwork(u) { 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, looksLikeArtwork(u) { 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 donorKeys(for: e) { 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 donorKeys(for: sib) { if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue } built[key] = sib } } return built }() var donor: ScheduleEvent? for key in donorKeys(for: e) { if let hit = map[key], hit.id != e.id, hit.resolvedSportBucket == e.resolvedSportBucket { 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 ) } /// Donor-key = sport + kanaal — voorkomt voetbal-art op Racing (zelfde Ziggo-kanaal). private static func donorKeys(for e: ScheduleEvent) -> [String] { let sport = e.resolvedSportBucket var keys: [String] = [] for ch in [e.channel, e.network].compactMap({ normalizeChannel($0) }) { keys.append("\(sport)|\(ch)") } return keys } 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("channellogo") { return false } if lower.range(of: #"ziggo_sport_\d"#, options: .regularExpression) != nil { 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 } } }