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:
parent
1efdc3bef0
commit
bfeb270459
9 changed files with 832 additions and 235 deletions
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>picture-in-picture</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
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 {
|
||||
pipPossible = false
|
||||
drawableHost.detachVideo()
|
||||
mediaPlayer.drawable = view
|
||||
} else {
|
||||
drawableHost.attach(to: view)
|
||||
mediaPlayer.drawable = drawableHost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,14 +563,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
liveRetryCount = 0
|
||||
everPlayed = false
|
||||
liveMode = false
|
||||
pipWindow = nil
|
||||
pipPossible = false
|
||||
pipActive = false
|
||||
if clearKeyProxyActive {
|
||||
|
||||
if proxyWasActive {
|
||||
// Laat in-flight HTTP van VLC even afronden vóór listener-cancel.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
ClearKeyDashProxy.shared.stop()
|
||||
clearKeyProxyActive = false
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +77,17 @@ struct EventsScreen: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ZStack {
|
||||
eventsBackground
|
||||
content
|
||||
.background(McColors.bg.ignoresSafeArea())
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
BrandHeader(subtitle: "EVENTS")
|
||||
}
|
||||
}
|
||||
.toolbarBackground(McColors.bg, for: .navigationBar)
|
||||
.toolbarBackground(EventsLook.deep.opacity(0.92), for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.navigationDestination(for: String.self) { eventId in
|
||||
EventDetailScreen(eventId: eventId)
|
||||
|
|
@ -55,124 +95,432 @@ struct EventsScreen: View {
|
|||
}
|
||||
}
|
||||
.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) } }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
if providers.count > 1 {
|
||||
providerChips
|
||||
private var eventsBackground: some View {
|
||||
ZStack {
|
||||
EventsLook.deep.ignoresSafeArea()
|
||||
RadialGradient(
|
||||
colors: [
|
||||
EventsLook.neon.opacity(0.14),
|
||||
EventsLook.live.opacity(0.06),
|
||||
.clear,
|
||||
],
|
||||
center: .topTrailing,
|
||||
startRadius: 20,
|
||||
endRadius: 420
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
RadialGradient(
|
||||
colors: [EventsLook.neon.opacity(0.07), .clear],
|
||||
center: .bottomLeading,
|
||||
startRadius: 10,
|
||||
endRadius: 360
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if loading && events.isEmpty {
|
||||
ProgressView("Events laden…")
|
||||
.tint(.white)
|
||||
ProgressView("Sportagenda laden…")
|
||||
.tint(EventsLook.neon)
|
||||
.foregroundStyle(McColors.textDim)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error, events.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
|
||||
VStack(spacing: 14) {
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
headerBlock
|
||||
filterChips
|
||||
if filtered.isEmpty {
|
||||
emptyBlock
|
||||
} else {
|
||||
if !liveEvents.isEmpty {
|
||||
Section {
|
||||
ForEach(liveEvents) { ev in
|
||||
Button { path.append(ev.id) } label: {
|
||||
EventRowView(event: ev, now: now)
|
||||
liveShelf
|
||||
}
|
||||
.listRowBackground(McColors.bgElevated)
|
||||
.listRowSeparatorTint(Color.white.opacity(0.06))
|
||||
if sportFilter == nil {
|
||||
ForEach(sportShelves, id: \.id) { shelf in
|
||||
eventShelf(
|
||||
title: shelf.label,
|
||||
accent: EventsLook.neon,
|
||||
items: shelf.items,
|
||||
featured: false
|
||||
)
|
||||
}
|
||||
} 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)
|
||||
} else {
|
||||
ForEach(daySections, id: \.0) { label, items in
|
||||
eventShelf(
|
||||
title: label,
|
||||
accent: EventsLook.neon.opacity(0.85),
|
||||
items: items,
|
||||
featured: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.refreshable { await refresh(isPull: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var providerChips: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
chip(label: "Alles", active: providerFilter == nil) {
|
||||
providerFilter = nil
|
||||
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))
|
||||
}
|
||||
ForEach(providers, id: \.self) { p in
|
||||
chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) {
|
||||
providerFilter = p
|
||||
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(.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 {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
private var liveShelf: some View {
|
||||
eventShelf(
|
||||
title: "Nu live",
|
||||
accent: EventsLook.live,
|
||||
items: liveEvents,
|
||||
featured: true
|
||||
)
|
||||
}
|
||||
|
||||
private func eventShelf(
|
||||
title: String,
|
||||
accent: Color,
|
||||
items: [ScheduleEvent],
|
||||
featured: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(accent)
|
||||
.frame(width: 3, height: 16)
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
if featured {
|
||||
Text("NU")
|
||||
.font(.system(size: 10, weight: .heavy, design: .rounded))
|
||||
.foregroundStyle(EventsLook.live)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(EventsLook.liveSoft, in: Capsule())
|
||||
}
|
||||
Spacer()
|
||||
Text("\(items.count)")
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(active ? .white : McColors.textDim)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(active ? McColors.accent : McColors.surface, in: Capsule())
|
||||
.foregroundStyle(Color.white.opacity(0.35))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(items) { ev in
|
||||
Button { path.append(ev.id) } label: {
|
||||
EventShelfCard(event: ev, now: now, featured: featured, pulse: pulse)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyBlock: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "sportscourt")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(EventsLook.neon.opacity(0.55))
|
||||
Text(
|
||||
sportFilter != nil
|
||||
? "Geen events in \(EventSport.label(for: sportFilter!))"
|
||||
: (providerFilter != nil
|
||||
? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))"
|
||||
: "Geen events in de komende dagen")
|
||||
)
|
||||
.font(McFonts.body)
|
||||
.foregroundStyle(McColors.textDim)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private func refresh(isPull: Bool = false, silent: Bool = false) async {
|
||||
if 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)
|
||||
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 ? 11 : 13, weight: .bold, design: .rounded))
|
||||
.font(.system(size: compact ? 10 : 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, compact ? 8 : 10)
|
||||
}
|
||||
.padding(.horizontal, compact ? 7 : 10)
|
||||
.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? {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue