stremio/apps/ios/VMC/UI/EventHubsScreens.swift
Jos Vooges | STH 084bcdaeff Use Chrome/122 on every Viaplay CDN hop, like the standalone MPD player.
iOS only sent that UA when the hostname looked like viaplay; stored MPDs often do not. Event plays now pin the browser session for the whole fetch chain, still direct to the CDN.
2026-09-28 21:19:22 +02:00

1639 lines
58 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Label: View>(_ 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<Label: View>(_ 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<Label: View>(_ 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<Content: View>: 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<Actions: View>: 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<Content: View>: 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<Items: RandomAccessCollection, Card: View>: 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<Row: View>: 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<String>()
return list.filter { seen.insert($0.id).inserted }
}
}
private extension String {
var hubNonBlank: String? {
let t = trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? nil : t
}
}