Stop replacing working FAAB thumbs with Gracenote URLs that 404, and only enrich highlights after verifying EventImages exist.
1380 lines
53 KiB
Swift
1380 lines
53 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Stadium palette (alleen Events)
|
||
|
||
private enum EventsLook {
|
||
static let neon = Color(red: 0.0, green: 0.91, blue: 0.627) // #00E8A0
|
||
static let neonSoft = Color(red: 0.0, green: 0.91, blue: 0.627).opacity(0.18)
|
||
static let live = Color(red: 1.0, green: 0.176, blue: 0.333) // #FF2D55
|
||
static let liveSoft = Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.22)
|
||
static let soon = Color(red: 1.0, green: 0.69, blue: 0.125) // #FFB020
|
||
static let deep = Color(red: 0.027, green: 0.078, blue: 0.063) // #071410
|
||
static let panel = Color(red: 0.071, green: 0.102, blue: 0.094) // #121A18
|
||
static let panelHot = Color(red: 0.102, green: 0.157, blue: 0.141) // #1A2824
|
||
}
|
||
|
||
// MARK: - List
|
||
|
||
struct EventsScreen: View {
|
||
@EnvironmentObject private var app: AppModel
|
||
|
||
@State private var events: [ScheduleEvent] = []
|
||
@State private var loading = true
|
||
@State private var error: String?
|
||
@State private var sportFilter: String?
|
||
@State private var providerFilter: String?
|
||
@State private var now = Date()
|
||
@State private var path = NavigationPath()
|
||
@State private var pulse = false
|
||
|
||
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||
private let autoRefresh = Timer.publish(every: 75, on: .main, in: .common).autoconnect()
|
||
|
||
private var providerScoped: [ScheduleEvent] {
|
||
guard let providerFilter, !providerFilter.isEmpty else { return events }
|
||
return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame }
|
||
}
|
||
|
||
private var filtered: [ScheduleEvent] {
|
||
guard let sportFilter, !sportFilter.isEmpty else { return providerScoped }
|
||
return providerScoped.filter { $0.resolvedSportBucket == sportFilter }
|
||
}
|
||
|
||
private var liveEvents: [ScheduleEvent] {
|
||
filtered.filter { EventHelpers.isLive($0, now: now) }
|
||
.sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) }
|
||
}
|
||
|
||
private var sportChips: [(id: String, label: String)] {
|
||
let present = Set(providerScoped.map(\.resolvedSportBucket))
|
||
return EventSport.order
|
||
.filter { present.contains($0) }
|
||
.map { ($0, EventSport.label(for: $0)) }
|
||
}
|
||
|
||
private var sportShelves: [(id: String, label: String, items: [ScheduleEvent])] {
|
||
let upcoming = filtered.filter { !EventHelpers.isLive($0, now: now) }
|
||
var byBucket: [String: [ScheduleEvent]] = [:]
|
||
for e in upcoming {
|
||
byBucket[e.resolvedSportBucket, default: []].append(e)
|
||
}
|
||
return EventSport.order.compactMap { bucket in
|
||
guard let items = byBucket[bucket], !items.isEmpty else { return nil }
|
||
return (bucket, EventSport.label(for: bucket), items)
|
||
}
|
||
}
|
||
|
||
private var daySections: [(String, [ScheduleEvent])] {
|
||
EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in
|
||
let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) }
|
||
return upcoming.isEmpty ? nil : (label, upcoming)
|
||
}
|
||
}
|
||
|
||
private var providers: [String] {
|
||
Array(Set(events.map(\.provider))).sorted()
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack(path: $path) {
|
||
ZStack {
|
||
eventsBackground
|
||
content
|
||
}
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .principal) {
|
||
BrandHeader(subtitle: "EVENTS")
|
||
}
|
||
}
|
||
.toolbarBackground(EventsLook.deep.opacity(0.92), for: .navigationBar)
|
||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||
.navigationDestination(for: String.self) { eventId in
|
||
EventDetailScreen(eventId: eventId)
|
||
.environmentObject(app)
|
||
}
|
||
}
|
||
.task { await refresh() }
|
||
.onAppear {
|
||
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
|
||
pulse = true
|
||
}
|
||
}
|
||
.onReceive(clock) { now = $0 }
|
||
.onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } }
|
||
}
|
||
|
||
private var eventsBackground: some View {
|
||
ZStack {
|
||
EventsLook.deep.ignoresSafeArea()
|
||
RadialGradient(
|
||
colors: [
|
||
EventsLook.neon.opacity(0.14),
|
||
EventsLook.live.opacity(0.06),
|
||
.clear,
|
||
],
|
||
center: .topTrailing,
|
||
startRadius: 20,
|
||
endRadius: 420
|
||
)
|
||
.ignoresSafeArea()
|
||
RadialGradient(
|
||
colors: [EventsLook.neon.opacity(0.07), .clear],
|
||
center: .bottomLeading,
|
||
startRadius: 10,
|
||
endRadius: 360
|
||
)
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if loading && events.isEmpty {
|
||
ProgressView("Sportagenda laden…")
|
||
.tint(EventsLook.neon)
|
||
.foregroundStyle(McColors.textDim)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if let error, events.isEmpty {
|
||
VStack(spacing: 14) {
|
||
Text(error)
|
||
.foregroundStyle(McColors.danger)
|
||
.multilineTextAlignment(.center)
|
||
Button("Opnieuw") { Task { await refresh() } }
|
||
.buttonStyle(McPrimaryButtonStyle())
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 22) {
|
||
headerBlock
|
||
filterChips
|
||
if filtered.isEmpty {
|
||
emptyBlock
|
||
} else {
|
||
if !liveEvents.isEmpty {
|
||
liveShelf
|
||
}
|
||
if sportFilter == nil {
|
||
ForEach(sportShelves, id: \.id) { shelf in
|
||
eventShelf(
|
||
title: shelf.label,
|
||
accent: EventsLook.neon,
|
||
items: shelf.items,
|
||
featured: false
|
||
)
|
||
}
|
||
} else {
|
||
ForEach(daySections, id: \.0) { label, items in
|
||
eventShelf(
|
||
title: label,
|
||
accent: EventsLook.neon.opacity(0.85),
|
||
items: items,
|
||
featured: false
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 28)
|
||
}
|
||
.refreshable { await refresh(isPull: true) }
|
||
}
|
||
}
|
||
|
||
private var headerBlock: some View {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Sportagenda")
|
||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
Text(subtitleLine)
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(Color.white.opacity(0.55))
|
||
}
|
||
Spacer(minLength: 8)
|
||
if !liveEvents.isEmpty {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(EventsLook.live)
|
||
.frame(width: 7, height: 7)
|
||
.opacity(pulse ? 1 : 0.35)
|
||
.shadow(color: EventsLook.live.opacity(0.8), radius: pulse ? 5 : 1)
|
||
Text("\(liveEvents.count) LIVE")
|
||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(EventsLook.liveSoft, in: Capsule())
|
||
.overlay(Capsule().stroke(EventsLook.live.opacity(0.45), lineWidth: 1))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 8)
|
||
}
|
||
|
||
private var subtitleLine: String {
|
||
if let sportFilter {
|
||
return EventSport.label(for: sportFilter)
|
||
}
|
||
if let providerFilter {
|
||
return EventHelpers.prettyProvider(providerFilter)
|
||
}
|
||
return "Live · vandaag · later"
|
||
}
|
||
|
||
private var filterChips: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
EventsChip(
|
||
label: "Alles",
|
||
active: sportFilter == nil,
|
||
style: .sport
|
||
) { sportFilter = nil }
|
||
ForEach(sportChips, id: \.id) { chip in
|
||
EventsChip(
|
||
label: chip.label,
|
||
active: sportFilter == chip.id,
|
||
style: .sport
|
||
) { sportFilter = chip.id }
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
|
||
if providers.count > 1 {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
EventsChip(
|
||
label: "Alle bronnen",
|
||
active: providerFilter == nil,
|
||
style: .provider
|
||
) { providerFilter = nil }
|
||
ForEach(providers, id: \.self) { p in
|
||
EventsChip(
|
||
label: EventHelpers.prettyProvider(p),
|
||
active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame,
|
||
style: .provider
|
||
) { providerFilter = p }
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var liveShelf: some View {
|
||
eventShelf(
|
||
title: "Nu live",
|
||
accent: EventsLook.live,
|
||
items: liveEvents,
|
||
featured: true
|
||
)
|
||
}
|
||
|
||
private func eventShelf(
|
||
title: String,
|
||
accent: Color,
|
||
items: [ScheduleEvent],
|
||
featured: Bool
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 8) {
|
||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||
.fill(accent)
|
||
.frame(width: 3, height: 16)
|
||
Text(title)
|
||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
if featured {
|
||
Text("NU")
|
||
.font(.system(size: 10, weight: .heavy, design: .rounded))
|
||
.foregroundStyle(EventsLook.live)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(EventsLook.liveSoft, in: Capsule())
|
||
}
|
||
Spacer()
|
||
Text("\(items.count)")
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(Color.white.opacity(0.35))
|
||
}
|
||
.padding(.horizontal, 16)
|
||
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
ForEach(items) { ev in
|
||
Button { path.append(ev.id) } label: {
|
||
EventShelfCard(event: ev, now: now, featured: featured, pulse: pulse)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var emptyBlock: some View {
|
||
VStack(spacing: 10) {
|
||
Image(systemName: "sportscourt")
|
||
.font(.system(size: 36, weight: .light))
|
||
.foregroundStyle(EventsLook.neon.opacity(0.55))
|
||
Text(
|
||
sportFilter != nil
|
||
? "Geen events in \(EventSport.label(for: sportFilter!))"
|
||
: (providerFilter != nil
|
||
? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))"
|
||
: "Geen events in de komende dagen")
|
||
)
|
||
.font(McFonts.body)
|
||
.foregroundStyle(McColors.textDim)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 48)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
|
||
private func refresh(isPull: Bool = false, silent: Bool = false) async {
|
||
if !silent && events.isEmpty { loading = true }
|
||
do {
|
||
let payload = try await app.api.listScheduleEvents()
|
||
events = EventHelpers.enrichImages(payload.events)
|
||
error = nil
|
||
// Drop stale filters if nothing left.
|
||
if let sportFilter, !sportChips.contains(where: { $0.id == sportFilter }) {
|
||
self.sportFilter = nil
|
||
}
|
||
if let providerFilter,
|
||
!providers.contains(where: { $0.caseInsensitiveCompare(providerFilter) == .orderedSame }) {
|
||
self.providerFilter = nil
|
||
}
|
||
} catch {
|
||
if events.isEmpty {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
loading = false
|
||
}
|
||
}
|
||
|
||
// MARK: - Chips
|
||
|
||
private struct EventsChip: View {
|
||
enum Style { case sport, provider }
|
||
|
||
let label: String
|
||
let active: Bool
|
||
let style: Style
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(.system(size: style == .sport ? 13 : 12, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(foreground)
|
||
.padding(.horizontal, style == .sport ? 14 : 12)
|
||
.padding(.vertical, style == .sport ? 9 : 7)
|
||
.background(background, in: Capsule())
|
||
.overlay(
|
||
Capsule()
|
||
.strokeBorder(border, lineWidth: active ? 1.2 : 0.5)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private var foreground: Color {
|
||
if active {
|
||
return style == .sport ? Color(red: 0.02, green: 0.12, blue: 0.09) : .white
|
||
}
|
||
return Color.white.opacity(0.72)
|
||
}
|
||
|
||
private var background: Color {
|
||
if active {
|
||
return style == .sport ? EventsLook.neon : EventsLook.panelHot
|
||
}
|
||
return EventsLook.panel.opacity(0.9)
|
||
}
|
||
|
||
private var border: Color {
|
||
if active {
|
||
return style == .sport ? EventsLook.neon : EventsLook.neon.opacity(0.55)
|
||
}
|
||
return Color.white.opacity(0.08)
|
||
}
|
||
}
|
||
|
||
// MARK: - Shelf card
|
||
|
||
private struct EventShelfCard: View {
|
||
let event: ScheduleEvent
|
||
let now: Date
|
||
var featured: Bool = false
|
||
var pulse: Bool = false
|
||
|
||
private var cardWidth: CGFloat { featured ? 268 : 196 }
|
||
private var cardHeight: CGFloat { featured ? 151 : 110 }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ZStack(alignment: .bottomLeading) {
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(EventsLook.panel)
|
||
|
||
if let art = EventHelpers.artUrl(event) {
|
||
RemoteImage(url: art, contentMode: .fill)
|
||
.frame(width: cardWidth, height: cardHeight)
|
||
.clipped()
|
||
} else {
|
||
Text(EventHelpers.prettyProvider(event.provider))
|
||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||
.foregroundStyle(Color.white.opacity(0.4))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
if let logo = event.logoUrl?.nilIfBlank {
|
||
HStack {
|
||
Spacer(minLength: 0)
|
||
TitleLogo(url: logo, maxWidth: 72, maxHeight: 22)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 4)
|
||
.background(Color.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||
}
|
||
.padding(8)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||
}
|
||
|
||
LinearGradient(
|
||
colors: [
|
||
.clear,
|
||
.black.opacity(0.15),
|
||
.black.opacity(0.82),
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
|
||
if featured {
|
||
LinearGradient(
|
||
colors: [EventsLook.live.opacity(0.55), .clear],
|
||
startPoint: .top,
|
||
endPoint: .center
|
||
)
|
||
.frame(height: 36)
|
||
.frame(maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
EventStatusBadge(event: event, now: now, compact: true, pulse: pulse)
|
||
Spacer(minLength: 4)
|
||
if featured && event.isPlayable {
|
||
Text("KIJK NU")
|
||
.font(.system(size: 10, weight: .heavy, design: .rounded))
|
||
.foregroundStyle(EventsLook.deep)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(EventsLook.neon, in: Capsule())
|
||
}
|
||
}
|
||
Text(EventHelpers.shortTime(event, now: now))
|
||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(10)
|
||
}
|
||
.frame(width: cardWidth, height: cardHeight)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.strokeBorder(
|
||
featured ? EventsLook.live.opacity(0.35) : Color.white.opacity(0.06),
|
||
lineWidth: 1
|
||
)
|
||
)
|
||
.shadow(
|
||
color: featured ? EventsLook.live.opacity(0.22) : .black.opacity(0.35),
|
||
radius: featured ? 12 : 6,
|
||
y: 4
|
||
)
|
||
|
||
Text(event.name)
|
||
.font(.system(size: featured ? 15 : 14, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(2)
|
||
.frame(width: cardWidth, alignment: .leading)
|
||
|
||
HStack(spacing: 6) {
|
||
Text(event.resolvedSportLabel)
|
||
.font(.system(size: 10, weight: .bold, design: .rounded))
|
||
.foregroundStyle(EventsLook.neon)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(EventsLook.neonSoft, in: Capsule())
|
||
Text(
|
||
[EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)]
|
||
.compactMap { $0 }
|
||
.filter { !$0.isEmpty }
|
||
.uniqued()
|
||
.joined(separator: " · ")
|
||
)
|
||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||
.foregroundStyle(Color.white.opacity(0.45))
|
||
.lineLimit(1)
|
||
}
|
||
.frame(width: cardWidth, alignment: .leading)
|
||
}
|
||
.frame(width: cardWidth, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
// MARK: - Detail
|
||
|
||
struct EventDetailScreen: View {
|
||
@EnvironmentObject private var app: AppModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
let eventId: String
|
||
|
||
@State private var event: ScheduleEvent?
|
||
@State private var siblings: [ScheduleEvent] = []
|
||
@State private var error: String?
|
||
@State private var now = Date()
|
||
@State private var playInfo: IptvPlayInfo?
|
||
@State private var playError: String?
|
||
@State private var pulse = false
|
||
@State private var appeared = false
|
||
@State private var playing = false
|
||
|
||
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let event {
|
||
detailBody(event)
|
||
} else if error != nil {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 28))
|
||
.foregroundStyle(EventsLook.soon)
|
||
Text(error ?? "Fout")
|
||
.font(McFonts.body)
|
||
.foregroundStyle(Color.white.opacity(0.8))
|
||
.multilineTextAlignment(.center)
|
||
Button("Terug") { dismiss() }
|
||
.buttonStyle(McPrimaryButtonStyle())
|
||
}
|
||
.padding(24)
|
||
} else {
|
||
ProgressView("Laden…")
|
||
.tint(EventsLook.neon)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.background(EventsLook.deep.ignoresSafeArea())
|
||
.navigationBarBackButtonHidden(true)
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.task { await load() }
|
||
.onAppear {
|
||
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
|
||
pulse = true
|
||
}
|
||
withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) {
|
||
appeared = true
|
||
}
|
||
}
|
||
.onReceive(clock) { date in
|
||
now = date
|
||
Task { await refreshEvent() }
|
||
}
|
||
.fullScreenCover(item: Binding<EventPlayItem?>(
|
||
get: {
|
||
guard let playInfo else { return nil }
|
||
return EventPlayItem(info: playInfo)
|
||
},
|
||
set: { playInfo = $0?.info }
|
||
)) { item in
|
||
if let url = URL(string: item.info.streamUrl) {
|
||
let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:))
|
||
PlayerScreen(request: .live(
|
||
url: url,
|
||
title: item.info.name,
|
||
fallback: fallback,
|
||
format: item.info.format,
|
||
drm: item.info.drm,
|
||
watchSessionId: item.info.watchSessionId,
|
||
watchKind: "event",
|
||
watchId: eventId
|
||
))
|
||
.environmentObject(app)
|
||
}
|
||
}
|
||
.alert("Afspelen mislukt", isPresented: Binding(
|
||
get: { playError != nil },
|
||
set: { if !$0 { playError = nil } }
|
||
)) {
|
||
Button("OK", role: .cancel) { playError = nil }
|
||
} message: {
|
||
Text(playError ?? "")
|
||
}
|
||
}
|
||
|
||
private func detailBody(_ ev: ScheduleEvent) -> some View {
|
||
GeometryReader { geo in
|
||
let w = geo.size.width
|
||
let heroH = min(geo.size.height * 0.48, 380)
|
||
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
cinematicHero(ev, width: w, height: heroH)
|
||
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
metaStrip(ev)
|
||
timeBlock(ev)
|
||
descriptionBlock(ev)
|
||
audioBlock(ev)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 24)
|
||
.opacity(appeared ? 1 : 0)
|
||
.offset(y: appeared ? 0 : 18)
|
||
}
|
||
}
|
||
.safeAreaInset(edge: .top, spacing: 0) {
|
||
detailTopBar(ev)
|
||
}
|
||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
bottomCTA(ev, bottomInset: max(geo.safeAreaInsets.bottom, 8))
|
||
}
|
||
.frame(width: w, height: geo.size.height)
|
||
}
|
||
}
|
||
|
||
private func detailTopBar(_ ev: ScheduleEvent) -> some View {
|
||
HStack(spacing: 12) {
|
||
Button {
|
||
dismiss()
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 14, weight: .bold))
|
||
Text("Events")
|
||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||
}
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 9)
|
||
.background(.ultraThinMaterial, in: Capsule())
|
||
.overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Terug naar Events")
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
if let provider = optionalProvider(ev) {
|
||
Text(provider)
|
||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(.black.opacity(0.35), in: Capsule())
|
||
.overlay(Capsule().stroke(Color.white.opacity(0.14), lineWidth: 1))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 6)
|
||
.padding(.bottom, 8)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [EventsLook.deep.opacity(0.92), EventsLook.deep.opacity(0.55), .clear],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
.ignoresSafeArea(edges: .top)
|
||
.allowsHitTesting(false)
|
||
)
|
||
}
|
||
|
||
private func cinematicHero(_ ev: ScheduleEvent, width: CGFloat, height: CGFloat) -> some View {
|
||
ZStack(alignment: .bottomLeading) {
|
||
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
|
||
.frame(width: width, height: height)
|
||
.clipped()
|
||
.scaleEffect(appeared ? 1.0 : 1.08)
|
||
.animation(.easeOut(duration: 0.9), value: appeared)
|
||
|
||
// Side vignette + bottom fade into content
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .black.opacity(0.35), location: 0),
|
||
.init(color: .clear, location: 0.28),
|
||
.init(color: EventsLook.deep.opacity(0.55), location: 0.72),
|
||
.init(color: EventsLook.deep, location: 1),
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
.frame(width: width, height: height)
|
||
|
||
LinearGradient(
|
||
colors: [EventsLook.deep.opacity(0.55), .clear],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
.frame(width: width * 0.45, height: height)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if EventHelpers.isLive(ev, now: now) {
|
||
LinearGradient(
|
||
colors: [EventsLook.live.opacity(0.45), .clear],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
.frame(width: width, height: 90)
|
||
.frame(maxHeight: .infinity, alignment: .top)
|
||
.blendMode(.plusLighter)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 8) {
|
||
EventStatusBadge(event: ev, now: now, pulse: pulse)
|
||
Text(ev.resolvedSportLabel.uppercased())
|
||
.font(.system(size: 11, weight: .heavy, design: .rounded))
|
||
.tracking(1.1)
|
||
.foregroundStyle(EventsLook.deep)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(EventsLook.neon, in: Capsule())
|
||
.shadow(color: EventsLook.neon.opacity(0.35), radius: 8, y: 2)
|
||
}
|
||
|
||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||
TitleLogo(url: logo, maxWidth: min(width - 48, 240), maxHeight: 56)
|
||
} else {
|
||
Text(ev.name)
|
||
.font(.system(size: 30, weight: .black, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
.shadow(color: .black.opacity(0.55), radius: 12, y: 4)
|
||
.lineLimit(3)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||
Text(ev.name)
|
||
.font(.system(size: 17, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.88))
|
||
.lineLimit(2)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 22)
|
||
.frame(width: width, alignment: .leading)
|
||
.opacity(appeared ? 1 : 0)
|
||
.offset(y: appeared ? 0 : 24)
|
||
}
|
||
.frame(width: width, height: height)
|
||
.clipped()
|
||
}
|
||
|
||
private func bottomCTA(_ ev: ScheduleEvent, bottomInset: CGFloat) -> some View {
|
||
VStack(spacing: 0) {
|
||
LinearGradient(
|
||
colors: [EventsLook.deep.opacity(0), EventsLook.deep.opacity(0.92), EventsLook.deep],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
.frame(height: 28)
|
||
.allowsHitTesting(false)
|
||
|
||
Group {
|
||
if ev.isPlayable {
|
||
Button {
|
||
Task { await play(ev) }
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
if playing {
|
||
ProgressView().tint(EventsLook.deep)
|
||
} else {
|
||
Image(systemName: "play.fill")
|
||
.font(.system(size: 16, weight: .bold))
|
||
}
|
||
Text(EventHelpers.isLive(ev, now: now) ? "Kijk live" : "Nu kijken")
|
||
.font(.system(size: 17, weight: .bold, design: .rounded))
|
||
}
|
||
.foregroundStyle(EventsLook.deep)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 16)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
),
|
||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
)
|
||
.shadow(color: EventsLook.neon.opacity(0.45), radius: 18, y: 6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(Color.white.opacity(0.22), lineWidth: 1)
|
||
)
|
||
}
|
||
.disabled(playing)
|
||
.scaleEffect(appeared ? 1 : 0.94)
|
||
.opacity(appeared ? 1 : 0)
|
||
} else {
|
||
Text(EventHelpers.ctaLabel(ev, now: now))
|
||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 16)
|
||
.background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, max(bottomInset, 10))
|
||
.background(EventsLook.deep)
|
||
}
|
||
}
|
||
|
||
private func metaStrip(_ ev: ScheduleEvent) -> some View {
|
||
let meta = EventHelpers.detailMeta(ev)
|
||
return Group {
|
||
if !meta.isEmpty {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(meta.split(separator: "·").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, id: \.self) { part in
|
||
Text(part)
|
||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.85))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(EventsLook.panelHot, in: Capsule())
|
||
.overlay(Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func timeBlock(_ ev: ScheduleEvent) -> some View {
|
||
let window = EventHelpers.detailTimeWindow(ev, now: now)
|
||
return Group {
|
||
if !window.isEmpty {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: EventHelpers.isLive(ev, now: now) ? "dot.radiowaves.left.and.right" : "calendar")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(EventHelpers.isLive(ev, now: now) ? EventsLook.live : EventsLook.neon)
|
||
.frame(width: 36, height: 36)
|
||
.background(
|
||
(EventHelpers.isLive(ev, now: now) ? EventsLook.liveSoft : EventsLook.neonSoft),
|
||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(EventHelpers.isLive(ev, now: now) ? "Nu bezig" : "Speeltijd")
|
||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
Text(window)
|
||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(14)
|
||
.background(EventsLook.panel.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(Color.white.opacity(0.06), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func descriptionBlock(_ ev: ScheduleEvent) -> some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if let subtitle = ev.subtitle, !subtitle.isEmpty,
|
||
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
|
||
Text(subtitle)
|
||
.font(.system(size: 15, weight: .medium, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.55))
|
||
.lineLimit(3)
|
||
}
|
||
if let description = ev.description, !description.isEmpty {
|
||
Text("Over dit event")
|
||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||
.foregroundStyle(EventsLook.neon.opacity(0.9))
|
||
Text(description)
|
||
.font(McFonts.body)
|
||
.foregroundStyle(.white.opacity(0.88))
|
||
.lineSpacing(5)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func audioBlock(_ ev: ScheduleEvent) -> some View {
|
||
Group {
|
||
if let langs = ev.audioLanguages, !langs.isEmpty {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Audio")
|
||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.4))
|
||
FlowChips(items: langs.prefix(8).map { $0.uppercased() })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func optionalProvider(_ ev: ScheduleEvent) -> String? {
|
||
let p = ev.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return p.isEmpty ? nil : p.uppercased()
|
||
}
|
||
|
||
private func load() async {
|
||
do {
|
||
let list = try await app.api.listScheduleEvents()
|
||
siblings = EventHelpers.enrichImages(list.events)
|
||
let raw = try await app.api.getScheduleEvent(eventId: eventId)
|
||
event = EventHelpers.enrichSingle(raw, siblings: siblings)
|
||
error = nil
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func refreshEvent() async {
|
||
guard let raw = try? await app.api.getScheduleEvent(eventId: eventId) else { return }
|
||
event = EventHelpers.enrichSingle(raw, siblings: siblings)
|
||
}
|
||
|
||
private func play(_ ev: ScheduleEvent) async {
|
||
playing = true
|
||
defer { playing = false }
|
||
do {
|
||
playInfo = try await app.api.scheduleEventPlay(eventId: ev.id)
|
||
} catch {
|
||
playError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct FlowChips: View {
|
||
let items: [String]
|
||
|
||
var body: some View {
|
||
FlexibleChipRow(items: items)
|
||
}
|
||
}
|
||
|
||
/// Eenvoudige wrapping chip-rij zonder externe dependency.
|
||
private struct FlexibleChipRow: View {
|
||
let items: [String]
|
||
|
||
private var rows: [[String]] {
|
||
// Simpele wrap: max ~3 per rij op smalle schermen
|
||
var result: [[String]] = []
|
||
var current: [String] = []
|
||
for item in items {
|
||
current.append(item)
|
||
if current.count >= 3 {
|
||
result.append(current)
|
||
current = []
|
||
}
|
||
}
|
||
if !current.isEmpty { result.append(current) }
|
||
return result
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||
HStack(spacing: 8) {
|
||
ForEach(row, id: \.self) { item in
|
||
Text(item)
|
||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(EventsLook.panelHot, in: Capsule())
|
||
.overlay(Capsule().stroke(EventsLook.neon.opacity(0.2), lineWidth: 1))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct EventPlayItem: Identifiable {
|
||
var id: String { info.streamId.isEmpty ? info.name : info.streamId }
|
||
let info: IptvPlayInfo
|
||
}
|
||
|
||
// MARK: - Badge
|
||
|
||
private struct EventStatusBadge: View {
|
||
let event: ScheduleEvent
|
||
let now: Date
|
||
var compact: Bool = false
|
||
var pulse: Bool = false
|
||
|
||
var body: some View {
|
||
let (label, color) = EventHelpers.statusVisual(event, now: now)
|
||
HStack(spacing: 5) {
|
||
if EventHelpers.isLive(event, now: now) {
|
||
Circle()
|
||
.fill(.white)
|
||
.frame(width: compact ? 5 : 6, height: compact ? 5 : 6)
|
||
.opacity(pulse ? 1 : 0.4)
|
||
}
|
||
Text(label)
|
||
.font(.system(size: compact ? 10 : 12, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
}
|
||
.padding(.horizontal, compact ? 7 : 10)
|
||
.padding(.vertical, compact ? 3 : 5)
|
||
.background(color, in: Capsule())
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
enum EventHelpers {
|
||
static let zone = TimeZone(identifier: "Europe/Amsterdam") ?? .current
|
||
private static let streamLabel = try! NSRegularExpression(pattern: #"^stream\s*\d+"#, options: .caseInsensitive)
|
||
|
||
static func isLive(_ event: ScheduleEvent, now: Date) -> Bool {
|
||
if event.phaseValue == "live" || event.isLive == true { return true }
|
||
guard let start = parse(event.startLocal, event.start),
|
||
let stop = parse(event.stopLocal, event.stop) else { return false }
|
||
return now >= start && now < stop
|
||
}
|
||
|
||
static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? {
|
||
guard let start = parse(event.startLocal, event.start) else { return nil }
|
||
return Int(start.timeIntervalSince(now) / 60)
|
||
}
|
||
|
||
static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? {
|
||
guard let stop = parse(event.stopLocal, event.stop) else { return nil }
|
||
return Int(stop.timeIntervalSince(now) / 60)
|
||
}
|
||
|
||
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) {
|
||
if isLive(event, now: now) { return ("LIVE", EventsLook.live) }
|
||
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||
return ("Straks · \(m)m", EventsLook.soon)
|
||
}
|
||
return ("Gepland", Color.white.opacity(0.18))
|
||
}
|
||
|
||
static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String {
|
||
if isLive(event, now: now) { return "Stream start binnenkort" }
|
||
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||
return "Start over \(m) min"
|
||
}
|
||
guard let start = parse(event.startLocal, event.start) else { return "Meer info" }
|
||
return "Start om \(timeFmt.string(from: start))"
|
||
}
|
||
|
||
static func shortTime(_ event: ScheduleEvent, now: Date) -> String {
|
||
if isLive(event, now: now) {
|
||
if let left = minutesUntilEnd(event, now: now), left > 0 { return "Nog \(left) min" }
|
||
return "Nu"
|
||
}
|
||
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||
return "Over \(m) min"
|
||
}
|
||
guard let start = parse(event.startLocal, event.start) else { return "" }
|
||
let cal = Calendar.current
|
||
var c = cal
|
||
c.timeZone = zone
|
||
let today = c.startOfDay(for: now)
|
||
let day = c.startOfDay(for: start)
|
||
if day == today { return "Vandaag \(timeFmt.string(from: start))" }
|
||
if day == c.date(byAdding: .day, value: 1, to: today) {
|
||
return "Morgen \(timeFmt.string(from: start))"
|
||
}
|
||
return dayTimeFmt.string(from: start)
|
||
}
|
||
|
||
static func detailTimeWindow(_ event: ScheduleEvent, now: Date) -> String {
|
||
guard let start = parse(event.startLocal, event.start) else { return "" }
|
||
let stop = parse(event.stopLocal, event.stop)
|
||
let range: String = {
|
||
if let stop {
|
||
return "\(timeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
|
||
}
|
||
return timeFmt.string(from: start)
|
||
}()
|
||
if isLive(event, now: now) { return range }
|
||
var c = Calendar.current
|
||
c.timeZone = zone
|
||
let today = c.startOfDay(for: now)
|
||
let day = c.startOfDay(for: start)
|
||
if day == today { return "Vandaag · \(range)" }
|
||
if day == c.date(byAdding: .day, value: 1, to: today) {
|
||
return "Morgen · \(range)"
|
||
}
|
||
if let stop {
|
||
return "\(dayTimeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
|
||
}
|
||
return dayTimeFmt.string(from: start)
|
||
}
|
||
|
||
static func displayChannel(_ event: ScheduleEvent) -> String? {
|
||
let raw = event.channel?.nilIfBlank ?? event.network?.nilIfBlank
|
||
guard let raw, !isStreamLabel(raw) else { return nil }
|
||
return raw
|
||
}
|
||
|
||
static func detailMeta(_ event: ScheduleEvent) -> String {
|
||
[
|
||
prettyProvider(event.provider),
|
||
event.category?.nilIfBlank,
|
||
event.eventType?.nilIfBlank,
|
||
displayChannel(event),
|
||
]
|
||
.compactMap { $0 }
|
||
.uniqued()
|
||
.joined(separator: " · ")
|
||
}
|
||
|
||
static func artUrl(_ event: ScheduleEvent) -> String? {
|
||
if let u = event.imageLandscape?.nilIfBlank, looksLikeArtwork(u) { return u }
|
||
let imgs = event.images ?? []
|
||
if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) {
|
||
return u
|
||
}
|
||
if let u = imgs.first(where: { looksLikeArtwork($0) }) { return u }
|
||
if let u = event.imagePortrait?.nilIfBlank, looksLikeArtwork(u) { return u }
|
||
return imgs.first { !$0.isEmpty && !$0.localizedCaseInsensitiveContains("assets/channels") }
|
||
}
|
||
|
||
static func backdropUrl(_ event: ScheduleEvent) -> String? {
|
||
if let u = event.imageBackdrop?.nilIfBlank { return u }
|
||
if let u = (event.images ?? []).first(where: { $0.localizedCaseInsensitiveContains("hero-16x9") }) {
|
||
return u
|
||
}
|
||
return artUrl(event)
|
||
}
|
||
|
||
static func prettyProvider(_ raw: String) -> String {
|
||
switch raw.lowercased() {
|
||
case "viaplay": return "Viaplay"
|
||
case "ziggosport": return "Ziggo Sport"
|
||
case "espn": return "ESPN"
|
||
case "appletv": return "Apple TV"
|
||
case "prime_hbo": return "Prime / HBO"
|
||
case "prime": return "Prime Video"
|
||
case "disneyplus": return "Disney+"
|
||
case "eyecons": return "Eyecons"
|
||
case "odido": return "Odido"
|
||
default:
|
||
return raw.replacingOccurrences(of: "_", with: " ").capitalized
|
||
}
|
||
}
|
||
|
||
static func groupByDay(_ events: [ScheduleEvent]) -> [(String, [ScheduleEvent])] {
|
||
var c = Calendar.current
|
||
c.timeZone = zone
|
||
let today = c.startOfDay(for: Date())
|
||
var groups: [(String, [ScheduleEvent])] = []
|
||
var index: [String: Int] = [:]
|
||
|
||
for e in events {
|
||
guard let start = parse(e.startLocal, e.start) else { continue }
|
||
let day = c.startOfDay(for: start)
|
||
let label: String
|
||
if day == today {
|
||
label = "Vandaag"
|
||
} else if day == c.date(byAdding: .day, value: 1, to: today) {
|
||
label = "Morgen"
|
||
} else if day == c.date(byAdding: .day, value: 2, to: today) {
|
||
label = "Overmorgen"
|
||
} else {
|
||
label = fullDayFmt.string(from: start)
|
||
}
|
||
if let i = index[label] {
|
||
groups[i].1.append(e)
|
||
} else {
|
||
index[label] = groups.count
|
||
groups.append((label, [e]))
|
||
}
|
||
}
|
||
return groups
|
||
}
|
||
|
||
static func enrichImages(_ events: [ScheduleEvent]) -> [ScheduleEvent] {
|
||
var donors: [String: ScheduleEvent] = [:]
|
||
for e in events {
|
||
if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue }
|
||
for key in donorKeys(for: e) {
|
||
if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue }
|
||
donors[key] = e
|
||
}
|
||
}
|
||
return events.map { enrichSingle($0, siblings: events, donors: donors) }
|
||
}
|
||
|
||
static func enrichSingle(
|
||
_ e: ScheduleEvent,
|
||
siblings: [ScheduleEvent],
|
||
donors: [String: ScheduleEvent]? = nil
|
||
) -> ScheduleEvent {
|
||
let cleanedChannel = e.channel.flatMap { isStreamLabel($0) ? nil : $0 }
|
||
let cleanedNetwork = e.network.flatMap { isStreamLabel($0) ? nil : $0 }
|
||
if e.imageLandscape != nil || !(e.images ?? []).isEmpty {
|
||
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
|
||
}
|
||
let map: [String: ScheduleEvent] = donors ?? {
|
||
var built: [String: ScheduleEvent] = [:]
|
||
for sib in siblings {
|
||
if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue }
|
||
for key in donorKeys(for: sib) {
|
||
if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue }
|
||
built[key] = sib
|
||
}
|
||
}
|
||
return built
|
||
}()
|
||
var donor: ScheduleEvent?
|
||
for key in donorKeys(for: e) {
|
||
if let hit = map[key], hit.id != e.id,
|
||
hit.resolvedSportBucket == e.resolvedSportBucket
|
||
{
|
||
donor = hit
|
||
break
|
||
}
|
||
}
|
||
guard let donor else {
|
||
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
|
||
}
|
||
return e.withImages(
|
||
landscape: e.imageLandscape ?? donor.imageLandscape,
|
||
backdrop: e.imageBackdrop ?? donor.imageBackdrop,
|
||
portrait: e.imagePortrait ?? donor.imagePortrait,
|
||
logo: e.logoUrl ?? donor.logoUrl,
|
||
imgs: (e.images?.isEmpty == false ? e.images : donor.images),
|
||
channel: cleanedChannel,
|
||
network: cleanedNetwork
|
||
)
|
||
}
|
||
|
||
/// Donor-key = sport + kanaal — voorkomt voetbal-art op Racing (zelfde Ziggo-kanaal).
|
||
private static func donorKeys(for e: ScheduleEvent) -> [String] {
|
||
let sport = e.resolvedSportBucket
|
||
var keys: [String] = []
|
||
for ch in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
|
||
keys.append("\(sport)|\(ch)")
|
||
}
|
||
return keys
|
||
}
|
||
|
||
private static func donorScore(_ e: ScheduleEvent) -> Int {
|
||
var s = 0
|
||
if e.imageLandscape != nil { s += 20 }
|
||
if e.imageBackdrop != nil { s += 10 }
|
||
if (e.images ?? []).contains(where: {
|
||
$0.localizedCaseInsensitiveContains("thumb") || $0.localizedCaseInsensitiveContains("akamaized.net/live")
|
||
}) { s += 30 }
|
||
if e.logoUrl != nil { s += 5 }
|
||
s += min((e.images ?? []).count, 5)
|
||
if e.provider.caseInsensitiveCompare("ziggosport") == .orderedSame { s += 8 }
|
||
if e.isPlayable { s += 3 }
|
||
return s
|
||
}
|
||
|
||
private static func normalizeChannel(_ value: String?) -> String? {
|
||
let s = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) ?? ""
|
||
return s.isEmpty ? nil : s
|
||
}
|
||
|
||
private static func isStreamLabel(_ value: String) -> Bool {
|
||
let s = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !s.isEmpty else { return false }
|
||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||
return streamLabel.firstMatch(in: s, options: [], range: range) != nil
|
||
}
|
||
|
||
private static func looksLikeArtwork(_ url: String) -> Bool {
|
||
let u = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if u.isEmpty { return false }
|
||
let lower = u.lowercased()
|
||
if lower.contains("assets/channels") { return false }
|
||
if lower.contains("channels-logo") { return false }
|
||
if lower.contains("/logos/") && lower.hasSuffix(".png") { return false }
|
||
if lower.contains("channellogo") { return false }
|
||
if lower.range(of: #"ziggo_sport_\d"#, options: .regularExpression) != nil { return false }
|
||
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")),
|
||
!lower.contains("boxart"), !lower.contains("hero-") {
|
||
return false
|
||
}
|
||
if lower.hasSuffix(".svg") { return false }
|
||
return true
|
||
}
|
||
|
||
static func parse(_ local: String?, _ utc: String) -> Date? {
|
||
let raw = local?.nilIfBlank ?? utc.nilIfBlank
|
||
guard let raw else { return nil }
|
||
let isoFrac = ISO8601DateFormatter()
|
||
isoFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
if let d = isoFrac.date(from: raw) { return d }
|
||
let iso = ISO8601DateFormatter()
|
||
iso.formatOptions = [.withInternetDateTime]
|
||
if let d = iso.date(from: raw) { return d }
|
||
let withZ = raw.hasSuffix("Z") || raw.contains("+") ? raw : "\(raw)Z"
|
||
return iso.date(from: withZ) ?? isoFrac.date(from: withZ)
|
||
}
|
||
|
||
private static let timeFmt: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "nl_NL")
|
||
f.timeZone = zone
|
||
f.dateFormat = "HH:mm"
|
||
return f
|
||
}()
|
||
|
||
private static let dayTimeFmt: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "nl_NL")
|
||
f.timeZone = zone
|
||
f.dateFormat = "EEE d MMM · HH:mm"
|
||
return f
|
||
}()
|
||
|
||
private static let fullDayFmt: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "nl_NL")
|
||
f.timeZone = zone
|
||
f.dateFormat = "EEEE d MMMM"
|
||
return f
|
||
}()
|
||
}
|
||
|
||
private extension String {
|
||
var nilIfBlank: String? {
|
||
let t = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return t.isEmpty ? nil : t
|
||
}
|
||
}
|
||
|
||
private extension Array where Element: Hashable {
|
||
func uniqued() -> [Element] {
|
||
var seen = Set<Element>()
|
||
return filter { seen.insert($0).inserted }
|
||
}
|
||
}
|