diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index c98f911..7610d86 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -345,7 +345,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 61; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.47; + MARKETING_VERSION = 0.5.48; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 60; + CURRENT_PROJECT_VERSION = 61; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.47; + MARKETING_VERSION = 0.5.48; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index 64adcb8..ed03312 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -61,6 +61,8 @@ final class ClearKeyDashProxy { private var lastMediaPlaylistByPath: [String: String] = [:] /// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien /// (anders A/V MEDIA-SEQUENCE-desync → stall na ~8–10s). + /// Som van EXTINF bij VOD/ENDLIST — VLC live-pad meldt anders duur 0 / niet-zoekbaar. + private var lastVodDurationMs: Int64 = 0 private var cachedMpdXML: String? private var cachedMpdAt: Date? private let mpdCacheTTL: TimeInterval = 0.8 @@ -134,6 +136,11 @@ final class ClearKeyDashProxy { struct StartResult { let url: URL let session: Int + let vodDurationMs: Int64 + } + + func vodDurationMs() -> Int64 { + withState { lastVodDurationMs } } /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug. @@ -165,6 +172,7 @@ final class ClearKeyDashProxy { self.segmentSeqByKey = [:] self.nextStableSeq = 1 self.lastMediaPlaylistByPath = [:] + self.lastVodDurationMs = 0 self.cachedMpdXML = nil self.cachedMpdAt = nil self.cryptoLock.lock() @@ -239,6 +247,10 @@ final class ClearKeyDashProxy { "warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)" ) + if hls || preferHlsForAVPlayer { + await peekVodDuration(fromMaster: body) + } + // Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt. if preferHlsForAVPlayer { await prefetchInitsForAVPlayer() @@ -247,7 +259,37 @@ final class ClearKeyDashProxy { guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else { throw ProxyError.bindFailed } - return StartResult(url: local, session: gen) + return StartResult(url: local, session: gen, vodDurationMs: withState { lastVodDurationMs }) + } + + /// Haal één media-playlist op zodat we ENDLIST/duur kennen vóór VLC start. + private func peekVodDuration(fromMaster data: Data) async { + let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + var videoPath: String? + var audioPath: String? + for raw in text.components(separatedBy: .newlines) { + let line = raw.trimmingCharacters(in: .whitespaces) + let uri: String? + if line.hasPrefix("#"), let q = Self.hlsMediaAttr(line, "URI") { + uri = q + } else if line.hasPrefix("http://127.0.0.1:") { + uri = line + } else { + uri = nil + } + guard let uri, let url = URL(string: uri) else { continue } + let path = url.path + (url.query.map { "?\($0)" } ?? "") + guard path.hasPrefix("/a/") else { continue } + if path.contains("ck=v") { videoPath = path } + else if path.contains("ck=a") { audioPath = path } + else if videoPath == nil { videoPath = path } + } + guard let path = videoPath ?? audioPath else { return } + _ = try? await payload(for: path) + let ms = withState { lastVodDurationMs } + if ms > 0 { + proxyLog.info("vod duration \(ms, privacy: .public)ms from peek") + } } private func prefetchInitsForAVPlayer() async { @@ -349,6 +391,7 @@ final class ClearKeyDashProxy { segmentSeqByKey = [:] nextStableSeq = 1 lastMediaPlaylistByPath = [:] + lastVodDurationMs = 0 cachedMpdXML = nil cachedMpdAt = nil cryptoLock.lock() @@ -746,7 +789,13 @@ final class ClearKeyDashProxy { } let window = Array(segments.suffix(from: startIdx)) let seq = seqBase + startIdx - proxyLog.info("hls rewrite segs=\(segments.count, privacy: .public) keep=\(window.count, privacy: .public) endlist=\(hasEndList, privacy: .public) type=\(playlistType ?? "-", privacy: .public) f1=\(isF1, privacy: .public) startIdx=\(startIdx, privacy: .public)") + if isComplete || (isF1 && startIdx == 0 && window.count > 40) { + let ms = Self.playlistDurationMs(window.map(\.inf), fallbackSeconds: targetDuration) + withState { + if ms > self.lastVodDurationMs { self.lastVodDurationMs = ms } + } + } + proxyLog.info("hls rewrite segs=\(segments.count, privacy: .public) keep=\(window.count, privacy: .public) endlist=\(hasEndList, privacy: .public) type=\(playlistType ?? "-", privacy: .public) f1=\(isF1, privacy: .public) startIdx=\(startIdx, privacy: .public) vodMs=\(self.lastVodDurationMs, privacy: .public)") out.append("#EXTM3U") out.append("#EXT-X-VERSION:7") @@ -778,6 +827,21 @@ final class ClearKeyDashProxy { return out.joined(separator: "\n") } + private static func playlistDurationMs(_ infLines: [String], fallbackSeconds: Int) -> Int64 { + var total: Double = 0 + for inf in infLines { + let raw = inf.trimmingCharacters(in: .whitespaces) + let rest = raw.hasPrefix("#EXTINF:") ? String(raw.dropFirst(8)) : raw + let token = rest.split(separator: ",", maxSplits: 1).first.map(String.init) ?? "" + if let seconds = Double(token.trimmingCharacters(in: .whitespaces)) { + total += seconds + } else { + total += Double(max(fallbackSeconds, 1)) + } + } + return Int64(total * 1000) + } + /// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`). private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane { let s = playlistURL.path.lowercased() diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index a4aae8f..9a6eb9b 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -55,6 +55,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var stopping = false private var stopGeneration = 0 private var didApplyPreferredAudio = false + private var knownVodDurationMs: Int64 = 0 override init() { // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). @@ -196,6 +197,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg audioTracks = [] canSeek = false didApplyPreferredAudio = false + knownVodDurationMs = 0 liveMode = false clearKeyProxyActive = false plainVideoView?.layoutIfNeeded() @@ -262,6 +264,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg liveStartedAt = Date() canSeek = false didApplyPreferredAudio = false + knownVodDurationMs = 0 liveMode = true usePipDrawable = false pipPossible = false @@ -281,6 +284,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg ) playURL = started.url proxySession = started.session + if started.vodDurationMs > 3000 { + knownVodDurationMs = started.vodDurationMs + let clamped = Int32(min(started.vodDurationMs, Int64(Int32.max))) + lengthMs = clamped + lengthText = Self.format(ms: clamped) + canSeek = true + } // Lokale clear-HLS of remote HLS. if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) { playFormat = "hls" @@ -345,7 +355,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg return } media.delegate = self - Self.applyLiveOptions(to: media, url: url, format: format, viaLocalProxy: clearKeyProxyActive) + Self.applyLiveOptions( + to: media, + url: url, + format: format, + viaLocalProxy: clearKeyProxyActive, + asVod: knownVodDurationMs > 3000 + ) // Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash). if mediaPlayer.drawable == nil { applyDrawable(force: true) @@ -399,10 +415,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg to media: VLCMedia, url: URL, format: String?, - viaLocalProxy: Bool + viaLocalProxy: Bool, + asVod: Bool = false ) { // Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen. - if viaLocalProxy { + // VOD (F1-replay): géén live-caching, anders blijft VLC isSeekable=false / duur=0. + if asVod { + media.addOption(":network-caching=1500") + } else if viaLocalProxy { media.addOption(":network-caching=1200") media.addOption(":live-caching=1000") } else { @@ -740,6 +760,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg self.liveMode = false self.canSeek = false self.didApplyPreferredAudio = false + self.knownVodDurationMs = 0 self.isPlaying = false self.hasError = false self.errorMessage = nil @@ -854,9 +875,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if l > 0 { lengthMs = l } position = Float(mediaPlayer.position) timeText = Self.format(ms: t) + if l <= 0, knownVodDurationMs > 3000 { + lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max))) + } lengthText = Self.format(ms: lengthMs) isPlaying = mediaPlayer.isPlaying - let seekable = mediaPlayer.isSeekable && lengthMs > 3000 + let seekable = (knownVodDurationMs > 3000 || mediaPlayer.isSeekable) && lengthMs > 3000 if seekable != canSeek { canSeek = seekable } } diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 2976cd3..0504c8a 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.47") + LabeledContent("Versie", value: "0.5.48") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") }