From 2da59616e834865b7234e5d6963df4662ef95bce Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 27 Sep 2026 23:58:07 +0200 Subject: [PATCH] iOS 0.5.42: F1 and Viaplay hubs under Events (no player changes). --- apps/ios/VMC.xcodeproj/project.pbxproj | 12 +- apps/ios/VMC/Data/ApiClient.swift | 33 + apps/ios/VMC/Data/Models.swift | 169 +++ apps/ios/VMC/UI/EventHubsScreens.swift | 1635 ++++++++++++++++++++++++ apps/ios/VMC/UI/EventsScreen.swift | 25 + apps/ios/VMC/UI/SettingsScreen.swift | 2 +- 6 files changed, 1871 insertions(+), 5 deletions(-) create mode 100644 apps/ios/VMC/UI/EventHubsScreens.swift diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 0f05ef7..299501d 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; }; A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; }; E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; }; + B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */; }; A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; }; B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; }; B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; }; @@ -69,6 +70,7 @@ F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = ""; }; 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = ""; }; F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = ""; }; + A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = ""; }; 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = ""; }; D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; @@ -186,6 +188,7 @@ D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */, 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */, F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */, + A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */, 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */, C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */, ); @@ -291,6 +294,7 @@ B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */, A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */, E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */, + B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */, A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */, B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */, ); @@ -341,7 +345,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -356,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.41; + MARKETING_VERSION = 0.5.42; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -374,7 +378,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -389,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.41; + MARKETING_VERSION = 0.5.42; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index 2c83d59..ee91dc1 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -344,6 +344,39 @@ actor ApiClient { return try await get("/api/v1/client/events/\(enc)/play") } + func eventHubs() async throws -> [EventHub] { + struct Wrap: Decodable { let hubs: [EventHub]? } + let wrap: Wrap = try await get("/api/v1/client/hubs") + return wrap.hubs ?? [] + } + + func f1Home() async throws -> F1HubPayload { + try await get("/api/v1/client/f1/home") + } + + func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail { + let enc = Self.encodePathSegment(meetingKey) + return try await get("/api/v1/client/f1/meetings/\(enc)") + } + + func f1ContentPlay(contentId: String) async throws -> IptvPlayInfo { + let enc = Self.encodePathSegment(contentId) + return try await get("/api/v1/client/f1/content/\(enc)/play") + } + + func viaplayHome() async throws -> ViaplayHubPayload { + try await get("/api/v1/client/viaplay/home") + } + + func viaplayContentPlay(guid: String, productUrl: String?) async throws -> IptvPlayInfo { + let enc = Self.encodePathSegment(guid) + var q = "" + if let productUrl = productUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !productUrl.isEmpty { + q = "?productUrl=\(Self.encodePathSegment(productUrl))" + } + return try await get("/api/v1/client/viaplay/content/\(enc)/play\(q)") + } + /// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed. private static func encodePathSegment(_ raw: String) -> String { var allowed = CharacterSet.alphanumerics diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index ac82128..aca32e8 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -593,3 +593,172 @@ struct ApiErrorBody: Decodable { let error: ErrorObj? } +// MARK: - Event hubs (F1 / Viaplay) + +struct EventHub: Decodable, Identifiable, Hashable { + let id: String + let label: String + let subtitle: String? +} + +struct F1Meeting: Decodable, Identifiable, Hashable { + var id: String { meetingKey } + let meetingKey: String + let title: String + let meetingNumber: Int? + let location: String? + let startMs: Double? + let endMs: Double? + let imageUrl: String? + + enum CodingKeys: String, CodingKey { + case meetingKey, title, meetingNumber, location, startMs, endMs, imageUrl + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + meetingKey = try decodeFlexibleString(c, forKey: .meetingKey) + title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" + meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber) + location = try c.decodeIfPresent(String.self, forKey: .location) + startMs = try? c.decodeIfPresent(Double.self, forKey: .startMs) + endMs = try? c.decodeIfPresent(Double.self, forKey: .endMs) + imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl) + } +} + +struct F1ReplayGroup: Decodable, Identifiable, Hashable { + var id: String { meetingKey } + let meetingKey: String + let title: String + let location: String? + let meetingNumber: Int? + let imageUrl: String? + let latestStart: String? + let sessions: [ScheduleEvent] + + enum CodingKeys: String, CodingKey { + case meetingKey, title, location, meetingNumber, imageUrl, latestStart, sessions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + meetingKey = try decodeFlexibleString(c, forKey: .meetingKey) + title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" + location = try c.decodeIfPresent(String.self, forKey: .location) + meetingNumber = try? c.decodeIfPresent(Int.self, forKey: .meetingNumber) + imageUrl = try c.decodeIfPresent(String.self, forKey: .imageUrl) + latestStart = try c.decodeIfPresent(String.self, forKey: .latestStart) + sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? [] + } +} + +struct F1HubPayload: Decodable { + let live: [ScheduleEvent] + let upcoming: [ScheduleEvent] + let recentReplays: [ScheduleEvent] + let replayGroups: [F1ReplayGroup] + let meetings: [F1Meeting] + let nextMeeting: F1Meeting? + + enum CodingKeys: String, CodingKey { + case live, upcoming, recentReplays, replayGroups, meetings, nextMeeting + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] + upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? [] + recentReplays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .recentReplays) ?? [] + replayGroups = try c.decodeIfPresent([F1ReplayGroup].self, forKey: .replayGroups) ?? [] + meetings = try c.decodeIfPresent([F1Meeting].self, forKey: .meetings) ?? [] + nextMeeting = try c.decodeIfPresent(F1Meeting.self, forKey: .nextMeeting) + } +} + +struct F1MeetingDetail: Decodable { + let meeting: F1Meeting? + let sessions: [ScheduleEvent] + let replays: [ScheduleEvent] + let upcoming: [ScheduleEvent] + let live: [ScheduleEvent] + + enum CodingKeys: String, CodingKey { + case meeting, sessions, replays, upcoming, live + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + meeting = try c.decodeIfPresent(F1Meeting.self, forKey: .meeting) + sessions = try c.decodeIfPresent([ScheduleEvent].self, forKey: .sessions) ?? [] + replays = try c.decodeIfPresent([ScheduleEvent].self, forKey: .replays) ?? [] + upcoming = try c.decodeIfPresent([ScheduleEvent].self, forKey: .upcoming) ?? [] + live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] + } +} + +struct ViaplaySport: Decodable, Identifiable, Hashable { + let id: String + let label: String + let count: Int? +} + +struct ViaplayHubItem: Decodable, Identifiable, Hashable { + let id: String + let guid: String + let title: String + let subtitle: String? + let imageUrl: String? + let kind: String? + let openAs: String? + let playable: Bool? + let durationLabel: String? + let start: String? + let badge: String? + let pageUrl: String? + + var opensAsEvent: Bool { (openAs ?? "").lowercased() == "event" } +} + +struct ViaplayHubRail: Decodable, Identifiable, Hashable { + let id: String + let title: String + let items: [ViaplayHubItem] + + enum CodingKeys: String, CodingKey { case id, title, items } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decodeIfPresent(String.self, forKey: .title) ?? "" + items = (try? c.decodeIfPresent([ViaplayHubItem].self, forKey: .items)) ?? [] + } +} + +struct ViaplayHubPayload: Decodable { + let live: [ScheduleEvent] + let today: [ScheduleEvent] + let weekend: [ScheduleEvent] + let later: [ScheduleEvent] + let catchup: [ScheduleEvent] + let sports: [ViaplaySport] + let events: [ScheduleEvent] + let rails: [ViaplayHubRail] + + enum CodingKeys: String, CodingKey { + case live, today, weekend, later, catchup, sports, events, rails + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + live = try c.decodeIfPresent([ScheduleEvent].self, forKey: .live) ?? [] + today = try c.decodeIfPresent([ScheduleEvent].self, forKey: .today) ?? [] + weekend = try c.decodeIfPresent([ScheduleEvent].self, forKey: .weekend) ?? [] + later = try c.decodeIfPresent([ScheduleEvent].self, forKey: .later) ?? [] + catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? [] + sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? [] + events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? [] + rails = try c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails) ?? [] + } +} + diff --git a/apps/ios/VMC/UI/EventHubsScreens.swift b/apps/ios/VMC/UI/EventHubsScreens.swift new file mode 100644 index 0000000..47cf8a4 --- /dev/null +++ b/apps/ios/VMC/UI/EventHubsScreens.swift @@ -0,0 +1,1635 @@ +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) { + 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: info.streamId.isEmpty ? nil : info.streamId, + logoUrl: info.logoUrl + )) + .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 + } +} diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift index cb8dbc6..012f029 100644 --- a/apps/ios/VMC/UI/EventsScreen.swift +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -19,6 +19,7 @@ struct EventsScreen: View { @EnvironmentObject private var app: AppModel @State private var events: [ScheduleEvent] = [] + @State private var hubs: [EventHub] = [] @State private var loading = true @State private var error: String? @State private var sportFilter: String? @@ -93,6 +94,10 @@ struct EventsScreen: View { EventDetailScreen(eventId: eventId) .environmentObject(app) } + .navigationDestination(for: EventHubRoute.self) { route in + EventHubDestination(route: route) + .environmentObject(app) + } } .task { await refresh() } .onAppear { @@ -149,6 +154,9 @@ struct EventsScreen: View { ScrollView { VStack(alignment: .leading, spacing: 22) { headerBlock + if !hubs.isEmpty { + hubRow + } filterChips if filtered.isEmpty { emptyBlock @@ -215,6 +223,20 @@ struct EventsScreen: View { .padding(.top, 8) } + private var hubRow: some View { + HStack(spacing: 12) { + ForEach(hubs) { hub in + if let route = EventHubRoute.forHub(hub.id) { + NavigationLink(value: route) { + EventHubEntryCard(hub: hub) + } + .buttonStyle(HubPressStyle()) + } + } + } + .padding(.horizontal, 16) + } + private var subtitleLine: String { if let sportFilter { return EventSport.label(for: sportFilter) @@ -359,6 +381,9 @@ struct EventsScreen: View { self.error = error.localizedDescription } } + if let list = try? await app.api.eventHubs() { + hubs = list.filter { EventHubRoute.forHub($0.id) != nil } + } loading = false } } diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 11c8d38..791ab68 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -96,7 +96,7 @@ struct SettingsScreen: View { Section("Info") { LabeledContent("App", value: "VMC iOS") - LabeledContent("Versie", value: "0.5.41") + LabeledContent("Versie", value: "0.5.42") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") }