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.
This commit is contained in:
Jos Vooges | STH 2026-09-20 02:02:38 +02:00
parent 1efdc3bef0
commit bfeb270459
9 changed files with 832 additions and 235 deletions

View file

@ -337,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -352,7 +352,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.4;
MARKETING_VERSION = 0.5.6;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -370,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -385,7 +385,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.4;
MARKETING_VERSION = 0.5.6;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -389,6 +389,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
let stop: String
let startLocal: String?
let stopLocal: String?
let sportBucket: String?
let sportLabel: String?
let imageLandscape: String?
let imageBackdrop: String?
let imagePortrait: String?
@ -398,11 +400,22 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
var isPlayable: Bool { playable == true }
var phaseValue: String { (phase ?? "upcoming").lowercased() }
var resolvedSportBucket: String {
let b = (sportBucket ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return b.isEmpty ? "other" : b
}
var resolvedSportLabel: String {
if let s = sportLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
return s
}
return EventSport.label(for: resolvedSportBucket)
}
enum CodingKeys: String, CodingKey {
case id, name, description, provider, category, eventType, subtitle
case channel, network, isLive, phase, playable, status
case start, stop, startLocal, stopLocal
case sportBucket, sportLabel
case imageLandscape, imageBackdrop, imagePortrait, logoUrl, images, audioLanguages
}
@ -425,6 +438,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop = try c.decodeIfPresent(String.self, forKey: .stop) ?? ""
startLocal = try c.decodeIfPresent(String.self, forKey: .startLocal)
stopLocal = try c.decodeIfPresent(String.self, forKey: .stopLocal)
sportBucket = try c.decodeIfPresent(String.self, forKey: .sportBucket)
sportLabel = try c.decodeIfPresent(String.self, forKey: .sportLabel)
imageLandscape = try c.decodeIfPresent(String.self, forKey: .imageLandscape)
imageBackdrop = try c.decodeIfPresent(String.self, forKey: .imageBackdrop)
imagePortrait = try c.decodeIfPresent(String.self, forKey: .imagePortrait)
@ -451,6 +466,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop: String,
startLocal: String?,
stopLocal: String?,
sportBucket: String? = nil,
sportLabel: String? = nil,
imageLandscape: String?,
imageBackdrop: String?,
imagePortrait: String?,
@ -475,6 +492,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
self.stop = stop
self.startLocal = startLocal
self.stopLocal = stopLocal
self.sportBucket = sportBucket
self.sportLabel = sportLabel
self.imageLandscape = imageLandscape
self.imageBackdrop = imageBackdrop
self.imagePortrait = imagePortrait
@ -510,6 +529,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop: stop,
startLocal: startLocal,
stopLocal: stopLocal,
sportBucket: sportBucket,
sportLabel: sportLabel,
imageLandscape: landscape ?? imageLandscape,
imageBackdrop: backdrop ?? imageBackdrop,
imagePortrait: portrait ?? imagePortrait,
@ -520,6 +541,30 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
}
}
enum EventSport {
static let order = [
"football", "racing", "tennis", "golf", "darts", "snooker",
"basketball", "waterpolo", "sailing", "hockey", "cycling", "other",
]
static func label(for bucket: String) -> String {
switch bucket.lowercased() {
case "football": return "Voetbal"
case "racing": return "Racing"
case "tennis": return "Tennis"
case "golf": return "Golf"
case "darts": return "Darts"
case "snooker": return "Snooker"
case "basketball": return "Basketbal"
case "waterpolo": return "Waterpolo"
case "sailing": return "Zeilen"
case "hockey": return "Hockey"
case "cycling": return "Wielrennen"
default: return "Overig"
}
}
}
/// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn.
func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String {
if let s = try? c.decode(String.self, forKey: key) { return s }

View file

@ -54,6 +54,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>picture-in-picture</string>
</array>
<key>UILaunchScreen</key>
<dict>

View file

@ -16,8 +16,8 @@ private final class ResumeOnce: @unchecked Sendable {
}
}
/// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey.
/// VLC speelt daarna plain DASH vanaf 127.0.0.1.
/// Lokale HTTP-proxy: haalt DASH MPD of HLS m3u8 + CENC-segmenten op, strip DRM, decrypt ClearKey.
/// VLC speelt daarna plain media vanaf 127.0.0.1.
final class ClearKeyDashProxy {
static let shared = ClearKeyDashProxy()
@ -28,6 +28,7 @@ final class ClearKeyDashProxy {
private var keyMap: ClearKeyKeyMap?
private var cryptoState = CencMp4Decryptor.SessionState()
private var generation = 0
private var isHlsMode = false
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 25
@ -41,19 +42,20 @@ final class ClearKeyDashProxy {
var isRunning: Bool { listener != nil && port > 0 }
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug.
func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL {
let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else {
throw ProxyError.noKeys
}
let hls = Self.isHlsURL(manifestURL)
let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in
queue.async {
self.stopLocked()
self.remoteManifest = manifestURL
self.keyMap = map
self.isHlsMode = hls
self.cryptoState = CencMp4Decryptor.SessionState()
self.generation += 1
@ -103,20 +105,28 @@ final class ClearKeyDashProxy {
guard boundPort > 0 else { throw ProxyError.bindFailed }
let gen = generation
let entryPath = hls ? "/manifest.m3u8" : "/manifest.mpd"
let (body, _) = try await payload(for: "/manifest.mpd")
let (body, _) = try await payload(for: entryPath)
guard !body.isEmpty, gen == generation else {
throw ProxyError.emptyManifest
}
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
proxyLog.info(
"warmup \(hls ? "HLS" : "MPD", privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)"
)
guard let local = URL(string: "http://127.0.0.1:\(boundPort)/manifest.mpd") else {
guard let local = URL(string: "http://127.0.0.1:\(boundPort)\(entryPath)") else {
throw ProxyError.bindFailed
}
return local
}
static func isHlsURL(_ url: URL) -> Bool {
let s = url.absoluteString.lowercased()
return s.contains(".m3u8") || s.contains("m3u8")
}
func stop() {
queue.sync { stopLocked() }
}
@ -128,6 +138,7 @@ final class ClearKeyDashProxy {
port = 0
remoteManifest = nil
keyMap = nil
isHlsMode = false
cryptoState = CencMp4Decryptor.SessionState()
proxyLog.info("proxy stopped")
}
@ -254,8 +265,16 @@ final class ClearKeyDashProxy {
let pathOnly = split.first ?? pathAndQuery
let query = split.count > 1 ? split[1] : nil
if pathOnly == "/" || pathOnly == "/manifest.mpd" {
if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" {
let raw = try await fetch(remoteManifest)
if isHlsMode || Self.looksLikeHls(raw) {
let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self)
guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else {
throw ProxyError.emptyManifest
}
let rewritten = rewriteHls(text, playlistURL: remoteManifest)
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
}
guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else {
// Sommige CDN's sturen UTF-16 BOM; probeer lossy.
let lossy = String(decoding: raw, as: UTF8.self)
@ -302,6 +321,11 @@ final class ClearKeyDashProxy {
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
var data = try await fetch(url)
let lower = url.absoluteString.lowercased()
if Self.isHlsURL(url) || Self.looksLikeHls(data) {
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
let rewritten = rewriteHls(text, playlistURL: url)
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
}
let isManifest = lower.contains(".mpd")
if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) {
let rewritten = rewriteMpd(xml, mpdURL: url)
@ -311,7 +335,10 @@ final class ClearKeyDashProxy {
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
}
let ct: String
if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") || lower.contains(".cmfv") || lower.contains(".cmfa") {
if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init")
|| lower.contains(".cmfv") || lower.contains(".cmfa") || lower.contains("map.mp4")
|| lower.contains("cmaf")
{
ct = "video/mp4"
} else {
ct = "application/octet-stream"
@ -319,11 +346,68 @@ final class ClearKeyDashProxy {
return (data, ct)
}
private static func looksLikeHls(_ data: Data) -> Bool {
let head = String(decoding: data.prefix(16), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
return head.hasPrefix("#EXTM3U")
}
/// Strip Widevine/PlayReady KEY-tags; herschrijf alle media-URI's naar de proxy.
private func rewriteHls(_ text: String, playlistURL: URL) -> String {
let lines = text.components(separatedBy: .newlines)
var out: [String] = []
out.reserveCapacity(lines.count)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#EXT-X-KEY:") || trimmed.hasPrefix("#EXT-X-SESSION-KEY:") {
continue
}
if trimmed.hasPrefix("#") {
out.append(rewriteHlsTagURIs(line, base: playlistURL))
} else if trimmed.isEmpty {
out.append(line)
} else {
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: trimmed)
out.append(proxyAbsoluteURL(resolved))
}
}
return out.joined(separator: "\n")
}
private func rewriteHlsTagURIs(_ line: String, base: URL) -> String {
guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else {
return line
}
let ns = line as NSString
let matches = regex.matches(in: line, options: [], range: NSRange(location: 0, length: ns.length))
var result = line
for match in matches.reversed() {
guard match.numberOfRanges >= 2 else { continue }
let uri = ns.substring(with: match.range(at: 1))
if uri.hasPrefix("data:") { continue }
if uri.contains("127.0.0.1:\(port)") { continue }
let resolved: URL
if uri.hasPrefix("http://") || uri.hasPrefix("https://") {
resolved = URL(string: uri) ?? base
} else {
resolved = join(base: asDirectory(base.deletingLastPathComponent()), relative: uri)
}
let replacement = "URI=\"\(proxyAbsoluteURL(resolved))\""
let full = match.range
result = (result as NSString).replacingCharacters(in: full, with: replacement)
}
return result
}
private func proxyAbsoluteURL(_ url: URL) -> String {
"http://127.0.0.1:\(port)/a/\(encodeURL(url))"
}
private func fetch(_ url: URL) async throws -> Data {
var req = URLRequest(url: url)
req.timeoutInterval = 25
req.cachePolicy = .reloadIgnoringLocalCacheData
req.setValue("VMC-iOS-ClearKey/0.5.4", forHTTPHeaderField: "User-Agent")
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
@ -534,7 +618,7 @@ final class ClearKeyDashProxy {
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
case .notStarted: return "ClearKey-proxy is niet actief"
case .badPath: return "Ongeldig proxy-pad"
case .emptyManifest: return "Leeg of ongeldig DASH-manifest ontvangen"
case .emptyManifest: return "Leeg of ongeldig stream-manifest ontvangen"
case .upstream(let code): return "Upstream HTTP \(code)"
}
}

View file

@ -43,6 +43,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var everPlayed = false
private var pipWindow: VLCPictureInPictureWindowControlling?
private var sceneObserver: NSObjectProtocol?
/// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld).
private weak var attachedView: UIView?
/// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start.
private var usePipDrawable = false
private var stopping = false
override init() {
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
@ -59,29 +64,45 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if let sceneObserver {
NotificationCenter.default.removeObserver(sceneObserver)
}
mediaPlayer.drawable = nil
mediaPlayer.stop()
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
}
}
func attach(to view: UIView) {
if attachedView === view { return }
attachedView = view
plainVideoView = view
view.backgroundColor = .black
applyDrawable()
}
/// Live/ClearKey → plain UIView. VOD → PiP-drawable (AVSampleBuffer-pad).
/// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable.
private func applyDrawable() {
guard let view = plainVideoView else { return }
if liveMode || clearKeyProxyActive {
pipPossible = false
drawableHost.detachVideo()
mediaPlayer.drawable = view
} else {
let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive
if wantPip {
drawableHost.attach(to: view)
mediaPlayer.drawable = drawableHost
if mediaPlayer.drawable as AnyObject? !== drawableHost {
mediaPlayer.drawable = drawableHost
}
} else {
drawableHost.detachVideo()
if mediaPlayer.drawable as AnyObject? !== view {
mediaPlayer.drawable = view
}
if liveMode || clearKeyProxyActive {
pipPossible = false
}
}
}
func startPictureInPicture() {
pipWindow?.startPictureInPicture()
preparePipDrawableThen {
self.pipWindow?.startPictureInPicture()
}
}
func stopPictureInPicture() {
@ -93,6 +114,33 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
else { startPictureInPicture() }
}
private func preparePipDrawableThen(_ work: @escaping () -> Void) {
if usePipDrawable, pipWindow != nil {
work()
return
}
usePipDrawable = true
applyDrawable()
// pictureInPictureReady komt async; kort wachten op pipWindow.
var attempts = 0
func tryStart() {
attempts += 1
if self.pipWindow != nil || attempts >= 12 {
work()
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart)
}
private func restorePlainDrawableAfterPip() {
guard !liveMode, !clearKeyProxyActive, !stopping else { return }
usePipDrawable = false
applyDrawable()
applySubtitleStyle()
}
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
hasError = false
errorMessage = nil
@ -101,7 +149,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
audioTracks = []
liveMode = false
clearKeyProxyActive = false
usePipDrawable = false
applyDrawable()
// PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken).
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
guard let media = VLCMedia(url: url) else {
hasError = true
errorMessage = "Kon media niet openen."
@ -150,6 +201,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
everPlayed = false
liveStartedAt = Date()
liveMode = true
usePipDrawable = false
var playURL = url
var playFormat = format
@ -159,8 +211,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
do {
stateText = "ClearKey laden…"
// Async: blokkeer de main thread niet (watchdog-crash).
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash"
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
clearKeyProxyActive = true
} catch {
hasError = true
@ -204,7 +257,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
}
if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") {
return "DASH-manifest kon niet worden geladen. Controleer de zender-URL."
return "Stream-manifest kon niet worden geladen. Controleer de zender-URL."
}
if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw."
@ -238,6 +291,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":network-caching=1800")
media.addOption(":http-reconnect")
media.addOption(":live-caching=1500")
media.addOption(":avcodec-hw=any")
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
media.addOption(":adaptive-logic=highest")
@ -294,10 +348,21 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
func addExternalSubtitle(url: URL) {
if pipActive {
stopPictureInPicture()
}
if usePipDrawable {
usePipDrawable = false
applyDrawable()
}
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.refreshTracks()
self?.applySubtitleStyle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return }
self.refreshTracks()
self.applySubtitleStyle()
if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) {
self.selectSubtitle(id: last.id)
}
}
}
@ -468,11 +533,28 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
func stop() {
guard !stopping else { return }
stopping = true
tick?.cancel()
tick = nil
let proxyWasActive = clearKeyProxyActive
clearKeyProxyActive = false
// Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft.
mediaPlayer.drawable = nil
drawableHost.detachVideo()
attachedView = nil
plainVideoView = nil
if pipActive {
stopPictureInPicture()
pipWindow?.stopPictureInPicture()
}
pipWindow = nil
pipPossible = false
pipActive = false
usePipDrawable = false
mediaPlayer.stop()
mediaPlayer.media = nil
livePlayURL = nil
@ -481,13 +563,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveRetryCount = 0
everPlayed = false
liveMode = false
pipWindow = nil
pipPossible = false
pipActive = false
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
if proxyWasActive {
// Laat in-flight HTTP van VLC even afronden vóór listener-cancel.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
ClearKeyDashProxy.shared.stop()
}
}
stopping = false
}
// MARK: - VLCMediaPlayerDelegate (VLCKit 4)
@ -588,7 +671,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
pipPossible = true
controller.stateChangeEventHandler = { [weak self] isStarted in
DispatchQueue.main.async {
self?.pipActive = isStarted
guard let self else { return }
self.pipActive = isStarted
if !isStarted {
self.restorePlainDrawableAfterPip()
}
}
}
// Automatisch PiP bij app → achtergrond (Netflix-gedrag).
@ -624,7 +711,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
object: nil,
queue: .main
) { [weak self] _ in
guard let self, self.isPlaying, !self.pipActive, self.pipPossible else { return }
guard let self, self.isPlaying, !self.pipActive else { return }
guard !self.liveMode, !self.clearKeyProxyActive else { return }
self.startPictureInPicture()
}
}
@ -729,6 +817,7 @@ struct VLCVideoView: UIViewRepresentable {
}
func updateUIView(_ uiView: UIView, context: Context) {
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
controller.attach(to: uiView)
}
}

View file

@ -1,5 +1,18 @@
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 {
@ -7,26 +20,51 @@ struct EventsScreen: View {
@State private var events: [ScheduleEvent] = []
@State private var loading = true
@State private var refreshing = false
@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 filtered: [ScheduleEvent] {
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 upcomingSections: [(String, [ScheduleEvent])] {
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)
@ -39,140 +77,450 @@ struct EventsScreen: View {
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)
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 {
VStack(spacing: 0) {
if providers.count > 1 {
providerChips
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 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)
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)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable { await refresh(isPull: true) }
}
}
}
private var providerChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
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) {
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
}
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)
.padding(.vertical, 8)
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 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())
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)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.horizontal, 24)
}
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
// 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
refreshing = 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)
}
}
@ -190,6 +538,7 @@ struct EventDetailScreen: View {
@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()
@ -203,13 +552,20 @@ struct EventDetailScreen: View {
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
}
} else {
ProgressView("Laden…").tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
ProgressView("Laden…")
.tint(EventsLook.neon)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(McColors.bg.ignoresSafeArea())
.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() }
@ -248,62 +604,72 @@ struct EventDetailScreen: View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
hero(ev)
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 12) {
Text(ev.name)
.font(McFonts.title)
.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: 0.8))
.foregroundStyle(Color.white.opacity(0.72))
.lineLimit(2)
}
let window = EventHelpers.detailTimeWindow(ev, now: now)
if !window.isEmpty {
Text(window)
.font(McFonts.headline)
.foregroundStyle(McColors.text)
.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(McColors.textDim)
.foregroundStyle(Color.white.opacity(0.5))
.lineLimit(3)
}
if let description = ev.description, !description.isEmpty {
Text(description)
.font(McFonts.body)
.foregroundStyle(Color(white: 0.91))
.foregroundStyle(Color.white.opacity(0.88))
.lineSpacing(4)
}
if let langs = ev.audioLanguages, !langs.isEmpty {
Text("Audio")
.font(McFonts.caption)
.foregroundStyle(Color(white: 0.56))
.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: 0.86))
.foregroundStyle(Color.white.opacity(0.86))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
.background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.padding(.vertical, 16)
}
}
@ -314,29 +680,38 @@ struct EventDetailScreen: View {
private func hero(_ ev: ScheduleEvent) -> some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
.frame(height: 220)
.frame(height: 280)
.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),
.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: {
Text("Terug")
.font(McFonts.caption)
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4))
.padding(10)
.background(.black.opacity(0.45), in: Circle())
}
Spacer()
if let logo = ev.logoUrl, !logo.isEmpty {
@ -344,13 +719,10 @@ struct EventDetailScreen: View {
.frame(width: 96, height: 36)
}
}
.padding(12)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
EventStatusBadge(event: ev, now: now)
.padding(16)
}
.frame(height: 220)
.frame(height: 280)
}
private func stickyBar(_ ev: ScheduleEvent) -> some View {
@ -359,10 +731,10 @@ struct EventDetailScreen: View {
Button {
Task { await play(ev) }
} label: {
Text("Kijk live")
Label("Kijk live", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(McPrimaryButtonStyle())
.buttonStyle(EventsLiveButtonStyle())
Button("Terug") { dismiss() }
.buttonStyle(McSecondaryButtonStyle())
@ -381,7 +753,7 @@ struct EventDetailScreen: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(McColors.bgElevated.ignoresSafeArea(edges: .bottom))
.background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom))
}
private func load() async {
@ -410,77 +782,56 @@ struct EventDetailScreen: View {
}
}
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: - 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)
}
}
// 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)
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))
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())
}
}
@ -499,22 +850,20 @@ enum EventHelpers {
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
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 }
let m = Int(stop.timeIntervalSince(now) / 60)
return m
return Int(stop.timeIntervalSince(now) / 60)
}
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) {
if isLive(event, now: now) { return ("LIVE", McColors.accent) }
if isLive(event, now: now) { return ("LIVE", EventsLook.live) }
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 ("Straks · \(m)m", EventsLook.soon)
}
return ("Gepland", Color(white: 0.23))
return ("Gepland", Color.white.opacity(0.18))
}
static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String {

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
LabeledContent("Versie", value: "0.5.2")
LabeledContent("Versie", value: "0.5.6")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
}

View file

@ -1,4 +1,4 @@
import { resolveSportBucket, sportLabelFor } from "./schedule-events";
import { detectLiveManifestFormat, resolveSportBucket, sportLabelFor } from "./schedule-events";
function assert(cond: unknown, msg: string) {
if (!cond) throw new Error(msg);
@ -51,4 +51,22 @@ expectBucket({ name: "Ligue 1: Monaco - Lens" }, "football");
expectBucket({ name: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
expectBucket({ name: "Some snooker final" }, "snooker");
function expectFormat(url: string, format: "hls" | "dash") {
const got = detectLiveManifestFormat(url);
assert(got === format, `format expected ${format} got ${got} for ${url.slice(0, 80)}`);
}
expectFormat(
"https://live-espn.example/event/ctr-all-hdri-complete.m3u8?r=1080",
"hls",
);
expectFormat(
"https://cdn.example/cenc.mpd?token=1",
"dash",
);
expectFormat(
"https://linear-espn.example/path/m3u8/playlist?hash=abc",
"hls",
);
console.log("schedule-events-sport tests ok");

View file

@ -853,9 +853,9 @@ export async function getScheduleEventPlay(
throw new AppError("NOT_READY", "Dit event is nog niet begonnen", 409);
}
const mpd = str(found.mpd) || str(found.manifest_url);
const streamUrl = str(found.mpd) || str(found.manifest_url);
const keys = extractKids(found);
if (!mpd || !keys.length) {
if (!streamUrl || !keys.length) {
throw new AppError(
"NOT_READY",
"Stream start binnenkort",
@ -863,6 +863,10 @@ export async function getScheduleEventPlay(
);
}
// ESPN/Disney e.d. leveren HLS/CMAF (.m3u8); Prime/Viaplay meestal DASH (.mpd).
// FAAB-veld heet vaak `mpd` ook als de URL een m3u8 is — nooit blind "dash" aannemen.
const format = detectLiveManifestFormat(streamUrl);
let watchSessionId: string | null = null;
if (viewerUserId) {
try {
@ -884,8 +888,8 @@ export async function getScheduleEventPlay(
eventId: pub.id,
name: pub.name,
logoUrl: pub.logoUrl ?? pub.imagePortrait,
streamUrl: mpd,
format: "dash" as const,
streamUrl,
format,
fallbackStreamUrl: null,
fallbackFormat: null,
drm: {
@ -895,3 +899,10 @@ export async function getScheduleEventPlay(
watchSessionId,
};
}
/** Infer client manifest type from URL (FAAB mislabels HLS as `mpd`). */
export function detectLiveManifestFormat(url: string): "hls" | "dash" {
const u = str(url).toLowerCase();
if (u.includes(".m3u8") || u.includes("m3u8")) return "hls";
return "dash";
}