iOS 0.5.48: show seek bar on F1 VOD (VLC live path reports no duration).

This commit is contained in:
Jos Vooges | STH 2026-09-28 01:08:59 +02:00
parent ef566e2756
commit 011891c89c
4 changed files with 99 additions and 11 deletions

View file

@ -345,7 +345,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 60; CURRENT_PROJECT_VERSION = 61;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -360,7 +360,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.47; MARKETING_VERSION = 0.5.48;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -378,7 +378,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 60; CURRENT_PROJECT_VERSION = 61;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -393,7 +393,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.47; MARKETING_VERSION = 0.5.48;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -61,6 +61,8 @@ final class ClearKeyDashProxy {
private var lastMediaPlaylistByPath: [String: String] = [:] private var lastMediaPlaylistByPath: [String: String] = [:]
/// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien /// 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). /// (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 cachedMpdXML: String?
private var cachedMpdAt: Date? private var cachedMpdAt: Date?
private let mpdCacheTTL: TimeInterval = 0.8 private let mpdCacheTTL: TimeInterval = 0.8
@ -134,6 +136,11 @@ final class ClearKeyDashProxy {
struct StartResult { struct StartResult {
let url: URL let url: URL
let session: Int 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. /// 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.segmentSeqByKey = [:]
self.nextStableSeq = 1 self.nextStableSeq = 1
self.lastMediaPlaylistByPath = [:] self.lastMediaPlaylistByPath = [:]
self.lastVodDurationMs = 0
self.cachedMpdXML = nil self.cachedMpdXML = nil
self.cachedMpdAt = nil self.cachedMpdAt = nil
self.cryptoLock.lock() self.cryptoLock.lock()
@ -239,6 +247,10 @@ final class ClearKeyDashProxy {
"warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)" "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. // Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt.
if preferHlsForAVPlayer { if preferHlsForAVPlayer {
await prefetchInitsForAVPlayer() await prefetchInitsForAVPlayer()
@ -247,7 +259,37 @@ final class ClearKeyDashProxy {
guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else { guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else {
throw ProxyError.bindFailed 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 { private func prefetchInitsForAVPlayer() async {
@ -349,6 +391,7 @@ final class ClearKeyDashProxy {
segmentSeqByKey = [:] segmentSeqByKey = [:]
nextStableSeq = 1 nextStableSeq = 1
lastMediaPlaylistByPath = [:] lastMediaPlaylistByPath = [:]
lastVodDurationMs = 0
cachedMpdXML = nil cachedMpdXML = nil
cachedMpdAt = nil cachedMpdAt = nil
cryptoLock.lock() cryptoLock.lock()
@ -746,7 +789,13 @@ final class ClearKeyDashProxy {
} }
let window = Array(segments.suffix(from: startIdx)) let window = Array(segments.suffix(from: startIdx))
let seq = seqBase + 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("#EXTM3U")
out.append("#EXT-X-VERSION:7") out.append("#EXT-X-VERSION:7")
@ -778,6 +827,21 @@ final class ClearKeyDashProxy {
return out.joined(separator: "\n") 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-…`). /// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`).
private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane { private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane {
let s = playlistURL.path.lowercased() let s = playlistURL.path.lowercased()

View file

@ -55,6 +55,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var stopping = false private var stopping = false
private var stopGeneration = 0 private var stopGeneration = 0
private var didApplyPreferredAudio = false private var didApplyPreferredAudio = false
private var knownVodDurationMs: Int64 = 0
override init() { override init() {
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4). // Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
@ -196,6 +197,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
audioTracks = [] audioTracks = []
canSeek = false canSeek = false
didApplyPreferredAudio = false didApplyPreferredAudio = false
knownVodDurationMs = 0
liveMode = false liveMode = false
clearKeyProxyActive = false clearKeyProxyActive = false
plainVideoView?.layoutIfNeeded() plainVideoView?.layoutIfNeeded()
@ -262,6 +264,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveStartedAt = Date() liveStartedAt = Date()
canSeek = false canSeek = false
didApplyPreferredAudio = false didApplyPreferredAudio = false
knownVodDurationMs = 0
liveMode = true liveMode = true
usePipDrawable = false usePipDrawable = false
pipPossible = false pipPossible = false
@ -281,6 +284,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
) )
playURL = started.url playURL = started.url
proxySession = started.session 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. // Lokale clear-HLS of remote HLS.
if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) { if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) {
playFormat = "hls" playFormat = "hls"
@ -345,7 +355,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return return
} }
media.delegate = self 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). // Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash).
if mediaPlayer.drawable == nil { if mediaPlayer.drawable == nil {
applyDrawable(force: true) applyDrawable(force: true)
@ -399,10 +415,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
to media: VLCMedia, to media: VLCMedia,
url: URL, url: URL,
format: String?, format: String?,
viaLocalProxy: Bool viaLocalProxy: Bool,
asVod: Bool = false
) { ) {
// Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen. // 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(":network-caching=1200")
media.addOption(":live-caching=1000") media.addOption(":live-caching=1000")
} else { } else {
@ -740,6 +760,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
self.liveMode = false self.liveMode = false
self.canSeek = false self.canSeek = false
self.didApplyPreferredAudio = false self.didApplyPreferredAudio = false
self.knownVodDurationMs = 0
self.isPlaying = false self.isPlaying = false
self.hasError = false self.hasError = false
self.errorMessage = nil self.errorMessage = nil
@ -854,9 +875,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if l > 0 { lengthMs = l } if l > 0 { lengthMs = l }
position = Float(mediaPlayer.position) position = Float(mediaPlayer.position)
timeText = Self.format(ms: t) timeText = Self.format(ms: t)
if l <= 0, knownVodDurationMs > 3000 {
lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max)))
}
lengthText = Self.format(ms: lengthMs) lengthText = Self.format(ms: lengthMs)
isPlaying = mediaPlayer.isPlaying isPlaying = mediaPlayer.isPlaying
let seekable = mediaPlayer.isSeekable && lengthMs > 3000 let seekable = (knownVodDurationMs > 3000 || mediaPlayer.isSeekable) && lengthMs > 3000
if seekable != canSeek { canSeek = seekable } if seekable != canSeek { canSeek = seekable }
} }

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") { Section("Info") {
LabeledContent("App", value: "VMC iOS") LabeledContent("App", value: "VMC iOS")
LabeledContent("Versie", value: "0.5.47") LabeledContent("Versie", value: "0.5.48")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
} }