From bfeb2704591b4f5be08d7f991f4372b81cb28008 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 20 Sep 2026 02:02:38 +0200 Subject: [PATCH] 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. --- apps/ios/VMC.xcodeproj/project.pbxproj | 8 +- apps/ios/VMC/Data/Models.swift | 45 ++ apps/ios/VMC/Info.plist | 1 + apps/ios/VMC/Playback/ClearKeyDashProxy.swift | 106 ++- .../VMC/Playback/VLCPlayerController.swift | 133 +++- apps/ios/VMC/UI/EventsScreen.swift | 733 +++++++++++++----- apps/ios/VMC/UI/SettingsScreen.swift | 2 +- .../src/viewer/schedule-events-sport.test.ts | 20 +- apps/master-api/src/viewer/schedule-events.ts | 19 +- 9 files changed, 832 insertions(+), 235 deletions(-) diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index d52f4f5..b3bf941 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -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)"; diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 14a7a2b..ac82128 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -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(_ c: KeyedDecodingContainer, forKey key: K) throws -> String { if let s = try? c.decode(String.self, forKey: key) { return s } diff --git a/apps/ios/VMC/Info.plist b/apps/ios/VMC/Info.plist index 258d939..438e6e5 100644 --- a/apps/ios/VMC/Info.plist +++ b/apps/ios/VMC/Info.plist @@ -54,6 +54,7 @@ UIBackgroundModes audio + picture-in-picture UILaunchScreen diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index 6731df3..017015b 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -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)" } } diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index b876781..660ced1 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -43,6 +43,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var everPlayed = false private var pipWindow: VLCPictureInPictureWindowControlling? private var sceneObserver: NSObjectProtocol? + /// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld). + private weak var attachedView: UIView? + /// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start. + private var usePipDrawable = false + private var stopping = false override init() { // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). @@ -59,29 +64,45 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if let sceneObserver { NotificationCenter.default.removeObserver(sceneObserver) } + mediaPlayer.drawable = nil + mediaPlayer.stop() + if clearKeyProxyActive { + ClearKeyDashProxy.shared.stop() + } } func attach(to view: UIView) { + if attachedView === view { return } + attachedView = view plainVideoView = view view.backgroundColor = .black applyDrawable() } - /// Live/ClearKey → plain UIView. VOD → PiP-drawable (AVSampleBuffer-pad). + /// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable. private func applyDrawable() { guard let view = plainVideoView else { return } - if liveMode || clearKeyProxyActive { - pipPossible = false - drawableHost.detachVideo() - mediaPlayer.drawable = view - } else { + let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive + if wantPip { drawableHost.attach(to: view) - mediaPlayer.drawable = drawableHost + if mediaPlayer.drawable as AnyObject? !== drawableHost { + mediaPlayer.drawable = drawableHost + } + } else { + drawableHost.detachVideo() + if mediaPlayer.drawable as AnyObject? !== view { + mediaPlayer.drawable = view + } + if liveMode || clearKeyProxyActive { + pipPossible = false + } } } func startPictureInPicture() { - pipWindow?.startPictureInPicture() + preparePipDrawableThen { + self.pipWindow?.startPictureInPicture() + } } func stopPictureInPicture() { @@ -93,6 +114,33 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg else { startPictureInPicture() } } + private func preparePipDrawableThen(_ work: @escaping () -> Void) { + if usePipDrawable, pipWindow != nil { + work() + return + } + usePipDrawable = true + applyDrawable() + // pictureInPictureReady komt async; kort wachten op pipWindow. + var attempts = 0 + func tryStart() { + attempts += 1 + if self.pipWindow != nil || attempts >= 12 { + work() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart) + } + + private func restorePlainDrawableAfterPip() { + guard !liveMode, !clearKeyProxyActive, !stopping else { return } + usePipDrawable = false + applyDrawable() + applySubtitleStyle() + } + func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { hasError = false errorMessage = nil @@ -101,7 +149,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg audioTracks = [] liveMode = false clearKeyProxyActive = false + usePipDrawable = false applyDrawable() + // PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken). + pipPossible = AVPictureInPictureController.isPictureInPictureSupported() guard let media = VLCMedia(url: url) else { hasError = true errorMessage = "Kon media niet openen." @@ -150,6 +201,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg everPlayed = false liveStartedAt = Date() liveMode = true + usePipDrawable = false var playURL = url var playFormat = format @@ -159,8 +211,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg do { stateText = "ClearKey laden…" // Async: blokkeer de main thread niet (watchdog-crash). + // HLS (ESPN) én DASH (Prime) via dezelfde proxy. playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) - playFormat = "dash" + playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash" clearKeyProxyActive = true } catch { hasError = true @@ -204,7 +257,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg return "Stream-server gaf een fout (\(msg)). Probeer opnieuw." } if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") { - return "DASH-manifest kon niet worden geladen. Controleer de zender-URL." + return "Stream-manifest kon niet worden geladen. Controleer de zender-URL." } if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") { return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw." @@ -238,6 +291,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg media.addOption(":network-caching=1800") media.addOption(":http-reconnect") media.addOption(":live-caching=1500") + media.addOption(":avcodec-hw=any") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") // VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module). media.addOption(":adaptive-logic=highest") @@ -294,10 +348,21 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } func addExternalSubtitle(url: URL) { + if pipActive { + stopPictureInPicture() + } + if usePipDrawable { + usePipDrawable = false + applyDrawable() + } _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.refreshTracks() - self?.applySubtitleStyle() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self else { return } + self.refreshTracks() + self.applySubtitleStyle() + if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) { + self.selectSubtitle(id: last.id) + } } } @@ -468,11 +533,28 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } func stop() { + guard !stopping else { return } + stopping = true tick?.cancel() tick = nil + + let proxyWasActive = clearKeyProxyActive + clearKeyProxyActive = false + + // Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft. + mediaPlayer.drawable = nil + drawableHost.detachVideo() + attachedView = nil + plainVideoView = nil + if pipActive { - stopPictureInPicture() + pipWindow?.stopPictureInPicture() } + pipWindow = nil + pipPossible = false + pipActive = false + usePipDrawable = false + mediaPlayer.stop() mediaPlayer.media = nil livePlayURL = nil @@ -481,13 +563,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg liveRetryCount = 0 everPlayed = false liveMode = false - pipWindow = nil - pipPossible = false - pipActive = false - if clearKeyProxyActive { - ClearKeyDashProxy.shared.stop() - clearKeyProxyActive = false + + if proxyWasActive { + // Laat in-flight HTTP van VLC even afronden vóór listener-cancel. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + ClearKeyDashProxy.shared.stop() + } } + stopping = false } // MARK: - VLCMediaPlayerDelegate (VLCKit 4) @@ -588,7 +671,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg pipPossible = true controller.stateChangeEventHandler = { [weak self] isStarted in DispatchQueue.main.async { - self?.pipActive = isStarted + guard let self else { return } + self.pipActive = isStarted + if !isStarted { + self.restorePlainDrawableAfterPip() + } } } // Automatisch PiP bij app → achtergrond (Netflix-gedrag). @@ -624,7 +711,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg object: nil, queue: .main ) { [weak self] _ in - guard let self, self.isPlaying, !self.pipActive, self.pipPossible else { return } + guard let self, self.isPlaying, !self.pipActive else { return } + guard !self.liveMode, !self.clearKeyProxyActive else { return } self.startPictureInPicture() } } @@ -729,6 +817,7 @@ struct VLCVideoView: UIViewRepresentable { } func updateUIView(_ uiView: UIView, context: Context) { + // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout). controller.attach(to: uiView) } } diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift index 2a0f995..93639bf 100644 --- a/apps/ios/VMC/UI/EventsScreen.swift +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -1,5 +1,18 @@ import SwiftUI +// MARK: - Stadium palette (alleen Events) + +private enum EventsLook { + static let neon = Color(red: 0.0, green: 0.91, blue: 0.627) // #00E8A0 + static let neonSoft = Color(red: 0.0, green: 0.91, blue: 0.627).opacity(0.18) + static let live = Color(red: 1.0, green: 0.176, blue: 0.333) // #FF2D55 + static let liveSoft = Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.22) + static let soon = Color(red: 1.0, green: 0.69, blue: 0.125) // #FFB020 + static let deep = Color(red: 0.027, green: 0.078, blue: 0.063) // #071410 + static let panel = Color(red: 0.071, green: 0.102, blue: 0.094) // #121A18 + static let panelHot = Color(red: 0.102, green: 0.157, blue: 0.141) // #1A2824 +} + // MARK: - List struct EventsScreen: View { @@ -7,26 +20,51 @@ struct EventsScreen: View { @State private var events: [ScheduleEvent] = [] @State private var loading = true - @State private var refreshing = false @State private var error: String? + @State private var sportFilter: String? @State private var providerFilter: String? @State private var now = Date() @State private var path = NavigationPath() + @State private var pulse = false private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() private let autoRefresh = Timer.publish(every: 75, on: .main, in: .common).autoconnect() - private var filtered: [ScheduleEvent] { + private var providerScoped: [ScheduleEvent] { guard let providerFilter, !providerFilter.isEmpty else { return events } return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame } } + private var filtered: [ScheduleEvent] { + guard let sportFilter, !sportFilter.isEmpty else { return providerScoped } + return providerScoped.filter { $0.resolvedSportBucket == sportFilter } + } + private var liveEvents: [ScheduleEvent] { filtered.filter { EventHelpers.isLive($0, now: now) } .sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) } } - private var upcomingSections: [(String, [ScheduleEvent])] { + private var sportChips: [(id: String, label: String)] { + let present = Set(providerScoped.map(\.resolvedSportBucket)) + return EventSport.order + .filter { present.contains($0) } + .map { ($0, EventSport.label(for: $0)) } + } + + private var sportShelves: [(id: String, label: String, items: [ScheduleEvent])] { + let upcoming = filtered.filter { !EventHelpers.isLive($0, now: now) } + var byBucket: [String: [ScheduleEvent]] = [:] + for e in upcoming { + byBucket[e.resolvedSportBucket, default: []].append(e) + } + return EventSport.order.compactMap { bucket in + guard let items = byBucket[bucket], !items.isEmpty else { return nil } + return (bucket, EventSport.label(for: bucket), items) + } + } + + private var daySections: [(String, [ScheduleEvent])] { EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) } return upcoming.isEmpty ? nil : (label, upcoming) @@ -39,140 +77,450 @@ struct EventsScreen: View { var body: some View { NavigationStack(path: $path) { - content - .background(McColors.bg.ignoresSafeArea()) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - BrandHeader(subtitle: "EVENTS") - } - } - .toolbarBackground(McColors.bg, for: .navigationBar) - .toolbarColorScheme(.dark, for: .navigationBar) - .navigationDestination(for: String.self) { eventId in - EventDetailScreen(eventId: eventId) - .environmentObject(app) + ZStack { + eventsBackground + content + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: "EVENTS") } + } + .toolbarBackground(EventsLook.deep.opacity(0.92), for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .navigationDestination(for: String.self) { eventId in + EventDetailScreen(eventId: eventId) + .environmentObject(app) + } } .task { await refresh() } + .onAppear { + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { + pulse = true + } + } .onReceive(clock) { now = $0 } .onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } } } + private var eventsBackground: some View { + ZStack { + EventsLook.deep.ignoresSafeArea() + RadialGradient( + colors: [ + EventsLook.neon.opacity(0.14), + EventsLook.live.opacity(0.06), + .clear, + ], + center: .topTrailing, + startRadius: 20, + endRadius: 420 + ) + .ignoresSafeArea() + RadialGradient( + colors: [EventsLook.neon.opacity(0.07), .clear], + center: .bottomLeading, + startRadius: 10, + endRadius: 360 + ) + .ignoresSafeArea() + } + } + @ViewBuilder private var content: some View { - VStack(spacing: 0) { - if providers.count > 1 { - providerChips + if loading && events.isEmpty { + ProgressView("Sportagenda laden…") + .tint(EventsLook.neon) + .foregroundStyle(McColors.textDim) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, events.isEmpty { + VStack(spacing: 14) { + Text(error) + .foregroundStyle(McColors.danger) + .multilineTextAlignment(.center) + Button("Opnieuw") { Task { await refresh() } } + .buttonStyle(McPrimaryButtonStyle()) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + headerBlock + filterChips + if filtered.isEmpty { + emptyBlock + } else { + if !liveEvents.isEmpty { + liveShelf + } + if sportFilter == nil { + ForEach(sportShelves, id: \.id) { shelf in + eventShelf( + title: shelf.label, + accent: EventsLook.neon, + items: shelf.items, + featured: false + ) + } + } else { + ForEach(daySections, id: \.0) { label, items in + eventShelf( + title: label, + accent: EventsLook.neon.opacity(0.85), + items: items, + featured: false + ) + } + } + } + } + .padding(.bottom, 28) + } + .refreshable { await refresh(isPull: true) } + } + } + + private var headerBlock: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("Sportagenda") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(subtitleLine) + .font(McFonts.caption) + .foregroundStyle(Color.white.opacity(0.55)) + } + Spacer(minLength: 8) + if !liveEvents.isEmpty { + HStack(spacing: 6) { + Circle() + .fill(EventsLook.live) + .frame(width: 7, height: 7) + .opacity(pulse ? 1 : 0.35) + .shadow(color: EventsLook.live.opacity(0.8), radius: pulse ? 5 : 1) + Text("\(liveEvents.count) LIVE") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(EventsLook.liveSoft, in: Capsule()) + .overlay(Capsule().stroke(EventsLook.live.opacity(0.45), lineWidth: 1)) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + + private var subtitleLine: String { + if let sportFilter { + return EventSport.label(for: sportFilter) + } + if let providerFilter { + return EventHelpers.prettyProvider(providerFilter) + } + return "Live · vandaag · later" + } + + private var filterChips: some View { + VStack(alignment: .leading, spacing: 10) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + EventsChip( + label: "Alles", + active: sportFilter == nil, + style: .sport + ) { sportFilter = nil } + ForEach(sportChips, id: \.id) { chip in + EventsChip( + label: chip.label, + active: sportFilter == chip.id, + style: .sport + ) { sportFilter = chip.id } + } + } + .padding(.horizontal, 16) } - if loading && events.isEmpty { - ProgressView("Events laden…") - .tint(.white) - .foregroundStyle(McColors.textDim) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error, events.isEmpty { - VStack(spacing: 12) { - Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) - Button("Opnieuw") { Task { await refresh() } } - .buttonStyle(McPrimaryButtonStyle()) - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if filtered.isEmpty { - ContentUnavailableView( - providerFilter != nil - ? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))" - : "Geen events in de komende dagen", - systemImage: "calendar" - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - if !liveEvents.isEmpty { - Section { - ForEach(liveEvents) { ev in - Button { path.append(ev.id) } label: { - EventRowView(event: ev, now: now) - } - .listRowBackground(McColors.bgElevated) - .listRowSeparatorTint(Color.white.opacity(0.06)) - } - } header: { - Text("Nu live") - .font(McFonts.headline) - .foregroundStyle(McColors.text) - .textCase(nil) - } - } - - ForEach(upcomingSections, id: \.0) { label, dayEvents in - Section { - ForEach(dayEvents) { ev in - Button { path.append(ev.id) } label: { - EventRowView(event: ev, now: now) - } - .listRowBackground(McColors.bgElevated) - .listRowSeparatorTint(Color.white.opacity(0.06)) - } - } header: { - Text(label) - .font(McFonts.headline) - .foregroundStyle(McColors.text) - .textCase(nil) + if providers.count > 1 { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + EventsChip( + label: "Alle bronnen", + active: providerFilter == nil, + style: .provider + ) { providerFilter = nil } + ForEach(providers, id: \.self) { p in + EventsChip( + label: EventHelpers.prettyProvider(p), + active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame, + style: .provider + ) { providerFilter = p } } } + .padding(.horizontal, 16) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .refreshable { await refresh(isPull: true) } } } } - private var providerChips: some View { - ScrollView(.horizontal, showsIndicators: false) { + private var liveShelf: some View { + eventShelf( + title: "Nu live", + accent: EventsLook.live, + items: liveEvents, + featured: true + ) + } + + private func eventShelf( + title: String, + accent: Color, + items: [ScheduleEvent], + featured: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { - chip(label: "Alles", active: providerFilter == nil) { - providerFilter = nil - } - ForEach(providers, id: \.self) { p in - chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) { - providerFilter = p - } + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(accent) + .frame(width: 3, height: 16) + Text(title) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + if featured { + Text("NU") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(EventsLook.live) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(EventsLook.liveSoft, in: Capsule()) } + Spacer() + Text("\(items.count)") + .font(McFonts.caption) + .foregroundStyle(Color.white.opacity(0.35)) } .padding(.horizontal, 16) - .padding(.vertical, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(items) { ev in + Button { path.append(ev.id) } label: { + EventShelfCard(event: ev, now: now, featured: featured, pulse: pulse) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } } } - private func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(label) - .font(McFonts.caption) - .foregroundStyle(active ? .white : McColors.textDim) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(active ? McColors.accent : McColors.surface, in: Capsule()) + private var emptyBlock: some View { + VStack(spacing: 10) { + Image(systemName: "sportscourt") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(EventsLook.neon.opacity(0.55)) + Text( + sportFilter != nil + ? "Geen events in \(EventSport.label(for: sportFilter!))" + : (providerFilter != nil + ? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))" + : "Geen events in de komende dagen") + ) + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .multilineTextAlignment(.center) } - .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + .padding(.horizontal, 24) } private func refresh(isPull: Bool = false, silent: Bool = false) async { - if isPull { refreshing = true } if !silent && events.isEmpty { loading = true } do { let payload = try await app.api.listScheduleEvents() events = EventHelpers.enrichImages(payload.events) error = nil + // Drop stale filters if nothing left. + if let sportFilter, !sportChips.contains(where: { $0.id == sportFilter }) { + self.sportFilter = nil + } + if let providerFilter, + !providers.contains(where: { $0.caseInsensitiveCompare(providerFilter) == .orderedSame }) { + self.providerFilter = nil + } } catch { if events.isEmpty { self.error = error.localizedDescription } } loading = false - refreshing = false + } +} + +// MARK: - Chips + +private struct EventsChip: View { + enum Style { case sport, provider } + + let label: String + let active: Bool + let style: Style + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: style == .sport ? 13 : 12, weight: .semibold, design: .rounded)) + .foregroundStyle(foreground) + .padding(.horizontal, style == .sport ? 14 : 12) + .padding(.vertical, style == .sport ? 9 : 7) + .background(background, in: Capsule()) + .overlay( + Capsule() + .strokeBorder(border, lineWidth: active ? 1.2 : 0.5) + ) + } + .buttonStyle(.plain) + } + + private var foreground: Color { + if active { + return style == .sport ? Color(red: 0.02, green: 0.12, blue: 0.09) : .white + } + return Color.white.opacity(0.72) + } + + private var background: Color { + if active { + return style == .sport ? EventsLook.neon : EventsLook.panelHot + } + return EventsLook.panel.opacity(0.9) + } + + private var border: Color { + if active { + return style == .sport ? EventsLook.neon : EventsLook.neon.opacity(0.55) + } + return Color.white.opacity(0.08) + } +} + +// MARK: - Shelf card + +private struct EventShelfCard: View { + let event: ScheduleEvent + let now: Date + var featured: Bool = false + var pulse: Bool = false + + private var cardWidth: CGFloat { featured ? 268 : 196 } + private var cardHeight: CGFloat { featured ? 151 : 110 } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(EventsLook.panel) + + if let art = EventHelpers.artUrl(event) { + RemoteImage(url: art, contentMode: .fill) + .frame(width: cardWidth, height: cardHeight) + .clipped() + } else { + Text(EventHelpers.prettyProvider(event.provider)) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + LinearGradient( + colors: [ + .clear, + .black.opacity(0.15), + .black.opacity(0.82), + ], + startPoint: .top, + endPoint: .bottom + ) + + if featured { + LinearGradient( + colors: [EventsLook.live.opacity(0.55), .clear], + startPoint: .top, + endPoint: .center + ) + .frame(height: 36) + .frame(maxHeight: .infinity, alignment: .top) + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + EventStatusBadge(event: event, now: now, compact: true, pulse: pulse) + Spacer(minLength: 4) + if featured && event.isPlayable { + Text("KIJK NU") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(EventsLook.deep) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(EventsLook.neon, in: Capsule()) + } + } + Text(EventHelpers.shortTime(event, now: now)) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + .lineLimit(1) + } + .padding(10) + } + .frame(width: cardWidth, height: cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + featured ? EventsLook.live.opacity(0.35) : Color.white.opacity(0.06), + lineWidth: 1 + ) + ) + .shadow( + color: featured ? EventsLook.live.opacity(0.22) : .black.opacity(0.35), + radius: featured ? 12 : 6, + y: 4 + ) + + Text(event.name) + .font(.system(size: featured ? 15 : 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .frame(width: cardWidth, alignment: .leading) + + HStack(spacing: 6) { + Text(event.resolvedSportLabel) + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(EventsLook.neon) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(EventsLook.neonSoft, in: Capsule()) + Text( + [EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)] + .compactMap { $0 } + .filter { !$0.isEmpty } + .uniqued() + .joined(separator: " · ") + ) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.45)) + .lineLimit(1) + } + .frame(width: cardWidth, alignment: .leading) + } + .frame(width: cardWidth, alignment: .leading) } } @@ -190,6 +538,7 @@ struct EventDetailScreen: View { @State private var now = Date() @State private var playInfo: IptvPlayInfo? @State private var playError: String? + @State private var pulse = false private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect() @@ -203,13 +552,20 @@ struct EventDetailScreen: View { Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle()) } } else { - ProgressView("Laden…").tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + ProgressView("Laden…") + .tint(EventsLook.neon) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .background(McColors.bg.ignoresSafeArea()) + .background(EventsLook.deep.ignoresSafeArea()) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .task { await load() } + .onAppear { + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { + pulse = true + } + } .onReceive(clock) { date in now = date Task { await refreshEvent() } @@ -248,62 +604,72 @@ struct EventDetailScreen: View { ScrollView { VStack(alignment: .leading, spacing: 0) { hero(ev) - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { Text(ev.name) - .font(McFonts.title) + .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundStyle(.white) .lineLimit(3) + HStack(spacing: 8) { + Text(ev.resolvedSportLabel) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(EventsLook.deep) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(EventsLook.neon, in: Capsule()) + EventStatusBadge(event: ev, now: now, pulse: pulse) + } + let meta = EventHelpers.detailMeta(ev) if !meta.isEmpty { Text(meta) .font(McFonts.body) - .foregroundStyle(Color(white: 0.8)) + .foregroundStyle(Color.white.opacity(0.72)) .lineLimit(2) } let window = EventHelpers.detailTimeWindow(ev, now: now) if !window.isEmpty { Text(window) - .font(McFonts.headline) - .foregroundStyle(McColors.text) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) } if let subtitle = ev.subtitle, !subtitle.isEmpty, subtitle.caseInsensitiveCompare(ev.name) != .orderedSame { Text(subtitle) .font(McFonts.body) - .foregroundStyle(McColors.textDim) + .foregroundStyle(Color.white.opacity(0.5)) .lineLimit(3) } if let description = ev.description, !description.isEmpty { Text(description) .font(McFonts.body) - .foregroundStyle(Color(white: 0.91)) + .foregroundStyle(Color.white.opacity(0.88)) .lineSpacing(4) } if let langs = ev.audioLanguages, !langs.isEmpty { Text("Audio") .font(McFonts.caption) - .foregroundStyle(Color(white: 0.56)) + .foregroundStyle(Color.white.opacity(0.4)) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(langs.prefix(8), id: \.self) { lang in Text(lang.uppercased()) .font(McFonts.caption) - .foregroundStyle(Color(white: 0.86)) + .foregroundStyle(Color.white.opacity(0.86)) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4)) + .background(EventsLook.panelHot, in: RoundedRectangle(cornerRadius: 6)) } } } } } .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.vertical, 16) } } @@ -314,29 +680,38 @@ struct EventDetailScreen: View { private func hero(_ ev: ScheduleEvent) -> some View { ZStack(alignment: .bottomLeading) { RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill) - .frame(height: 220) + .frame(height: 280) .frame(maxWidth: .infinity) .clipped() .overlay { LinearGradient( stops: [ - .init(color: .black.opacity(0.2), location: 0), - .init(color: .clear, location: 0.45), - .init(color: McColors.bg.opacity(0.95), location: 1), + .init(color: .black.opacity(0.25), location: 0), + .init(color: .clear, location: 0.4), + .init(color: EventsLook.deep.opacity(0.98), location: 1), ], startPoint: .top, endPoint: .bottom ) } + .overlay(alignment: .top) { + if EventHelpers.isLive(ev, now: now) { + LinearGradient( + colors: [EventsLook.live.opacity(0.5), .clear], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 48) + } + } HStack { Button { dismiss() } label: { - Text("Terug") - .font(McFonts.caption) + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4)) + .padding(10) + .background(.black.opacity(0.45), in: Circle()) } Spacer() if let logo = ev.logoUrl, !logo.isEmpty { @@ -344,13 +719,10 @@ struct EventDetailScreen: View { .frame(width: 96, height: 36) } } - .padding(12) + .padding(14) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - - EventStatusBadge(event: ev, now: now) - .padding(16) } - .frame(height: 220) + .frame(height: 280) } private func stickyBar(_ ev: ScheduleEvent) -> some View { @@ -359,10 +731,10 @@ struct EventDetailScreen: View { Button { Task { await play(ev) } } label: { - Text("Kijk live") + Label("Kijk live", systemImage: "play.fill") .frame(maxWidth: .infinity) } - .buttonStyle(McPrimaryButtonStyle()) + .buttonStyle(EventsLiveButtonStyle()) Button("Terug") { dismiss() } .buttonStyle(McSecondaryButtonStyle()) @@ -381,7 +753,7 @@ struct EventDetailScreen: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(McColors.bgElevated.ignoresSafeArea(edges: .bottom)) + .background(EventsLook.panel.opacity(0.96).ignoresSafeArea(edges: .bottom)) } private func load() async { @@ -410,77 +782,56 @@ struct EventDetailScreen: View { } } +private struct EventsLiveButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(McFonts.headline) + .foregroundStyle(EventsLook.deep) + .padding(.horizontal, 22) + .padding(.vertical, 13) + .background( + LinearGradient( + colors: [EventsLook.neon, EventsLook.neon.opacity(0.82)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(configuration.isPressed ? 0.8 : 1), + in: Capsule() + ) + .shadow(color: EventsLook.neon.opacity(0.35), radius: configuration.isPressed ? 2 : 10, y: 4) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + private struct EventPlayItem: Identifiable { var id: String { info.streamId.isEmpty ? info.name : info.streamId } let info: IptvPlayInfo } -// MARK: - Row - -private struct EventRowView: View { - let event: ScheduleEvent - let now: Date - - var body: some View { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(white: 0.1)) - .frame(width: 112, height: 63) - if let art = EventHelpers.artUrl(event) { - RemoteImage(url: art, contentMode: .fill) - .frame(width: 112, height: 63) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } else { - Text(EventHelpers.prettyProvider(event.provider)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(McColors.textDim) - } - } - .frame(width: 112, height: 63) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - EventStatusBadge(event: event, now: now, compact: true) - Text(EventHelpers.shortTime(event, now: now)) - .font(McFonts.caption) - .foregroundStyle(McColors.textDim) - .lineLimit(1) - } - Text(event.name) - .font(McFonts.headline) - .foregroundStyle(McColors.text) - .lineLimit(2) - Text( - [EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)] - .compactMap { $0 } - .filter { !$0.isEmpty } - .uniqued() - .joined(separator: " · ") - ) - .font(McFonts.caption) - .foregroundStyle(McColors.textDim) - .lineLimit(1) - } - Spacer(minLength: 0) - } - .padding(.vertical, 4) - } -} +// MARK: - Badge private struct EventStatusBadge: View { let event: ScheduleEvent let now: Date var compact: Bool = false + var pulse: Bool = false var body: some View { let (label, color) = EventHelpers.statusVisual(event, now: now) - Text(label) - .font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .padding(.horizontal, compact ? 8 : 10) - .padding(.vertical, compact ? 3 : 5) - .background(color, in: RoundedRectangle(cornerRadius: 4)) + HStack(spacing: 5) { + if EventHelpers.isLive(event, now: now) { + Circle() + .fill(.white) + .frame(width: compact ? 5 : 6, height: compact ? 5 : 6) + .opacity(pulse ? 1 : 0.4) + } + Text(label) + .font(.system(size: compact ? 10 : 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + .padding(.horizontal, compact ? 7 : 10) + .padding(.vertical, compact ? 3 : 5) + .background(color, in: Capsule()) } } @@ -499,22 +850,20 @@ enum EventHelpers { static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? { guard let start = parse(event.startLocal, event.start) else { return nil } - let m = Int(start.timeIntervalSince(now) / 60) - return m + return Int(start.timeIntervalSince(now) / 60) } static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? { guard let stop = parse(event.stopLocal, event.stop) else { return nil } - let m = Int(stop.timeIntervalSince(now) / 60) - return m + return Int(stop.timeIntervalSince(now) / 60) } static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) { - if isLive(event, now: now) { return ("LIVE", McColors.accent) } + if isLive(event, now: now) { return ("LIVE", EventsLook.live) } if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 { - return ("Straks · over \(m) min", Color(red: 0.9, green: 0.49, blue: 0.13)) + return ("Straks · \(m)m", EventsLook.soon) } - return ("Gepland", Color(white: 0.23)) + return ("Gepland", Color.white.opacity(0.18)) } static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String { diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index b76d72b..409a815 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -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") } diff --git a/apps/master-api/src/viewer/schedule-events-sport.test.ts b/apps/master-api/src/viewer/schedule-events-sport.test.ts index f061dfe..7978774 100644 --- a/apps/master-api/src/viewer/schedule-events-sport.test.ts +++ b/apps/master-api/src/viewer/schedule-events-sport.test.ts @@ -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"); diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index ef7d78a..8f31013 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -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"; +}