import SwiftUI // MARK: - Routing enum EventHubRoute: Hashable { case f1 case f1Meeting(String) case viaplay static func forHub(_ id: String) -> EventHubRoute? { switch id.lowercased() { case "f1", "f1tv": return .f1 case "viaplay": return .viaplay default: return nil } } } struct EventHubDestination: View { let route: EventHubRoute var body: some View { switch route { case .f1: F1HubScreen() case .f1Meeting(let key): F1MeetingScreen(meetingKey: key) case .viaplay: ViaplayHubScreen() } } } // MARK: - Theme struct HubTheme { enum Kind { case f1, viaplay } let kind: Kind let accent: Color let accent2: Color let base: Color let symbol: String static let f1 = HubTheme( kind: .f1, accent: Color(red: 0.882, green: 0.024, blue: 0.0), // #E10600 accent2: Color(red: 0.36, green: 0.0, blue: 0.02), base: Color(red: 0.039, green: 0.039, blue: 0.047), symbol: "flag.checkered" ) static let viaplay = HubTheme( kind: .viaplay, accent: Color(red: 1.0, green: 0.176, blue: 0.478), // #FF2D7A accent2: Color(red: 0.357, green: 0.169, blue: 0.839), // #5B2BD6 base: Color(red: 0.051, green: 0.039, blue: 0.094), symbol: "sportscourt.fill" ) static let liveRed = Color(red: 1.0, green: 0.231, blue: 0.188) static let soonAmber = Color(red: 1.0, green: 0.690, blue: 0.125) static func forHub(_ id: String) -> HubTheme { id.lowercased() == "viaplay" ? .viaplay : .f1 } } // MARK: - Entry card (Events) struct EventHubEntryCard: View { let hub: EventHub private var theme: HubTheme { HubTheme.forHub(hub.id) } var body: some View { ZStack(alignment: .bottomLeading) { LinearGradient( colors: [theme.accent, theme.accent2], startPoint: .topLeading, endPoint: .bottomTrailing ) Image(systemName: theme.symbol) .font(.system(size: 84, weight: .bold)) .foregroundStyle(.white.opacity(0.12)) .rotationEffect(.degrees(-12)) .offset(x: 18, y: 18) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) LinearGradient(colors: [.clear, .black.opacity(0.32)], startPoint: .top, endPoint: .bottom) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center) { HubWordmark(kind: theme.kind, size: 22) .foregroundStyle(.white) Spacer(minLength: 8) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(.white.opacity(0.2), in: Circle()) } Spacer(minLength: 10) Text(hub.label) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .lineLimit(1) if let sub = hub.subtitle?.hubNonBlank { Text(sub) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.78)) .lineLimit(1) .padding(.top, 2) } } .padding(14) } .frame(maxWidth: .infinity) .frame(height: 118) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(.white.opacity(0.14), lineWidth: 1) ) .shadow(color: theme.accent.opacity(0.28), radius: 14, y: 6) .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) } } // MARK: - F1 hub struct F1HubScreen: View { @EnvironmentObject private var app: AppModel @StateObject private var playback = HubPlayback() @State private var payload: F1HubPayload? @State private var error: String? @State private var now = Date() @State private var ticks = 0 private let theme = HubTheme.f1 private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() var body: some View { HubScaffold(theme: theme, backLabel: "Events", onRefresh: { await load() }) { layout in if let payload { loaded(payload, layout) } else if let error { HubMessageView( layout: layout, symbol: theme.symbol, title: "Formule 1 niet beschikbaar", message: error, retry: { await load() } ) } else { HubLoadingView(layout: layout) } } .modifier(HubPlaybackModifier(playback: playback)) .task { await load() } .onReceive(clock) { date in now = date ticks += 1 if ticks % 2 == 0 { Task { await load(silent: true) } } } } @ViewBuilder private func loaded(_ p: F1HubPayload, _ layout: HubLayout) -> some View { let weekend = HubFormat.uniqueById(p.live + p.upcoming) let isEmpty = weekend.isEmpty && p.recentReplays.isEmpty && p.replayGroups.isEmpty && p.meetings.isEmpty VStack(alignment: .leading, spacing: 32) { hero(p, layout) if isEmpty { ContentUnavailableView( "Geen sessies", systemImage: "flag.checkered", description: Text("Er staan op dit moment geen F1-sessies of replays klaar.") ) .padding(.top, 12) } if !weekend.isEmpty { HubSection(title: "Dit weekend", count: weekend.count, live: !p.live.isEmpty) { HubRail(items: weekend) { ev in eventTap(ev) { HubEventCard(event: ev, now: now, theme: theme, width: 280, showsPlayGlyph: canPlay(ev)) } } } } if !p.recentReplays.isEmpty { HubSection(title: "Terugkijken", count: p.recentReplays.count) { HubRail(items: p.recentReplays) { ev in eventTap(ev) { HubEventCard(event: ev, now: now, theme: theme, width: 240, showsPlayGlyph: canPlay(ev)) } } } } if !p.replayGroups.isEmpty { HubSection(title: "Raceweekenden", subtitle: "Alle sessies per Grand Prix") { HubRail(items: p.replayGroups) { group in NavigationLink(value: EventHubRoute.f1Meeting(group.meetingKey)) { HubMeetingCard( title: group.title, round: group.meetingNumber, location: group.location, dateLine: group.sessions.count == 1 ? "1 sessie" : "\(group.sessions.count) sessies", imageUrl: group.imageUrl?.hubNonBlank ?? group.sessions.lazy.compactMap(EventHelpers.artUrl).first, theme: theme ) } .buttonStyle(HubPressStyle()) } } } if !p.meetings.isEmpty { seasonSection(p) } } } @ViewBuilder private func hero(_ p: F1HubPayload, _ layout: HubLayout) -> some View { let featured = p.live.first ?? p.upcoming.first ?? p.recentReplays.first let meeting = p.nextMeeting let image = featured.flatMap(EventHelpers.backdropUrl) ?? meeting?.imageUrl?.hubNonBlank if let featured { let state = HubFormat.state(featured, now: now) HubHero( theme: theme, layout: layout, imageUrl: image, eyebrow: heroEyebrow(featured, state: state), isLive: state == .live, title: featured.name, subtitle: meeting?.title.hubNonBlank ?? featured.subtitle?.hubNonBlank ) { if canPlay(featured) { Button { play(featured) } label: { Label(state == .live ? "Kijk live" : "Terugkijken", systemImage: "play.fill") } .buttonStyle(HubPrimaryButtonStyle()) if let meeting { NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) { Label("Weekend", systemImage: "calendar") } .buttonStyle(HubGlassButtonStyle()) } } else if let meeting { NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) { Label("Bekijk weekend", systemImage: "calendar") } .buttonStyle(HubPrimaryButtonStyle()) } else { NavigationLink(value: featured.id) { Label("Details", systemImage: "info.circle") } .buttonStyle(HubPrimaryButtonStyle()) } } } else if let meeting { HubHero( theme: theme, layout: layout, imageUrl: image, eyebrow: "Volgende Grand Prix", isLive: false, title: meeting.title, subtitle: [meeting.location?.hubNonBlank, HubFormat.meetingRange(meeting)] .compactMap { $0 } .joined(separator: " · ") ) { NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) { Label("Bekijk weekend", systemImage: "calendar") } .buttonStyle(HubPrimaryButtonStyle()) } } else { HubHero( theme: theme, layout: layout, imageUrl: nil, eyebrow: "F1 TV", isLive: false, title: "Formule 1", subtitle: "Live sessies en replays" ) { EmptyView() } } } private func seasonSection(_ p: F1HubPayload) -> some View { let nowMs = now.timeIntervalSince1970 * 1000 let nextKey = p.nextMeeting?.meetingKey ?? p.meetings.first(where: { ($0.endMs ?? $0.startMs ?? 0) >= nowMs })?.meetingKey let year = p.meetings.lazy.compactMap(\.startMs).first.map { Calendar.current.component(.year, from: Date(timeIntervalSince1970: $0 / 1000)) } return HubSection(title: year.map { "Seizoen \(String($0))" } ?? "Seizoen", count: p.meetings.count) { ScrollViewReader { proxy in HubRail(items: p.meetings) { m in NavigationLink(value: EventHubRoute.f1Meeting(m.meetingKey)) { HubMeetingCard( title: m.title, round: m.meetingNumber, location: m.location, dateLine: HubFormat.meetingRange(m), imageUrl: m.imageUrl, theme: theme, width: 180, highlight: m.meetingKey == nextKey, dimmed: (m.endMs ?? m.startMs ?? .infinity) < nowMs ) } .buttonStyle(HubPressStyle()) .id(m.meetingKey) } .onAppear { if let nextKey { proxy.scrollTo(nextKey, anchor: .leading) } } } } } private func heroEyebrow(_ ev: ScheduleEvent, state: HubEventState) -> String { switch state { case .live: return "Nu live" case .replay: return "Nieuw om terug te kijken" case .soon, .upcoming: return HubFormat.whenLabel(ev, now: now) } } @ViewBuilder private func eventTap(_ ev: ScheduleEvent, @ViewBuilder label: () -> Label) -> some View { if canPlay(ev) { Button { play(ev) } label: { label() } .buttonStyle(HubPressStyle()) } else { NavigationLink(value: ev.id) { label() } .buttonStyle(HubPressStyle()) } } private func canPlay(_ ev: ScheduleEvent) -> Bool { F1Ids.canPlayNow(ev, now: now) && F1Ids.contentId(ev.id) != nil } private func play(_ ev: ScheduleEvent) { guard let cid = F1Ids.contentId(ev.id) else { return } let api = app.api playback.start { try await api.f1ContentPlay(contentId: cid) } } private func load(silent: Bool = false) async { do { payload = try await app.api.f1Home() error = nil } catch { if payload == nil && !silent { self.error = error.localizedDescription } } } } // MARK: - F1 meeting struct F1MeetingScreen: View { @EnvironmentObject private var app: AppModel @StateObject private var playback = HubPlayback() let meetingKey: String @State private var detail: F1MeetingDetail? @State private var error: String? @State private var now = Date() private let theme = HubTheme.f1 private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() var body: some View { HubScaffold(theme: theme, backLabel: "Formule 1", onRefresh: { await load() }) { layout in if let detail { loaded(detail, layout) } else if let error { HubMessageView( layout: layout, symbol: theme.symbol, title: "Weekend niet gevonden", message: error, retry: { await load() } ) } else { HubLoadingView(layout: layout) } } .modifier(HubPlaybackModifier(playback: playback)) .task { await load() } .onReceive(clock) { now = $0 } } @ViewBuilder private func loaded(_ d: F1MeetingDetail, _ layout: HubLayout) -> some View { let program = d.sessions.sorted { (HubFormat.start($0) ?? .distantFuture) < (HubFormat.start($1) ?? .distantFuture) } VStack(alignment: .leading, spacing: 32) { hero(d, program: program, layout) if !d.live.isEmpty { HubSection(title: "Nu live", count: d.live.count, live: true) { HubRail(items: d.live) { ev in tap(ev) { HubEventCard(event: ev, now: now, theme: theme, width: 280, showsPlayGlyph: true) } } } } if !d.replays.isEmpty { HubSection(title: "Terugkijken", count: d.replays.count) { HubRail(items: d.replays) { ev in tap(ev) { HubEventCard( event: ev, now: now, theme: theme, width: 240, fallbackImage: d.meeting?.imageUrl, showsPlayGlyph: canPlay(ev) ) } } } } if !program.isEmpty { HubSection(title: "Programma", subtitle: "Tijden in Nederlandse tijd") { HubGroupedList(items: program) { ev in tap(ev) { HubProgramRow(event: ev, now: now, theme: theme, canPlay: canPlay(ev)) } } } } } } @ViewBuilder private func hero(_ d: F1MeetingDetail, program: [ScheduleEvent], _ layout: HubLayout) -> some View { let meeting = d.meeting let title = meeting?.title.hubNonBlank ?? program.first?.name ?? "Grand Prix" let eyebrow = [meeting?.meetingNumber.map { "Ronde \($0)" }, meeting?.location?.hubNonBlank] .compactMap { $0 } .joined(separator: " · ") let image = meeting?.imageUrl?.hubNonBlank ?? program.lazy.compactMap(EventHelpers.backdropUrl).first let live = d.live.first let replay = d.replays.first let next = d.upcoming.first HubHero( theme: theme, layout: layout, imageUrl: image, eyebrow: eyebrow.isEmpty ? "Grand Prix" : eyebrow, isLive: live != nil, title: title, subtitle: meeting.flatMap(HubFormat.meetingRange) ) { if let live, canPlay(live) { Button { play(live) } label: { Label("Kijk live", systemImage: "play.fill") } .buttonStyle(HubPrimaryButtonStyle()) } else if let replay, canPlay(replay) { Button { play(replay) } label: { Label("Kijk \(replay.name)", systemImage: "play.fill") } .buttonStyle(HubPrimaryButtonStyle()) } else if let next { NavigationLink(value: next.id) { Label(HubFormat.whenLabel(next, now: now), systemImage: "clock") } .buttonStyle(HubGlassButtonStyle()) } } } @ViewBuilder private func tap(_ ev: ScheduleEvent, @ViewBuilder label: () -> Label) -> some View { if canPlay(ev) { Button { play(ev) } label: { label() } .buttonStyle(HubPressStyle()) } else { NavigationLink(value: ev.id) { label() } .buttonStyle(HubPressStyle()) } } private func canPlay(_ ev: ScheduleEvent) -> Bool { F1Ids.canPlayNow(ev, now: now) && F1Ids.contentId(ev.id) != nil } private func play(_ ev: ScheduleEvent) { guard let cid = F1Ids.contentId(ev.id) else { return } let api = app.api playback.start { try await api.f1ContentPlay(contentId: cid) } } private func load() async { do { detail = try await app.api.f1Meeting(meetingKey: meetingKey) error = nil } catch { if detail == nil { self.error = error.localizedDescription } } } } // MARK: - Viaplay hub struct ViaplayHubScreen: View { @EnvironmentObject private var app: AppModel @StateObject private var playback = HubPlayback() @State private var payload: ViaplayHubPayload? @State private var error: String? @State private var now = Date() @State private var ticks = 0 @State private var sport: String? private let theme = HubTheme.viaplay private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() var body: some View { HubScaffold(theme: theme, backLabel: "Events", onRefresh: { await load() }) { layout in if let payload { loaded(payload, layout) } else if let error { HubMessageView( layout: layout, symbol: theme.symbol, title: "Viaplay niet beschikbaar", message: error, retry: { await load() } ) } else { HubLoadingView(layout: layout) } } .modifier(HubPlaybackModifier(playback: playback)) .task { await load() } .onReceive(clock) { date in now = date ticks += 1 if ticks % 2 == 0 { Task { await load(silent: true) } } } } private func filtered(_ list: [ScheduleEvent]) -> [ScheduleEvent] { guard let sport else { return list } return list.filter { ($0.sportBucket ?? "").caseInsensitiveCompare(sport) == .orderedSame } } @ViewBuilder private func loaded(_ p: ViaplayHubPayload, _ layout: HubLayout) -> some View { let live = filtered(p.live) let today = filtered(p.today) let weekend = filtered(p.weekend) let later = filtered(p.later) let catchup = filtered(p.catchup) let rails = p.rails.filter { !$0.items.isEmpty } let noEvents = live.isEmpty && today.isEmpty && weekend.isEmpty && later.isEmpty && catchup.isEmpty VStack(alignment: .leading, spacing: 28) { hero(featured: live.first ?? today.first ?? weekend.first ?? later.first, layout) if p.sports.count > 1 { sportChips(p.sports) } if noEvents { ContentUnavailableView( sport == nil ? "Geen events gepland" : "Niets gevonden", systemImage: "calendar.badge.exclamationmark", description: Text( sport == nil ? "Er staan geen Viaplay-uitzendingen in de agenda." : "Geen uitzendingen voor deze sport. Kies een andere sport of bekijk alles." ) ) } eventRail("Nu live", live, width: 280, live: true) eventRail("Vandaag", today, width: 240) eventRail("Dit weekend", weekend, width: 240) eventRail("Binnenkort", later, width: 240) eventRail("Terugkijken", catchup, width: 240) ForEach(rails) { rail in HubSection(title: rail.title) { HubRail(items: rail.items) { item in itemTap(item) { HubContentCard(item: item, theme: theme, width: 240) } } } } } } @ViewBuilder private func hero(featured: ScheduleEvent?, _ layout: HubLayout) -> some View { if let featured { let state = HubFormat.state(featured, now: now) HubHero( theme: theme, layout: layout, imageUrl: EventHelpers.backdropUrl(featured), eyebrow: state == .live ? "Nu live" : [featured.resolvedSportLabel, HubFormat.whenLabel(featured, now: now)].joined(separator: " · "), isLive: state == .live, title: featured.name, subtitle: featured.subtitle?.hubNonBlank ?? EventHelpers.displayChannel(featured) ) { if state == .live { Button { playEvent(featured) } label: { Label("Kijk live", systemImage: "play.fill") } .buttonStyle(HubPrimaryButtonStyle()) NavigationLink(value: featured.id) { Label("Details", systemImage: "info.circle") } .buttonStyle(HubGlassButtonStyle()) } else { NavigationLink(value: featured.id) { Label("Details", systemImage: "info.circle") } .buttonStyle(HubPrimaryButtonStyle()) } } } else { HubHero( theme: theme, layout: layout, imageUrl: nil, eyebrow: "Viaplay", isLive: false, title: "Viaplay Sport", subtitle: "Live sport, samenvattingen en meer" ) { EmptyView() } } } private func sportChips(_ sports: [ViaplaySport]) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { HubChip(label: "Alles", count: nil, selected: sport == nil) { withAnimation(.snappy) { sport = nil } } ForEach(sports) { s in HubChip(label: s.label, count: s.count, selected: sport == s.id) { withAnimation(.snappy) { sport = (sport == s.id) ? nil : s.id } } } } } .contentMargins(.horizontal, 20, for: .scrollContent) .sensoryFeedback(.selection, trigger: sport) } @ViewBuilder private func eventRail(_ title: String, _ items: [ScheduleEvent], width: CGFloat, live: Bool = false) -> some View { if !items.isEmpty { HubSection(title: title, count: items.count, live: live) { HubRail(items: items) { ev in NavigationLink(value: ev.id) { HubEventCard(event: ev, now: now, theme: theme, width: width, showsSport: true) } .buttonStyle(HubPressStyle()) } } } } @ViewBuilder private func itemTap(_ item: ViaplayHubItem, @ViewBuilder label: () -> Label) -> some View { if item.opensAsEvent { NavigationLink(value: item.id) { label() } .buttonStyle(HubPressStyle()) } else { Button { playContent(item) } label: { label() } .buttonStyle(HubPressStyle()) } } private func playEvent(_ ev: ScheduleEvent) { let api = app.api let id = ev.id playback.start { try await api.scheduleEventPlay(eventId: id) } } private func playContent(_ item: ViaplayHubItem) { let api = app.api let guid = item.guid let productUrl = item.pageUrl playback.start { try await api.viaplayContentPlay(guid: guid, productUrl: productUrl) } } private func load(silent: Bool = false) async { do { let p = try await app.api.viaplayHome() payload = p error = nil if let sport, !p.sports.contains(where: { $0.id == sport }) { self.sport = nil } } catch { if payload == nil && !silent { self.error = error.localizedDescription } } } } // MARK: - Playback @MainActor final class HubPlayback: ObservableObject { @Published var info: IptvPlayInfo? @Published var preparing = false @Published var error: String? @Published var requests = 0 func start(_ load: @escaping () async throws -> IptvPlayInfo) { guard !preparing else { return } preparing = true requests += 1 Task { defer { preparing = false } do { info = try await load() } catch { self.error = error.localizedDescription } } } } private struct HubPlaybackModifier: ViewModifier { @EnvironmentObject private var app: AppModel @ObservedObject var playback: HubPlayback func body(content: Content) -> some View { content .overlay { if playback.preparing { VStack(spacing: 12) { ProgressView() .controlSize(.large) .tint(.white) Text("Stream voorbereiden…") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.white.opacity(0.85)) } .padding(.horizontal, 28) .padding(.vertical, 22) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .environment(\.colorScheme, .dark) .transition(.opacity.combined(with: .scale(scale: 0.94))) } } .animation(.easeOut(duration: 0.2), value: playback.preparing) .sensoryFeedback(.impact(flexibility: .soft), trigger: playback.requests) .fullScreenCover(isPresented: Binding( get: { playback.info != nil }, set: { if !$0 { playback.info = nil } } )) { if let info = playback.info, let url = URL(string: info.streamUrl) { let sid = info.streamId let viaplay = ViaplayPlaybackHeaders.matches(url) || sid.localizedCaseInsensitiveContains("viaplay") PlayerScreen(request: .live( url: url, title: info.name, fallback: info.fallbackStreamUrl.flatMap(URL.init(string:)), format: info.format, drm: info.drm, watchSessionId: info.watchSessionId, watchKind: "event", watchId: viaplay ? (sid.isEmpty ? "viaplay" : sid) : (sid.isEmpty ? nil : sid), logoUrl: info.logoUrl, isLive: info.live ?? true )) .environmentObject(app) } } .alert("Afspelen mislukt", isPresented: Binding( get: { playback.error != nil }, set: { if !$0 { playback.error = nil } } )) { Button("OK", role: .cancel) { playback.error = nil } } message: { Text(playback.error ?? "") } } } // MARK: - Scaffold struct HubLayout { let width: CGFloat let height: CGFloat let topInset: CGFloat } private struct HubScaffold: View { let theme: HubTheme let backLabel: String var onRefresh: (() async -> Void)? @ViewBuilder let content: (HubLayout) -> Content @Environment(\.dismiss) private var dismiss var body: some View { GeometryReader { geo in let layout = HubLayout(width: geo.size.width, height: geo.size.height, topInset: geo.safeAreaInsets.top) ZStack(alignment: .topLeading) { background.ignoresSafeArea() scroll(layout) backButton .padding(.leading, 16) .padding(.top, 6) } } .environment(\.colorScheme, .dark) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) } @ViewBuilder private func scroll(_ layout: HubLayout) -> some View { let base = ScrollView(.vertical, showsIndicators: false) { content(layout) .padding(.bottom, 40) } .ignoresSafeArea(edges: .top) if let onRefresh { base.refreshable { await onRefresh() } } else { base } } private var background: some View { ZStack { theme.base RadialGradient( colors: [theme.accent2.opacity(0.16), .clear], center: .bottomLeading, startRadius: 10, endRadius: 420 ) } } private var backButton: some View { Button { dismiss() } label: { HStack(spacing: 5) { Image(systemName: "chevron.left") .font(.system(size: 15, weight: .semibold)) Text(backLabel) .font(.system(size: 15, weight: .medium)) } .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 36) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(0.14), lineWidth: 1)) } .buttonStyle(HubPressStyle()) } } private struct HubLoadingView: View { let layout: HubLayout var body: some View { VStack(spacing: 14) { ProgressView() .controlSize(.large) .tint(.white) Text("Laden…") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.white.opacity(0.6)) } .frame(width: layout.width, height: layout.height) } } private struct HubMessageView: View { let layout: HubLayout let symbol: String let title: String let message: String let retry: () async -> Void var body: some View { ContentUnavailableView { Label(title, systemImage: symbol) } description: { Text(message) } actions: { Button { Task { await retry() } } label: { Text("Opnieuw proberen") } .buttonStyle(HubPrimaryButtonStyle()) } .frame(width: layout.width, height: layout.height * 0.9) } } // MARK: - Hero private struct HubHero: View { let theme: HubTheme let layout: HubLayout let imageUrl: String? let eyebrow: String let isLive: Bool let title: String let subtitle: String? @ViewBuilder let actions: () -> Actions private var height: CGFloat { layout.topInset + min(max(layout.width * 0.92, 340), 500) } var body: some View { ZStack(alignment: .bottomLeading) { HubArtwork(url: imageUrl, theme: theme, width: layout.width, height: height) LinearGradient( stops: [ .init(color: .black.opacity(0.5), location: 0), .init(color: .clear, location: 0.22), .init(color: .clear, location: 0.42), .init(color: theme.base.opacity(0.82), location: 0.76), .init(color: theme.base, location: 1), ], startPoint: .top, endPoint: .bottom ) VStack(alignment: .leading, spacing: 10) { HubWordmark(kind: theme.kind, size: 22) .foregroundStyle(theme.kind == .f1 ? theme.accent : .white) .shadow(color: .black.opacity(0.4), radius: 6, y: 2) HStack(spacing: 8) { if isLive { HubStateBadge(state: .live) } Text(eyebrow.uppercased()) .font(.system(size: 12, weight: .semibold)) .tracking(0.8) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) } Text(title) .font(.system(size: 30, weight: .bold)) .foregroundStyle(.white) .lineLimit(3) .minimumScaleFactor(0.75) .fixedSize(horizontal: false, vertical: true) if let subtitle = subtitle?.hubNonBlank { Text(subtitle) .font(.system(size: 15, weight: .regular)) .foregroundStyle(.white.opacity(0.72)) .lineLimit(2) } HStack(spacing: 10) { actions() } .padding(.top, 6) } .padding(.horizontal, 20) .padding(.bottom, 4) } .frame(width: layout.width, height: height) .clipped() } } // MARK: - Sections & rails private struct HubSection: View { let title: String var subtitle: String? var count: Int? var live: Bool = false @ViewBuilder let content: () -> Content var body: some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(title) .font(.system(size: 22, weight: .bold)) .foregroundStyle(.white) if live { HubLiveDot(size: 8, color: HubTheme.liveRed) } Spacer(minLength: 8) if let count { Text("\(count)") .font(.system(size: 13, weight: .semibold)) .monospacedDigit() .foregroundStyle(.white.opacity(0.4)) } } if let subtitle { Text(subtitle) .font(.system(size: 13)) .foregroundStyle(.white.opacity(0.5)) } } .padding(.horizontal, 20) content() } } } private struct HubRail: View where Items.Element: Identifiable { let items: Items @ViewBuilder let card: (Items.Element) -> Card var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 14) { ForEach(items) { item in card(item) } } .scrollTargetLayout() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollTargetBehavior(.viewAligned) } } private struct HubGroupedList: View { let items: [ScheduleEvent] @ViewBuilder let row: (ScheduleEvent) -> Row var body: some View { VStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.id) { index, ev in row(ev) if index < items.count - 1 { Rectangle() .fill(.white.opacity(0.08)) .frame(height: 0.5) .padding(.leading, 82) } } } .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(.white.opacity(0.06), lineWidth: 1) ) .padding(.horizontal, 16) } } // MARK: - Cards private struct HubEventCard: View { let event: ScheduleEvent let now: Date let theme: HubTheme var width: CGFloat = 260 var fallbackImage: String? var showsPlayGlyph: Bool = false var showsSport: Bool = false private var height: CGFloat { (width * 9 / 16).rounded() } var body: some View { VStack(alignment: .leading, spacing: 10) { artwork VStack(alignment: .leading, spacing: 3) { Text(event.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .lineLimit(2) .multilineTextAlignment(.leading) Text(metaLine) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.55)) .lineLimit(1) } } .frame(width: width, alignment: .leading) } private var metaLine: String { var parts: [String] = [] if showsSport { parts.append(event.resolvedSportLabel) } if let sub = event.subtitle?.hubNonBlank, sub.caseInsensitiveCompare(event.name) != .orderedSame { parts.append(sub) } let when = HubFormat.whenLabel(event, now: now) if !when.isEmpty { parts.append(when) } return parts.joined(separator: " · ") } private var artwork: some View { let state = HubFormat.state(event, now: now) let progress = HubFormat.progress(event, now: now) return ZStack(alignment: .bottomLeading) { HubArtwork( url: EventHelpers.artUrl(event) ?? fallbackImage?.hubNonBlank, theme: theme, width: width, height: height ) LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .center, endPoint: .bottom) VStack(alignment: .leading, spacing: 0) { HubStateBadge(state: state) Spacer(minLength: 0) HStack(alignment: .bottom) { if let progress { ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.28)) Capsule().fill(.white) .frame(width: max(4, (width - 64) * progress)) } .frame(width: width - 64, height: 3) } Spacer(minLength: 0) if showsPlayGlyph { Image(systemName: "play.fill") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.black) .frame(width: 30, height: 30) .background(.white, in: Circle()) .shadow(color: .black.opacity(0.3), radius: 4, y: 2) } } } .padding(10) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(state == .live ? HubTheme.liveRed.opacity(0.5) : .white.opacity(0.08), lineWidth: 1) ) .shadow(color: .black.opacity(0.35), radius: 10, y: 6) } } private struct HubContentCard: View { let item: ViaplayHubItem let theme: HubTheme var width: CGFloat = 240 private var height: CGFloat { (width * 9 / 16).rounded() } var body: some View { VStack(alignment: .leading, spacing: 10) { ZStack(alignment: .bottomLeading) { HubArtwork(url: item.imageUrl, theme: theme, width: width, height: height) LinearGradient(colors: [.clear, .black.opacity(0.5)], startPoint: .center, endPoint: .bottom) VStack(alignment: .leading, spacing: 0) { if let badge = item.badge?.hubNonBlank { Text(badge.uppercased()) .font(.system(size: 10, weight: .bold)) .tracking(0.4) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) } Spacer(minLength: 0) HStack { Spacer(minLength: 0) if let duration = item.durationLabel?.hubNonBlank { Text(duration) .font(.system(size: 11, weight: .semibold)) .monospacedDigit() .foregroundStyle(.white) .padding(.horizontal, 7) .padding(.vertical, 3) .background(.black.opacity(0.6), in: Capsule()) } } } .padding(10) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(.white.opacity(0.08), lineWidth: 1) ) .shadow(color: .black.opacity(0.35), radius: 10, y: 6) VStack(alignment: .leading, spacing: 3) { Text(item.title) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .lineLimit(2) .multilineTextAlignment(.leading) if let sub = item.subtitle?.hubNonBlank { Text(sub) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.55)) .lineLimit(1) } } } .frame(width: width, alignment: .leading) } } private struct HubMeetingCard: View { let title: String let round: Int? let location: String? let dateLine: String? let imageUrl: String? let theme: HubTheme var width: CGFloat = 200 var highlight: Bool = false var dimmed: Bool = false private var height: CGFloat { (width * 1.3).rounded() } var body: some View { ZStack(alignment: .bottomLeading) { HubArtwork(url: imageUrl, theme: theme, width: width, height: height) LinearGradient( stops: [ .init(color: .clear, location: 0.3), .init(color: .black.opacity(0.88), location: 1), ], startPoint: .top, endPoint: .bottom ) VStack(alignment: .leading, spacing: 4) { if let round { Text("RONDE \(round)") .font(.system(size: 11, weight: .heavy)) .tracking(1) .foregroundStyle(theme.accent) } Text(title) .font(.system(size: 17, weight: .bold)) .foregroundStyle(.white) .lineLimit(3) .minimumScaleFactor(0.8) .multilineTextAlignment(.leading) if let location = location?.hubNonBlank { Text(location) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.7)) .lineLimit(1) } if let dateLine = dateLine?.hubNonBlank { Text(dateLine) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white.opacity(0.9)) .lineLimit(1) } } .padding(14) if highlight { Text("VOLGENDE") .font(.system(size: 10, weight: .heavy)) .tracking(0.8) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(theme.accent, in: Capsule()) .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } .frame(width: width, height: height) .saturation(dimmed ? 0.3 : 1) .opacity(dimmed ? 0.75 : 1) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(highlight ? theme.accent : .white.opacity(0.08), lineWidth: highlight ? 2 : 1) ) .shadow(color: highlight ? theme.accent.opacity(0.3) : .black.opacity(0.35), radius: 12, y: 6) } } private struct HubProgramRow: View { let event: ScheduleEvent let now: Date let theme: HubTheme let canPlay: Bool var body: some View { let start = HubFormat.start(event) let state = HubFormat.state(event, now: now) HStack(spacing: 14) { VStack(spacing: 2) { Text(start.map { HubFormat.weekday.string(from: $0).uppercased() } ?? "") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white.opacity(0.5)) Text(start.map { HubFormat.time.string(from: $0) } ?? "–") .font(.system(size: 17, weight: .semibold)) .monospacedDigit() .foregroundStyle(.white) } .frame(width: 54) VStack(alignment: .leading, spacing: 3) { Text(event.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .lineLimit(2) .multilineTextAlignment(.leading) Text(detail(state: state, start: start)) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.5)) .lineLimit(1) } Spacer(minLength: 8) if state == .live { HubStateBadge(state: .live) } else if canPlay { Image(systemName: "play.circle.fill") .font(.system(size: 28)) .symbolRenderingMode(.palette) .foregroundStyle(.white, theme.accent) } else { Image(systemName: "chevron.right") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white.opacity(0.3)) } } .padding(.horizontal, 14) .padding(.vertical, 12) .contentShape(Rectangle()) } private func detail(state: HubEventState, start: Date?) -> String { switch state { case .live: return "Nu live" case .replay: return "Terugkijken" case .soon(let m): return "Begint over \(m) min" case .upcoming: return start.map { HubFormat.dayMonth.string(from: $0) } ?? "" } } } // MARK: - Small components private struct HubArtwork: View { let url: String? let theme: HubTheme let width: CGFloat let height: CGFloat var body: some View { Group { if let url = url?.hubNonBlank { RemoteImage(url: url, contentMode: .fill) } else { ZStack { LinearGradient( colors: [theme.accent.opacity(0.6), theme.accent2.opacity(0.45), theme.base], startPoint: .topLeading, endPoint: .bottomTrailing ) HubWordmark(kind: theme.kind, size: max(18, min(width, height) * 0.22)) .foregroundStyle(.white.opacity(0.35)) } } } .frame(width: width, height: height) .clipped() } } private struct HubWordmark: View { let kind: HubTheme.Kind var size: CGFloat = 20 var body: some View { switch kind { case .f1: Text("F1") .font(.system(size: size, weight: .black).italic()) .tracking(-0.5) case .viaplay: Text("viaplay") .font(.system(size: size * 0.9, weight: .heavy, design: .rounded)) .tracking(-0.3) } } } enum HubEventState: Equatable { case live case soon(Int) case replay case upcoming } private struct HubStateBadge: View { let state: HubEventState var body: some View { switch state { case .live: HStack(spacing: 5) { HubLiveDot(size: 6, color: .white) Text("LIVE") } .modifier(BadgeText(foreground: .white)) .background(HubTheme.liveRed, in: Capsule()) case .soon(let m): Text(m <= 0 ? "START NU" : "OVER \(m) MIN") .modifier(BadgeText(foreground: .black)) .background(HubTheme.soonAmber, in: Capsule()) case .replay: HStack(spacing: 4) { Image(systemName: "arrow.counterclockwise") .font(.system(size: 9, weight: .bold)) Text("REPLAY") } .modifier(BadgeText(foreground: .white)) .background(.ultraThinMaterial, in: Capsule()) case .upcoming: EmptyView() } } private struct BadgeText: ViewModifier { let foreground: Color func body(content: Content) -> some View { content .font(.system(size: 10, weight: .bold)) .tracking(0.4) .foregroundStyle(foreground) .padding(.horizontal, 8) .padding(.vertical, 4) } } } private struct HubLiveDot: View { var size: CGFloat = 6 var color: Color = .white @State private var on = false var body: some View { Circle() .fill(color) .frame(width: size, height: size) .opacity(on ? 1 : 0.35) .onAppear { withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { on = true } } } } private struct HubChip: View { let label: String let count: Int? let selected: Bool let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 6) { Text(label) .font(.system(size: 14, weight: .semibold)) if let count, count > 0 { Text("\(count)") .font(.system(size: 12, weight: .semibold)) .monospacedDigit() .opacity(0.55) } } .foregroundStyle(selected ? .black : .white) .padding(.horizontal, 14) .frame(height: 34) .background(selected ? Color.white : Color.white.opacity(0.1), in: Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(selected ? 0 : 0.1), lineWidth: 1)) } .buttonStyle(HubPressStyle()) } } // MARK: - Button styles struct HubPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : 1) .opacity(configuration.isPressed ? 0.9 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) } } private struct HubPrimaryButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 16, weight: .semibold)) .lineLimit(1) .foregroundStyle(.black) .padding(.horizontal, 22) .frame(height: 46) .background(.white, in: Capsule()) .scaleEffect(configuration.isPressed ? 0.96 : 1) .opacity(configuration.isPressed ? 0.85 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) } } private struct HubGlassButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 16, weight: .semibold)) .lineLimit(1) .foregroundStyle(.white) .padding(.horizontal, 20) .frame(height: 46) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(0.18), lineWidth: 1)) .scaleEffect(configuration.isPressed ? 0.96 : 1) .opacity(configuration.isPressed ? 0.85 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) } } // MARK: - Helpers private enum F1Ids { static func contentId(_ eventId: String) -> String? { let t = eventId.trimmingCharacters(in: .whitespacesAndNewlines) let raw = t.lowercased().hasPrefix("f1tv:") ? String(t.dropFirst(5)) : t return !raw.isEmpty && raw.allSatisfy(\.isNumber) ? raw : nil } static func canPlayNow(_ ev: ScheduleEvent, now: Date) -> Bool { if EventHelpers.isLive(ev, now: now) || ev.isPlayable { return true } if (ev.eventType ?? "").lowercased() == "replay" { return true } return ev.phaseValue == "ended" } } enum HubFormat { private static func make(_ format: String) -> DateFormatter { let f = DateFormatter() f.locale = Locale(identifier: "nl_NL") f.timeZone = EventHelpers.zone f.dateFormat = format return f } static let time = make("HH:mm") static let weekday = make("EEE") static let dayMonth = make("EEE d MMM") static let shortDay = make("d MMM") private static var calendar: Calendar { var c = Calendar(identifier: .gregorian) c.timeZone = EventHelpers.zone return c } static func start(_ e: ScheduleEvent) -> Date? { EventHelpers.parse(e.startLocal, e.start) } static func stop(_ e: ScheduleEvent) -> Date? { EventHelpers.parse(e.stopLocal, e.stop) } static func state(_ e: ScheduleEvent, now: Date) -> HubEventState { if EventHelpers.isLive(e, now: now) { return .live } if e.phaseValue == "ended" || (e.eventType ?? "").lowercased() == "replay" { return .replay } if let s = start(e) { let minutes = Int(s.timeIntervalSince(now) / 60) if minutes >= 0 && minutes < 60 { return .soon(minutes) } } return .upcoming } static func whenLabel(_ e: ScheduleEvent, now: Date) -> String { if EventHelpers.isLive(e, now: now) { if let s = stop(e) { return "Live tot \(time.string(from: s))" } return "Nu live" } guard let s = start(e) else { return "" } let cal = calendar let t = time.string(from: s) if cal.isDate(s, inSameDayAs: now) { return "Vandaag \(t)" } if let d = cal.date(byAdding: .day, value: 1, to: now), cal.isDate(s, inSameDayAs: d) { return "Morgen \(t)" } if let d = cal.date(byAdding: .day, value: -1, to: now), cal.isDate(s, inSameDayAs: d) { return "Gisteren \(t)" } return "\(dayMonth.string(from: s)) · \(t)" } static func progress(_ e: ScheduleEvent, now: Date) -> Double? { guard EventHelpers.isLive(e, now: now), let s = start(e), let t = stop(e), t > s else { return nil } return min(1, max(0, now.timeIntervalSince(s) / t.timeIntervalSince(s))) } static func meetingRange(_ m: F1Meeting) -> String? { guard let startMs = m.startMs, startMs > 0 else { return nil } let a = Date(timeIntervalSince1970: startMs / 1000) let b = Date(timeIntervalSince1970: (m.endMs.flatMap { $0 > 0 ? $0 : nil } ?? startMs) / 1000) let cal = calendar if cal.isDate(a, inSameDayAs: b) { return shortDay.string(from: a) } if cal.component(.month, from: a) == cal.component(.month, from: b) { return "\(cal.component(.day, from: a))–\(shortDay.string(from: b))" } return "\(shortDay.string(from: a)) – \(shortDay.string(from: b))" } static func uniqueById(_ list: [ScheduleEvent]) -> [ScheduleEvent] { var seen = Set() return list.filter { seen.insert($0.id).inserted } } } private extension String { var hubNonBlank: String? { let t = trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } }