stremio/apps/ios/VMC/UI/EventsScreen.swift
Jos Vooges | STH ddf5ae7f8f Fix iOS archive for VLCKit 4 and Events play sheet binding.
Unblocks TestFlight 0.5.36 after media.parse/.buffering API removals and optional EventPlayItem Binding inference.
2026-09-21 03:09:27 +02:00

1379 lines
53 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)
}
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,
logoUrl: item.info.logoUrl ?? event?.logoUrl
))
.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 { 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 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("/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 }
}
}