1635 lines
58 KiB
Swift
1635 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) {
|
||
PlayerScreen(request: .live(
|
||
url: url,
|
||
title: info.name,
|
||
fallback: info.fallbackStreamUrl.flatMap(URL.init(string:)),
|
||
format: info.format,
|
||
drm: info.drm,
|
||
watchSessionId: info.watchSessionId,
|
||
watchKind: "event",
|
||
watchId: info.streamId.isEmpty ? nil : info.streamId,
|
||
logoUrl: info.logoUrl
|
||
))
|
||
.environmentObject(app)
|
||
}
|
||
}
|
||
.alert("Afspelen mislukt", isPresented: Binding(
|
||
get: { playback.error != nil },
|
||
set: { if !$0 { playback.error = nil } }
|
||
)) {
|
||
Button("OK", role: .cancel) { playback.error = nil }
|
||
} message: {
|
||
Text(playback.error ?? "")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Scaffold
|
||
|
||
struct HubLayout {
|
||
let width: CGFloat
|
||
let height: CGFloat
|
||
let topInset: CGFloat
|
||
}
|
||
|
||
private struct HubScaffold<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
|
||
}
|
||
}
|