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

View file

@ -389,6 +389,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
let stop: String let stop: String
let startLocal: String? let startLocal: String?
let stopLocal: String? let stopLocal: String?
let sportBucket: String?
let sportLabel: String?
let imageLandscape: String? let imageLandscape: String?
let imageBackdrop: String? let imageBackdrop: String?
let imagePortrait: String? let imagePortrait: String?
@ -398,11 +400,22 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
var isPlayable: Bool { playable == true } var isPlayable: Bool { playable == true }
var phaseValue: String { (phase ?? "upcoming").lowercased() } 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 { enum CodingKeys: String, CodingKey {
case id, name, description, provider, category, eventType, subtitle case id, name, description, provider, category, eventType, subtitle
case channel, network, isLive, phase, playable, status case channel, network, isLive, phase, playable, status
case start, stop, startLocal, stopLocal case start, stop, startLocal, stopLocal
case sportBucket, sportLabel
case imageLandscape, imageBackdrop, imagePortrait, logoUrl, images, audioLanguages case imageLandscape, imageBackdrop, imagePortrait, logoUrl, images, audioLanguages
} }
@ -425,6 +438,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop = try c.decodeIfPresent(String.self, forKey: .stop) ?? "" stop = try c.decodeIfPresent(String.self, forKey: .stop) ?? ""
startLocal = try c.decodeIfPresent(String.self, forKey: .startLocal) startLocal = try c.decodeIfPresent(String.self, forKey: .startLocal)
stopLocal = try c.decodeIfPresent(String.self, forKey: .stopLocal) 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) imageLandscape = try c.decodeIfPresent(String.self, forKey: .imageLandscape)
imageBackdrop = try c.decodeIfPresent(String.self, forKey: .imageBackdrop) imageBackdrop = try c.decodeIfPresent(String.self, forKey: .imageBackdrop)
imagePortrait = try c.decodeIfPresent(String.self, forKey: .imagePortrait) imagePortrait = try c.decodeIfPresent(String.self, forKey: .imagePortrait)
@ -451,6 +466,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop: String, stop: String,
startLocal: String?, startLocal: String?,
stopLocal: String?, stopLocal: String?,
sportBucket: String? = nil,
sportLabel: String? = nil,
imageLandscape: String?, imageLandscape: String?,
imageBackdrop: String?, imageBackdrop: String?,
imagePortrait: String?, imagePortrait: String?,
@ -475,6 +492,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
self.stop = stop self.stop = stop
self.startLocal = startLocal self.startLocal = startLocal
self.stopLocal = stopLocal self.stopLocal = stopLocal
self.sportBucket = sportBucket
self.sportLabel = sportLabel
self.imageLandscape = imageLandscape self.imageLandscape = imageLandscape
self.imageBackdrop = imageBackdrop self.imageBackdrop = imageBackdrop
self.imagePortrait = imagePortrait self.imagePortrait = imagePortrait
@ -510,6 +529,8 @@ struct ScheduleEvent: Decodable, Identifiable, Hashable {
stop: stop, stop: stop,
startLocal: startLocal, startLocal: startLocal,
stopLocal: stopLocal, stopLocal: stopLocal,
sportBucket: sportBucket,
sportLabel: sportLabel,
imageLandscape: landscape ?? imageLandscape, imageLandscape: landscape ?? imageLandscape,
imageBackdrop: backdrop ?? imageBackdrop, imageBackdrop: backdrop ?? imageBackdrop,
imagePortrait: portrait ?? imagePortrait, 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. /// API streamId kan String (`"123"` / `"custom:uuid"`) of legacy Int zijn.
func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String { func decodeFlexibleString<K: CodingKey>(_ c: KeyedDecodingContainer<K>, forKey key: K) throws -> String {
if let s = try? c.decode(String.self, forKey: key) { return s } if let s = try? c.decode(String.self, forKey: key) { return s }

View file

@ -54,6 +54,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>picture-in-picture</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict> <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. /// Lokale HTTP-proxy: haalt DASH MPD of HLS m3u8 + CENC-segmenten op, strip DRM, decrypt ClearKey.
/// VLC speelt daarna plain DASH vanaf 127.0.0.1. /// VLC speelt daarna plain media vanaf 127.0.0.1.
final class ClearKeyDashProxy { final class ClearKeyDashProxy {
static let shared = ClearKeyDashProxy() static let shared = ClearKeyDashProxy()
@ -28,6 +28,7 @@ final class ClearKeyDashProxy {
private var keyMap: ClearKeyKeyMap? private var keyMap: ClearKeyKeyMap?
private var cryptoState = CencMp4Decryptor.SessionState() private var cryptoState = CencMp4Decryptor.SessionState()
private var generation = 0 private var generation = 0
private var isHlsMode = false
private let urlSession: URLSession = { private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 25 cfg.timeoutIntervalForRequest = 25
@ -41,19 +42,20 @@ final class ClearKeyDashProxy {
var isRunning: Bool { listener != nil && port > 0 } var isRunning: Bool { listener != nil && port > 0 }
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug. /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug.
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL { func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL {
let map = ClearKeyKeyMap(entries: keys) let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else { guard !map.isEmpty else {
throw ProxyError.noKeys throw ProxyError.noKeys
} }
let hls = Self.isHlsURL(manifestURL)
let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in
queue.async { queue.async {
self.stopLocked() self.stopLocked()
self.remoteManifest = manifestURL self.remoteManifest = manifestURL
self.keyMap = map self.keyMap = map
self.isHlsMode = hls
self.cryptoState = CencMp4Decryptor.SessionState() self.cryptoState = CencMp4Decryptor.SessionState()
self.generation += 1 self.generation += 1
@ -103,20 +105,28 @@ final class ClearKeyDashProxy {
guard boundPort > 0 else { throw ProxyError.bindFailed } guard boundPort > 0 else { throw ProxyError.bindFailed }
let gen = generation 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 { guard !body.isEmpty, gen == generation else {
throw ProxyError.emptyManifest throw ProxyError.emptyManifest
} }
let preview = String(data: body.prefix(200), encoding: .utf8) ?? "" 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 throw ProxyError.bindFailed
} }
return local return local
} }
static func isHlsURL(_ url: URL) -> Bool {
let s = url.absoluteString.lowercased()
return s.contains(".m3u8") || s.contains("m3u8")
}
func stop() { func stop() {
queue.sync { stopLocked() } queue.sync { stopLocked() }
} }
@ -128,6 +138,7 @@ final class ClearKeyDashProxy {
port = 0 port = 0
remoteManifest = nil remoteManifest = nil
keyMap = nil keyMap = nil
isHlsMode = false
cryptoState = CencMp4Decryptor.SessionState() cryptoState = CencMp4Decryptor.SessionState()
proxyLog.info("proxy stopped") proxyLog.info("proxy stopped")
} }
@ -254,8 +265,16 @@ final class ClearKeyDashProxy {
let pathOnly = split.first ?? pathAndQuery let pathOnly = split.first ?? pathAndQuery
let query = split.count > 1 ? split[1] : nil 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) 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 { guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else {
// Sommige CDN's sturen UTF-16 BOM; probeer lossy. // Sommige CDN's sturen UTF-16 BOM; probeer lossy.
let lossy = String(decoding: raw, as: UTF8.self) 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) { private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
var data = try await fetch(url) var data = try await fetch(url)
let lower = url.absoluteString.lowercased() 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") let isManifest = lower.contains(".mpd")
if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) { if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) {
let rewritten = rewriteMpd(xml, mpdURL: url) let rewritten = rewriteMpd(xml, mpdURL: url)
@ -311,7 +335,10 @@ final class ClearKeyDashProxy {
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState) _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
} }
let ct: String 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" ct = "video/mp4"
} else { } else {
ct = "application/octet-stream" ct = "application/octet-stream"
@ -319,11 +346,68 @@ final class ClearKeyDashProxy {
return (data, ct) 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 { private func fetch(_ url: URL) async throws -> Data {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.timeoutInterval = 25 req.timeoutInterval = 25
req.cachePolicy = .reloadIgnoringLocalCacheData 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") req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req) let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { 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 .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
case .notStarted: return "ClearKey-proxy is niet actief" case .notStarted: return "ClearKey-proxy is niet actief"
case .badPath: return "Ongeldig proxy-pad" 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)" 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 everPlayed = false
private var pipWindow: VLCPictureInPictureWindowControlling? private var pipWindow: VLCPictureInPictureWindowControlling?
private var sceneObserver: NSObjectProtocol? 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() { override init() {
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4). // Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
@ -59,29 +64,45 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if let sceneObserver { if let sceneObserver {
NotificationCenter.default.removeObserver(sceneObserver) NotificationCenter.default.removeObserver(sceneObserver)
} }
mediaPlayer.drawable = nil
mediaPlayer.stop()
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
}
} }
func attach(to view: UIView) { func attach(to view: UIView) {
if attachedView === view { return }
attachedView = view
plainVideoView = view plainVideoView = view
view.backgroundColor = .black view.backgroundColor = .black
applyDrawable() 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() { private func applyDrawable() {
guard let view = plainVideoView else { return } guard let view = plainVideoView else { return }
let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive
if wantPip {
drawableHost.attach(to: view)
if mediaPlayer.drawable as AnyObject? !== drawableHost {
mediaPlayer.drawable = drawableHost
}
} else {
drawableHost.detachVideo()
if mediaPlayer.drawable as AnyObject? !== view {
mediaPlayer.drawable = view
}
if liveMode || clearKeyProxyActive { if liveMode || clearKeyProxyActive {
pipPossible = false pipPossible = false
drawableHost.detachVideo() }
mediaPlayer.drawable = view
} else {
drawableHost.attach(to: view)
mediaPlayer.drawable = drawableHost
} }
} }
func startPictureInPicture() { func startPictureInPicture() {
pipWindow?.startPictureInPicture() preparePipDrawableThen {
self.pipWindow?.startPictureInPicture()
}
} }
func stopPictureInPicture() { func stopPictureInPicture() {
@ -93,6 +114,33 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
else { startPictureInPicture() } 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) { func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
hasError = false hasError = false
errorMessage = nil errorMessage = nil
@ -101,7 +149,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
audioTracks = [] audioTracks = []
liveMode = false liveMode = false
clearKeyProxyActive = false clearKeyProxyActive = false
usePipDrawable = false
applyDrawable() applyDrawable()
// PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken).
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
guard let media = VLCMedia(url: url) else { guard let media = VLCMedia(url: url) else {
hasError = true hasError = true
errorMessage = "Kon media niet openen." errorMessage = "Kon media niet openen."
@ -150,6 +201,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
everPlayed = false everPlayed = false
liveStartedAt = Date() liveStartedAt = Date()
liveMode = true liveMode = true
usePipDrawable = false
var playURL = url var playURL = url
var playFormat = format var playFormat = format
@ -159,8 +211,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
do { do {
stateText = "ClearKey laden…" stateText = "ClearKey laden…"
// Async: blokkeer de main thread niet (watchdog-crash). // 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) playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash" playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
clearKeyProxyActive = true clearKeyProxyActive = true
} catch { } catch {
hasError = true hasError = true
@ -204,7 +257,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return "Stream-server gaf een fout (\(msg)). Probeer opnieuw." return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
} }
if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") { 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") { if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw." 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(":network-caching=1800")
media.addOption(":http-reconnect") media.addOption(":http-reconnect")
media.addOption(":live-caching=1500") media.addOption(":live-caching=1500")
media.addOption(":avcodec-hw=any")
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module). // VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
media.addOption(":adaptive-logic=highest") media.addOption(":adaptive-logic=highest")
@ -294,10 +348,21 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
func addExternalSubtitle(url: URL) { func addExternalSubtitle(url: URL) {
if pipActive {
stopPictureInPicture()
}
if usePipDrawable {
usePipDrawable = false
applyDrawable()
}
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.refreshTracks() guard let self else { return }
self?.applySubtitleStyle() 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() { func stop() {
guard !stopping else { return }
stopping = true
tick?.cancel() tick?.cancel()
tick = nil 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 { if pipActive {
stopPictureInPicture() pipWindow?.stopPictureInPicture()
} }
pipWindow = nil
pipPossible = false
pipActive = false
usePipDrawable = false
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.media = nil mediaPlayer.media = nil
livePlayURL = nil livePlayURL = nil
@ -481,14 +563,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveRetryCount = 0 liveRetryCount = 0
everPlayed = false everPlayed = false
liveMode = false liveMode = false
pipWindow = nil
pipPossible = false if proxyWasActive {
pipActive = false // Laat in-flight HTTP van VLC even afronden vóór listener-cancel.
if clearKeyProxyActive { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
ClearKeyDashProxy.shared.stop() ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
} }
} }
stopping = false
}
// MARK: - VLCMediaPlayerDelegate (VLCKit 4) // MARK: - VLCMediaPlayerDelegate (VLCKit 4)
@ -588,7 +671,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
pipPossible = true pipPossible = true
controller.stateChangeEventHandler = { [weak self] isStarted in controller.stateChangeEventHandler = { [weak self] isStarted in
DispatchQueue.main.async { 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). // Automatisch PiP bij app → achtergrond (Netflix-gedrag).
@ -624,7 +711,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [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() self.startPictureInPicture()
} }
} }
@ -729,6 +817,7 @@ struct VLCVideoView: UIViewRepresentable {
} }
func updateUIView(_ uiView: UIView, context: Context) { func updateUIView(_ uiView: UIView, context: Context) {
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
controller.attach(to: uiView) controller.attach(to: uiView)
} }
} }

View file

@ -1,5 +1,18 @@
import SwiftUI 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 // MARK: - List
struct EventsScreen: View { struct EventsScreen: View {
@ -7,26 +20,51 @@ struct EventsScreen: View {
@State private var events: [ScheduleEvent] = [] @State private var events: [ScheduleEvent] = []
@State private var loading = true @State private var loading = true
@State private var refreshing = false
@State private var error: String? @State private var error: String?
@State private var sportFilter: String?
@State private var providerFilter: String? @State private var providerFilter: String?
@State private var now = Date() @State private var now = Date()
@State private var path = NavigationPath() @State private var path = NavigationPath()
@State private var pulse = false
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() 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 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 } guard let providerFilter, !providerFilter.isEmpty else { return events }
return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame } 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] { private var liveEvents: [ScheduleEvent] {
filtered.filter { EventHelpers.isLive($0, now: now) } filtered.filter { EventHelpers.isLive($0, now: now) }
.sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) } .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 EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in
let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) } let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) }
return upcoming.isEmpty ? nil : (label, upcoming) return upcoming.isEmpty ? nil : (label, upcoming)
@ -39,15 +77,17 @@ struct EventsScreen: View {
var body: some View { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
ZStack {
eventsBackground
content content
.background(McColors.bg.ignoresSafeArea()) }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
BrandHeader(subtitle: "EVENTS") BrandHeader(subtitle: "EVENTS")
} }
} }
.toolbarBackground(McColors.bg, for: .navigationBar) .toolbarBackground(EventsLook.deep.opacity(0.92), for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.navigationDestination(for: String.self) { eventId in .navigationDestination(for: String.self) { eventId in
EventDetailScreen(eventId: eventId) EventDetailScreen(eventId: eventId)
@ -55,124 +95,432 @@ struct EventsScreen: View {
} }
} }
.task { await refresh() } .task { await refresh() }
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
pulse = true
}
}
.onReceive(clock) { now = $0 } .onReceive(clock) { now = $0 }
.onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } } .onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } }
} }
@ViewBuilder private var eventsBackground: some View {
private var content: some View { ZStack {
VStack(spacing: 0) { EventsLook.deep.ignoresSafeArea()
if providers.count > 1 { RadialGradient(
providerChips 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 { if loading && events.isEmpty {
ProgressView("Events laden…") ProgressView("Sportagenda laden…")
.tint(.white) .tint(EventsLook.neon)
.foregroundStyle(McColors.textDim) .foregroundStyle(McColors.textDim)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error, events.isEmpty { } else if let error, events.isEmpty {
VStack(spacing: 12) { VStack(spacing: 14) {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) Text(error)
.foregroundStyle(McColors.danger)
.multilineTextAlignment(.center)
Button("Opnieuw") { Task { await refresh() } } Button("Opnieuw") { Task { await refresh() } }
.buttonStyle(McPrimaryButtonStyle()) .buttonStyle(McPrimaryButtonStyle())
} }
.padding() .padding()
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 { } else {
List { ScrollView {
VStack(alignment: .leading, spacing: 22) {
headerBlock
filterChips
if filtered.isEmpty {
emptyBlock
} else {
if !liveEvents.isEmpty { if !liveEvents.isEmpty {
Section { liveShelf
ForEach(liveEvents) { ev in
Button { path.append(ev.id) } label: {
EventRowView(event: ev, now: now)
} }
.listRowBackground(McColors.bgElevated) if sportFilter == nil {
.listRowSeparatorTint(Color.white.opacity(0.06)) ForEach(sportShelves, id: \.id) { shelf in
eventShelf(
title: shelf.label,
accent: EventsLook.neon,
items: shelf.items,
featured: false
)
} }
} header: { } else {
Text("Nu live") ForEach(daySections, id: \.0) { label, items in
.font(McFonts.headline) eventShelf(
.foregroundStyle(McColors.text) title: label,
.textCase(nil) accent: EventsLook.neon.opacity(0.85),
} items: items,
} featured: false
)
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) .padding(.bottom, 28)
}
.refreshable { await refresh(isPull: true) } .refreshable { await refresh(isPull: true) }
} }
} }
}
private var providerChips: some View { private var headerBlock: some View {
ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .firstTextBaseline) {
HStack(spacing: 8) { VStack(alignment: .leading, spacing: 4) {
chip(label: "Alles", active: providerFilter == nil) { Text("Sportagenda")
providerFilter = nil .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(subtitleLine)
.font(McFonts.caption)
.foregroundStyle(Color.white.opacity(0.55))
} }
ForEach(providers, id: \.self) { p in Spacer(minLength: 8)
chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) { if !liveEvents.isEmpty {
providerFilter = p 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(.horizontal, 16)
.padding(.vertical, 8) .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 func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View { private var liveShelf: some View {
Button(action: action) { eventShelf(
Text(label) 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) .font(McFonts.caption)
.foregroundStyle(active ? .white : McColors.textDim) .foregroundStyle(Color.white.opacity(0.35))
.padding(.horizontal, 14) }
.padding(.vertical, 8) .padding(.horizontal, 16)
.background(active ? McColors.accent : McColors.surface, in: Capsule())
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) .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 { private func refresh(isPull: Bool = false, silent: Bool = false) async {
if isPull { refreshing = true }
if !silent && events.isEmpty { loading = true } if !silent && events.isEmpty { loading = true }
do { do {
let payload = try await app.api.listScheduleEvents() let payload = try await app.api.listScheduleEvents()
events = EventHelpers.enrichImages(payload.events) events = EventHelpers.enrichImages(payload.events)
error = nil 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 { } catch {
if events.isEmpty { if events.isEmpty {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
} }
loading = false 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 now = Date()
@State private var playInfo: IptvPlayInfo? @State private var playInfo: IptvPlayInfo?
@State private var playError: String? @State private var playError: String?
@State private var pulse = false
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
@ -203,13 +552,20 @@ struct EventDetailScreen: View {
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle()) Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
} }
} else { } 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) .navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .navigationBar)
.task { await load() } .task { await load() }
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
pulse = true
}
}
.onReceive(clock) { date in .onReceive(clock) { date in
now = date now = date
Task { await refreshEvent() } Task { await refreshEvent() }
@ -248,62 +604,72 @@ struct EventDetailScreen: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
hero(ev) hero(ev)
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 12) {
Text(ev.name) Text(ev.name)
.font(McFonts.title) .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(3) .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) let meta = EventHelpers.detailMeta(ev)
if !meta.isEmpty { if !meta.isEmpty {
Text(meta) Text(meta)
.font(McFonts.body) .font(McFonts.body)
.foregroundStyle(Color(white: 0.8)) .foregroundStyle(Color.white.opacity(0.72))
.lineLimit(2) .lineLimit(2)
} }
let window = EventHelpers.detailTimeWindow(ev, now: now) let window = EventHelpers.detailTimeWindow(ev, now: now)
if !window.isEmpty { if !window.isEmpty {
Text(window) Text(window)
.font(McFonts.headline) .font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(McColors.text) .foregroundStyle(.white)
} }
if let subtitle = ev.subtitle, !subtitle.isEmpty, if let subtitle = ev.subtitle, !subtitle.isEmpty,
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
Text(subtitle) Text(subtitle)
.font(McFonts.body) .font(McFonts.body)
.foregroundStyle(McColors.textDim) .foregroundStyle(Color.white.opacity(0.5))
.lineLimit(3) .lineLimit(3)
} }
if let description = ev.description, !description.isEmpty { if let description = ev.description, !description.isEmpty {
Text(description) Text(description)
.font(McFonts.body) .font(McFonts.body)
.foregroundStyle(Color(white: 0.91)) .foregroundStyle(Color.white.opacity(0.88))
.lineSpacing(4) .lineSpacing(4)
} }
if let langs = ev.audioLanguages, !langs.isEmpty { if let langs = ev.audioLanguages, !langs.isEmpty {
Text("Audio") Text("Audio")
.font(McFonts.caption) .font(McFonts.caption)
.foregroundStyle(Color(white: 0.56)) .foregroundStyle(Color.white.opacity(0.4))
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(langs.prefix(8), id: \.self) { lang in ForEach(langs.prefix(8), id: \.self) { lang in
Text(lang.uppercased()) Text(lang.uppercased())
.font(McFonts.caption) .font(McFonts.caption)
.foregroundStyle(Color(white: 0.86)) .foregroundStyle(Color.white.opacity(0.86))
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 5)
.background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4)) .background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6))
} }
} }
} }
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 16)
} }
} }
@ -314,29 +680,38 @@ struct EventDetailScreen: View {
private func hero(_ ev: ScheduleEvent) -> some View { private func hero(_ ev: ScheduleEvent) -> some View {
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
.frame(height: 220) .frame(height: 280)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.clipped() .clipped()
.overlay { .overlay {
LinearGradient( LinearGradient(
stops: [ stops: [
.init(color: .black.opacity(0.2), location: 0), .init(color: .black.opacity(0.25), location: 0),
.init(color: .clear, location: 0.45), .init(color: .clear, location: 0.4),
.init(color: McColors.bg.opacity(0.95), location: 1), .init(color: EventsLook.deep.opacity(0.98), location: 1),
], ],
startPoint: .top, startPoint: .top,
endPoint: .bottom 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 { HStack {
Button { dismiss() } label: { Button { dismiss() } label: {
Text("Terug") Image(systemName: "chevron.left")
.font(McFonts.caption) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 12) .padding(10)
.padding(.vertical, 6) .background(.black.opacity(0.45), in: Circle())
.background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4))
} }
Spacer() Spacer()
if let logo = ev.logoUrl, !logo.isEmpty { if let logo = ev.logoUrl, !logo.isEmpty {
@ -344,13 +719,10 @@ struct EventDetailScreen: View {
.frame(width: 96, height: 36) .frame(width: 96, height: 36)
} }
} }
.padding(12) .padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .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 { private func stickyBar(_ ev: ScheduleEvent) -> some View {
@ -359,10 +731,10 @@ struct EventDetailScreen: View {
Button { Button {
Task { await play(ev) } Task { await play(ev) }
} label: { } label: {
Text("Kijk live") Label("Kijk live", systemImage: "play.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(McPrimaryButtonStyle()) .buttonStyle(EventsLiveButtonStyle())
Button("Terug") { dismiss() } Button("Terug") { dismiss() }
.buttonStyle(McSecondaryButtonStyle()) .buttonStyle(McSecondaryButtonStyle())
@ -381,7 +753,7 @@ struct EventDetailScreen: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(McColors.bgElevated.ignoresSafeArea(edges: .bottom)) .background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom))
} }
private func load() async { 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 { private struct EventPlayItem: Identifiable {
var id: String { info.streamId.isEmpty ? info.name : info.streamId } var id: String { info.streamId.isEmpty ? info.name : info.streamId }
let info: IptvPlayInfo let info: IptvPlayInfo
} }
// MARK: - Row // MARK: - Badge
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 { private struct EventStatusBadge: View {
let event: ScheduleEvent let event: ScheduleEvent
let now: Date let now: Date
var compact: Bool = false var compact: Bool = false
var pulse: Bool = false
var body: some View { var body: some View {
let (label, color) = EventHelpers.statusVisual(event, now: now) 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) Text(label)
.font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded)) .font(.system(size: compact ? 10 : 12, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, compact ? 8 : 10) }
.padding(.horizontal, compact ? 7 : 10)
.padding(.vertical, compact ? 3 : 5) .padding(.vertical, compact ? 3 : 5)
.background(color, in: RoundedRectangle(cornerRadius: 4)) .background(color, in: Capsule())
} }
} }
@ -499,22 +850,20 @@ enum EventHelpers {
static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? { static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? {
guard let start = parse(event.startLocal, event.start) else { return nil } guard let start = parse(event.startLocal, event.start) else { return nil }
let m = Int(start.timeIntervalSince(now) / 60) return Int(start.timeIntervalSince(now) / 60)
return m
} }
static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? { static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? {
guard let stop = parse(event.stopLocal, event.stop) else { return nil } guard let stop = parse(event.stopLocal, event.stop) else { return nil }
let m = Int(stop.timeIntervalSince(now) / 60) return Int(stop.timeIntervalSince(now) / 60)
return m
} }
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) { 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 { 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 { static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String {

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") { Section("Info") {
LabeledContent("App", value: "VMC iOS") 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("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "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) { function assert(cond: unknown, msg: string) {
if (!cond) throw new Error(msg); 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: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
expectBucket({ name: "Some snooker final" }, "snooker"); 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"); 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); 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); const keys = extractKids(found);
if (!mpd || !keys.length) { if (!streamUrl || !keys.length) {
throw new AppError( throw new AppError(
"NOT_READY", "NOT_READY",
"Stream start binnenkort", "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; let watchSessionId: string | null = null;
if (viewerUserId) { if (viewerUserId) {
try { try {
@ -884,8 +888,8 @@ export async function getScheduleEventPlay(
eventId: pub.id, eventId: pub.id,
name: pub.name, name: pub.name,
logoUrl: pub.logoUrl ?? pub.imagePortrait, logoUrl: pub.logoUrl ?? pub.imagePortrait,
streamUrl: mpd, streamUrl,
format: "dash" as const, format,
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
drm: { drm: {
@ -895,3 +899,10 @@ export async function getScheduleEventPlay(
watchSessionId, 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";
}