stremio/apps/ios/VMC/UI/EventsScreen.swift
Jos Vooges | STH bfeb270459 Polish iOS Events like Android TV and fix ClearKey/PiP playback.
Sport shelves and neon Events UI; HLS ClearKey proxy; safer drawable/PiP teardown for stable video and subtitles.
2026-09-20 02:02:38 +02:00

1152 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

import SwiftUI
// MARK: - 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)
}
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
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: 12) {
Text(error ?? "Fout").foregroundStyle(McColors.danger)
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
}
} 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
}
}
.onReceive(clock) { date in
now = date
Task { await refreshEvent() }
}
.fullScreenCover(item: Binding(
get: { playInfo.map { EventPlayItem(info: $0) } },
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 {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
hero(ev)
VStack(alignment: .leading, spacing: 12) {
Text(ev.name)
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(3)
HStack(spacing: 8) {
Text(ev.resolvedSportLabel)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(EventsLook.deep)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(EventsLook.neon, in: Capsule())
EventStatusBadge(event: ev, now: now, pulse: pulse)
}
let meta = EventHelpers.detailMeta(ev)
if !meta.isEmpty {
Text(meta)
.font(McFonts.body)
.foregroundStyle(Color.white.opacity(0.72))
.lineLimit(2)
}
let window = EventHelpers.detailTimeWindow(ev, now: now)
if !window.isEmpty {
Text(window)
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
}
if let subtitle = ev.subtitle, !subtitle.isEmpty,
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
Text(subtitle)
.font(McFonts.body)
.foregroundStyle(Color.white.opacity(0.5))
.lineLimit(3)
}
if let description = ev.description, !description.isEmpty {
Text(description)
.font(McFonts.body)
.foregroundStyle(Color.white.opacity(0.88))
.lineSpacing(4)
}
if let langs = ev.audioLanguages, !langs.isEmpty {
Text("Audio")
.font(McFonts.caption)
.foregroundStyle(Color.white.opacity(0.4))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(langs.prefix(8), id: \.self) { lang in
Text(lang.uppercased())
.font(McFonts.caption)
.foregroundStyle(Color.white.opacity(0.86))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
}
}
stickyBar(ev)
}
}
private func hero(_ ev: ScheduleEvent) -> some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
.frame(height: 280)
.frame(maxWidth: .infinity)
.clipped()
.overlay {
LinearGradient(
stops: [
.init(color: .black.opacity(0.25), location: 0),
.init(color: .clear, location: 0.4),
.init(color: EventsLook.deep.opacity(0.98), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
.overlay(alignment: .top) {
if EventHelpers.isLive(ev, now: now) {
LinearGradient(
colors: [EventsLook.live.opacity(0.5), .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 48)
}
}
HStack {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.padding(10)
.background(.black.opacity(0.45), in: Circle())
}
Spacer()
if let logo = ev.logoUrl, !logo.isEmpty {
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
.frame(width: 96, height: 36)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(height: 280)
}
private func stickyBar(_ ev: ScheduleEvent) -> some View {
HStack(spacing: 10) {
if ev.isPlayable {
Button {
Task { await play(ev) }
} label: {
Label("Kijk live", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(EventsLiveButtonStyle())
Button("Terug") { dismiss() }
.buttonStyle(McSecondaryButtonStyle())
} else {
Button {} label: {
Text(EventHelpers.ctaLabel(ev, now: now))
.frame(maxWidth: .infinity)
}
.buttonStyle(McSecondaryButtonStyle())
.disabled(true)
.opacity(0.55)
Button("Terug") { dismiss() }
.buttonStyle(McPrimaryButtonStyle())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom))
}
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 {
do {
playInfo = try await app.api.scheduleEventPlay(eventId: ev.id)
} catch {
playError = error.localizedDescription
}
}
}
private struct EventsLiveButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(McFonts.headline)
.foregroundStyle(EventsLook.deep)
.padding(.horizontal, 22)
.padding(.vertical, 13)
.background(
LinearGradient(
colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.opacity(configuration.isPressed ? 0.8 : 1),
in: Capsule()
)
.shadow(color: EventsLook.neon.opacity(0.35), radius: configuration.isPressed ? 2 : 10, y: 4)
.scaleEffect(configuration.isPressed ? 0.98 : 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 { 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 { 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 [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
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 [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) {
if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue }
built[key] = sib
}
}
return built
}()
var donor: ScheduleEvent?
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
if let hit = map[key], hit.id != e.id {
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
)
}
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("/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 }
}
}