Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
803 lines
31 KiB
Swift
803 lines
31 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - List
|
||
|
||
struct EventsScreen: View {
|
||
@EnvironmentObject private var app: AppModel
|
||
|
||
@State private var events: [ScheduleEvent] = []
|
||
@State private var loading = true
|
||
@State private var refreshing = false
|
||
@State private var error: String?
|
||
@State private var providerFilter: String?
|
||
@State private var now = Date()
|
||
@State private var path = NavigationPath()
|
||
|
||
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 filtered: [ScheduleEvent] {
|
||
guard let providerFilter, !providerFilter.isEmpty else { return events }
|
||
return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame }
|
||
}
|
||
|
||
private var liveEvents: [ScheduleEvent] {
|
||
filtered.filter { EventHelpers.isLive($0, now: now) }
|
||
.sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) }
|
||
}
|
||
|
||
private var upcomingSections: [(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) {
|
||
content
|
||
.background(McColors.bg.ignoresSafeArea())
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .principal) {
|
||
BrandHeader(subtitle: "EVENTS")
|
||
}
|
||
}
|
||
.toolbarBackground(McColors.bg, for: .navigationBar)
|
||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||
.navigationDestination(for: String.self) { eventId in
|
||
EventDetailScreen(eventId: eventId)
|
||
.environmentObject(app)
|
||
}
|
||
}
|
||
.task { await refresh() }
|
||
.onReceive(clock) { now = $0 }
|
||
.onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
VStack(spacing: 0) {
|
||
if providers.count > 1 {
|
||
providerChips
|
||
}
|
||
|
||
if loading && events.isEmpty {
|
||
ProgressView("Events laden…")
|
||
.tint(.white)
|
||
.foregroundStyle(McColors.textDim)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if let error, events.isEmpty {
|
||
VStack(spacing: 12) {
|
||
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
|
||
Button("Opnieuw") { Task { await refresh() } }
|
||
.buttonStyle(McPrimaryButtonStyle())
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if filtered.isEmpty {
|
||
ContentUnavailableView(
|
||
providerFilter != nil
|
||
? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))"
|
||
: "Geen events in de komende dagen",
|
||
systemImage: "calendar"
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
List {
|
||
if !liveEvents.isEmpty {
|
||
Section {
|
||
ForEach(liveEvents) { ev in
|
||
Button { path.append(ev.id) } label: {
|
||
EventRowView(event: ev, now: now)
|
||
}
|
||
.listRowBackground(McColors.bgElevated)
|
||
.listRowSeparatorTint(Color.white.opacity(0.06))
|
||
}
|
||
} header: {
|
||
Text("Nu live")
|
||
.font(McFonts.headline)
|
||
.foregroundStyle(McColors.text)
|
||
.textCase(nil)
|
||
}
|
||
}
|
||
|
||
ForEach(upcomingSections, id: \.0) { label, dayEvents in
|
||
Section {
|
||
ForEach(dayEvents) { ev in
|
||
Button { path.append(ev.id) } label: {
|
||
EventRowView(event: ev, now: now)
|
||
}
|
||
.listRowBackground(McColors.bgElevated)
|
||
.listRowSeparatorTint(Color.white.opacity(0.06))
|
||
}
|
||
} header: {
|
||
Text(label)
|
||
.font(McFonts.headline)
|
||
.foregroundStyle(McColors.text)
|
||
.textCase(nil)
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
.refreshable { await refresh(isPull: true) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private var providerChips: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
chip(label: "Alles", active: providerFilter == nil) {
|
||
providerFilter = nil
|
||
}
|
||
ForEach(providers, id: \.self) { p in
|
||
chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) {
|
||
providerFilter = p
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
private func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(active ? .white : McColors.textDim)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 8)
|
||
.background(active ? McColors.accent : McColors.surface, in: Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func refresh(isPull: Bool = false, silent: Bool = false) async {
|
||
if isPull { refreshing = true }
|
||
if !silent && events.isEmpty { loading = true }
|
||
do {
|
||
let payload = try await app.api.listScheduleEvents()
|
||
events = EventHelpers.enrichImages(payload.events)
|
||
error = nil
|
||
} catch {
|
||
if events.isEmpty {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
loading = false
|
||
refreshing = false
|
||
}
|
||
}
|
||
|
||
// 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?
|
||
|
||
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(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.background(McColors.bg.ignoresSafeArea())
|
||
.navigationBarBackButtonHidden(true)
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.task { await load() }
|
||
.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: 10) {
|
||
Text(ev.name)
|
||
.font(McFonts.title)
|
||
.foregroundStyle(.white)
|
||
.lineLimit(3)
|
||
|
||
let meta = EventHelpers.detailMeta(ev)
|
||
if !meta.isEmpty {
|
||
Text(meta)
|
||
.font(McFonts.body)
|
||
.foregroundStyle(Color(white: 0.8))
|
||
.lineLimit(2)
|
||
}
|
||
|
||
let window = EventHelpers.detailTimeWindow(ev, now: now)
|
||
if !window.isEmpty {
|
||
Text(window)
|
||
.font(McFonts.headline)
|
||
.foregroundStyle(McColors.text)
|
||
}
|
||
|
||
if let subtitle = ev.subtitle, !subtitle.isEmpty,
|
||
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
|
||
Text(subtitle)
|
||
.font(McFonts.body)
|
||
.foregroundStyle(McColors.textDim)
|
||
.lineLimit(3)
|
||
}
|
||
|
||
if let description = ev.description, !description.isEmpty {
|
||
Text(description)
|
||
.font(McFonts.body)
|
||
.foregroundStyle(Color(white: 0.91))
|
||
.lineSpacing(4)
|
||
}
|
||
|
||
if let langs = ev.audioLanguages, !langs.isEmpty {
|
||
Text("Audio")
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(Color(white: 0.56))
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(langs.prefix(8), id: \.self) { lang in
|
||
Text(lang.uppercased())
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(Color(white: 0.86))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 14)
|
||
}
|
||
}
|
||
|
||
stickyBar(ev)
|
||
}
|
||
}
|
||
|
||
private func hero(_ ev: ScheduleEvent) -> some View {
|
||
ZStack(alignment: .bottomLeading) {
|
||
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
|
||
.frame(height: 220)
|
||
.frame(maxWidth: .infinity)
|
||
.clipped()
|
||
.overlay {
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .black.opacity(0.2), location: 0),
|
||
.init(color: .clear, location: 0.45),
|
||
.init(color: McColors.bg.opacity(0.95), location: 1),
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
}
|
||
|
||
HStack {
|
||
Button { dismiss() } label: {
|
||
Text("Terug")
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
Spacer()
|
||
if let logo = ev.logoUrl, !logo.isEmpty {
|
||
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
|
||
.frame(width: 96, height: 36)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
|
||
EventStatusBadge(event: ev, now: now)
|
||
.padding(16)
|
||
}
|
||
.frame(height: 220)
|
||
}
|
||
|
||
private func stickyBar(_ ev: ScheduleEvent) -> some View {
|
||
HStack(spacing: 10) {
|
||
if ev.isPlayable {
|
||
Button {
|
||
Task { await play(ev) }
|
||
} label: {
|
||
Text("Kijk live")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(McPrimaryButtonStyle())
|
||
|
||
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(McColors.bgElevated.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 EventPlayItem: Identifiable {
|
||
var id: String { info.streamId.isEmpty ? info.name : info.streamId }
|
||
let info: IptvPlayInfo
|
||
}
|
||
|
||
// MARK: - Row
|
||
|
||
private struct EventRowView: View {
|
||
let event: ScheduleEvent
|
||
let now: Date
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(Color(white: 0.1))
|
||
.frame(width: 112, height: 63)
|
||
if let art = EventHelpers.artUrl(event) {
|
||
RemoteImage(url: art, contentMode: .fill)
|
||
.frame(width: 112, height: 63)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
} else {
|
||
Text(EventHelpers.prettyProvider(event.provider))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(McColors.textDim)
|
||
}
|
||
}
|
||
.frame(width: 112, height: 63)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 8) {
|
||
EventStatusBadge(event: event, now: now, compact: true)
|
||
Text(EventHelpers.shortTime(event, now: now))
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(McColors.textDim)
|
||
.lineLimit(1)
|
||
}
|
||
Text(event.name)
|
||
.font(McFonts.headline)
|
||
.foregroundStyle(McColors.text)
|
||
.lineLimit(2)
|
||
Text(
|
||
[EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)]
|
||
.compactMap { $0 }
|
||
.filter { !$0.isEmpty }
|
||
.uniqued()
|
||
.joined(separator: " · ")
|
||
)
|
||
.font(McFonts.caption)
|
||
.foregroundStyle(McColors.textDim)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
}
|
||
|
||
private struct EventStatusBadge: View {
|
||
let event: ScheduleEvent
|
||
let now: Date
|
||
var compact: Bool = false
|
||
|
||
var body: some View {
|
||
let (label, color) = EventHelpers.statusVisual(event, now: now)
|
||
Text(label)
|
||
.font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, compact ? 8 : 10)
|
||
.padding(.vertical, compact ? 3 : 5)
|
||
.background(color, in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
let m = Int(start.timeIntervalSince(now) / 60)
|
||
return m
|
||
}
|
||
|
||
static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? {
|
||
guard let stop = parse(event.stopLocal, event.stop) else { return nil }
|
||
let m = Int(stop.timeIntervalSince(now) / 60)
|
||
return m
|
||
}
|
||
|
||
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) {
|
||
if isLive(event, now: now) { return ("LIVE", McColors.accent) }
|
||
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
|
||
return ("Straks · over \(m) min", Color(red: 0.9, green: 0.49, blue: 0.13))
|
||
}
|
||
return ("Gepland", Color(white: 0.23))
|
||
}
|
||
|
||
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 }
|
||
}
|
||
}
|