From e6607654a25b7441a0a08c4154e1d443e86f66a9 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 01:16:09 +0200 Subject: [PATCH] iOS 0.5.49: F1 VOD seek by trimming HLS playlist; scrub slider only seeks on release. --- apps/ios/VMC.xcodeproj/project.pbxproj | 8 +-- apps/ios/VMC/Playback/ClearKeyDashProxy.swift | 52 +++++++++++++++---- apps/ios/VMC/Playback/PlayerScreen.swift | 33 +++++++++--- .../VMC/Playback/VLCPlayerController.swift | 47 ++++++++++++++--- apps/ios/VMC/UI/SettingsScreen.swift | 2 +- 5 files changed, 113 insertions(+), 29 deletions(-) diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 7610d86..d98d80a 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 = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.48; + MARKETING_VERSION = 0.5.49; 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 = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.48; + MARKETING_VERSION = 0.5.49; 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 ed03312..e8b7e96 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -63,6 +63,8 @@ final class ClearKeyDashProxy { /// (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 + /// VLC kan HLS-cbcs via de proxy niet zoeken; we knippen de playlist vanaf dit punt. + private var playbackStartOffsetSec: Double = 0 private var cachedMpdXML: String? private var cachedMpdAt: Date? private let mpdCacheTTL: TimeInterval = 0.8 @@ -143,6 +145,13 @@ final class ClearKeyDashProxy { withState { lastVodDurationMs } } + func setPlaybackStartOffset(ms: Int64) { + withState { + playbackStartOffsetSec = Double(max(0, ms)) / 1000.0 + lastMediaPlaylistByPath = [:] + } + } + /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug. /// - Parameter preferHlsForAVPlayer: forceer altijd een lokale `.m3u8` (DASH wordt omgezet). func start( @@ -173,6 +182,7 @@ final class ClearKeyDashProxy { self.nextStableSeq = 1 self.lastMediaPlaylistByPath = [:] self.lastVodDurationMs = 0 + self.playbackStartOffsetSec = 0 self.cachedMpdXML = nil self.cachedMpdAt = nil self.cryptoLock.lock() @@ -392,6 +402,7 @@ final class ClearKeyDashProxy { nextStableSeq = 1 lastMediaPlaylistByPath = [:] lastVodDurationMs = 0 + playbackStartOffsetSec = 0 cachedMpdXML = nil cachedMpdAt = nil cryptoLock.lock() @@ -788,14 +799,20 @@ final class ClearKeyDashProxy { startIdx = 0 } let window = Array(segments.suffix(from: startIdx)) - let seq = seqBase + startIdx 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)") + let seekSec = withState { self.playbackStartOffsetSec } + var playFrom = 0 + if seekSec > 0.4, isComplete || isF1 { + playFrom = Self.segmentIndex(at: seekSec, infLines: window.map(\.inf), fallbackSeconds: targetDuration) + } + let playWindow = Array(window.suffix(from: playFrom)) + let seq = seqBase + startIdx + playFrom + proxyLog.info("hls rewrite segs=\(segments.count, privacy: .public) keep=\(playWindow.count, privacy: .public) endlist=\(hasEndList, privacy: .public) type=\(playlistType ?? "-", privacy: .public) f1=\(isF1, privacy: .public) startIdx=\(startIdx, privacy: .public) seek=\(Int(seekSec), privacy: .public)s vodMs=\(self.lastVodDurationMs, privacy: .public)") out.append("#EXTM3U") out.append("#EXT-X-VERSION:7") @@ -816,7 +833,7 @@ final class ClearKeyDashProxy { out.append(h) } } - for seg in window { + for seg in playWindow { out.append(seg.inf) let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri) out.append(proxyAbsoluteURL(resolved, lane: lane)) @@ -830,18 +847,31 @@ final class ClearKeyDashProxy { 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)) - } + total += extinfSeconds(inf, fallback: fallbackSeconds) } return Int64(total * 1000) } + private static func segmentIndex(at seconds: Double, infLines: [String], fallbackSeconds: Int) -> Int { + guard !infLines.isEmpty else { return 0 } + var acc = 0.0 + for (i, inf) in infLines.enumerated() { + acc += extinfSeconds(inf, fallback: fallbackSeconds) + if acc >= seconds { return i } + } + return max(0, infLines.count - 1) + } + + private static func extinfSeconds(_ inf: String, fallback: Int) -> Double { + 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)) { + return seconds + } + return Double(max(fallback, 1)) + } + /// 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/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 9789e0f..4810bfb 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -84,6 +84,8 @@ struct PlayerScreen: View { @State private var hideTask: Task? @State private var heartbeatTask: Task? @State private var showTracks = false + @State private var sliderDraft: Double = 0 + @State private var isScrubbing = false @State private var trackTab = 0 @State private var subtitleHits: [SubtitleHit] = [] @State private var subsLoading = false @@ -195,6 +197,15 @@ struct PlayerScreen: View { } } + private static func formatClock(ratio: Double, durationMs: Int64) -> String { + let ms = Int(Double(max(durationMs, 0)) * min(max(ratio, 0), 1) / 1000) + let h = ms / 3600 + let m = (ms % 3600) / 60 + let s = ms % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } + /// Letterbox/pillarbox buiten de VLC-surface zodat FreeType onderaan het beeld zit. private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize { guard container.width > 1, container.height > 1, aspect > 0 else { return container } @@ -274,18 +285,28 @@ struct PlayerScreen: View { if showSeekChrome { Slider( value: Binding( - get: { Double(vlc.position) }, - set: { - vlc.seek(ratio: Float($0)) + get: { isScrubbing ? sliderDraft : Double(vlc.position) }, + set: { sliderDraft = $0 } + ), + in: 0...1, + onEditingChanged: { editing in + if editing { + isScrubbing = true + sliderDraft = Double(vlc.position) + scheduleHide(cancelOnly: true) + } else { + isScrubbing = false + vlc.seek(ratio: Float(sliderDraft)) scheduleHide() } - ), - in: 0...1 + } ) .tint(McColors.accent) HStack { - Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) + Text(isScrubbing ? Self.formatClock(ratio: sliderDraft, durationMs: vlc.durationMs()) : vlc.timeText) + .font(McFonts.caption) + .foregroundStyle(.white.opacity(0.85)) Spacer() Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) } diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index 9a6eb9b..ed22df2 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -56,6 +56,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var stopGeneration = 0 private var didApplyPreferredAudio = false private var knownVodDurationMs: Int64 = 0 + /// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset). + private var seekOriginMs: Int64 = 0 override init() { // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). @@ -198,6 +200,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg canSeek = false didApplyPreferredAudio = false knownVodDurationMs = 0 + seekOriginMs = 0 liveMode = false clearKeyProxyActive = false plainVideoView?.layoutIfNeeded() @@ -265,6 +268,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg canSeek = false didApplyPreferredAudio = false knownVodDurationMs = 0 + seekOriginMs = 0 liveMode = true usePipDrawable = false pipPossible = false @@ -455,18 +459,44 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } func seek(toMs: Int64) { - mediaPlayer.time = VLCTime(number: NSNumber(value: toMs)) + let target = max(0, toMs) + if knownVodDurationMs > 3000, clearKeyProxyActive, let base = livePlayURL { + let endPad = max(knownVodDurationMs - 1500, 0) + let clamped = min(target, endPad) + seekOriginMs = clamped + ClearKeyDashProxy.shared.setPlaybackStartOffset(ms: clamped) + var comps = URLComponents(url: base, resolvingAgainstBaseURL: false) + var items = (comps?.queryItems ?? []).filter { $0.name != "vmcSeek" } + items.append(URLQueryItem(name: "vmcSeek", value: String(clamped))) + comps?.queryItems = items + if let url = comps?.url { + position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0 + timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max)))) + startLiveMedia(url: url, format: livePlayFormat) + invalidatePipPlaybackState() + return + } + } + mediaPlayer.time = VLCTime(number: NSNumber(value: target)) invalidatePipPlaybackState() } func seek(ratio: Float) { + let dur = durationMs() + if dur > 3000 { + seek(toMs: Int64(Double(dur) * Double(min(max(ratio, 0), 1)))) + return + } mediaPlayer.position = Double(ratio) invalidatePipPlaybackState() } - func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) } + func currentPositionMs() -> Int64 { + Int64(mediaPlayer.time.intValue) + seekOriginMs + } func durationMs() -> Int64 { + if knownVodDurationMs > 3000 { return knownVodDurationMs } let v = mediaPlayer.media?.length.intValue ?? lengthMs return Int64(max(v, 0)) } @@ -761,6 +791,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg self.canSeek = false self.didApplyPreferredAudio = false self.knownVodDurationMs = 0 + self.seekOriginMs = 0 self.isPlaying = false self.hasError = false self.errorMessage = nil @@ -870,14 +901,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private func refreshTime() { guard !stopping else { return } - let t = mediaPlayer.time.intValue + let t = Int64(mediaPlayer.time.intValue) + seekOriginMs let l = mediaPlayer.media?.length.intValue ?? 0 - if l > 0 { lengthMs = l } - position = Float(mediaPlayer.position) - timeText = Self.format(ms: t) - if l <= 0, knownVodDurationMs > 3000 { + if knownVodDurationMs > 3000 { lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max))) + position = Float(Double(t) / Double(knownVodDurationMs)) + } else { + if l > 0 { lengthMs = l } + position = Float(mediaPlayer.position) } + timeText = Self.format(ms: Int32(min(max(t, 0), Int64(Int32.max)))) lengthText = Self.format(ms: lengthMs) isPlaying = mediaPlayer.isPlaying let seekable = (knownVodDurationMs > 3000 || mediaPlayer.isSeekable) && lengthMs > 3000 diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 0504c8a..39f82c3 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.48") + LabeledContent("Versie", value: "0.5.49") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") }