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.
1639 lines
58 KiB
Swift
1639 lines
58 KiB
Swift
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
|
||
}
|
||
}
|