iOS 0.5.49: F1 VOD seek by trimming HLS playlist; scrub slider only seeks on release.

This commit is contained in:
Jos Vooges | STH 2026-09-28 01:16:09 +02:00
parent 011891c89c
commit e6607654a2
5 changed files with 113 additions and 29 deletions

View file

@ -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)";

View file

@ -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,16 +847,29 @@ final class ClearKeyDashProxy {
private static func playlistDurationMs(_ infLines: [String], fallbackSeconds: Int) -> Int64 {
var total: Double = 0
for inf in infLines {
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)) {
total += seconds
} else {
total += Double(max(fallbackSeconds, 1))
return seconds
}
}
return Int64(total * 1000)
return Double(max(fallback, 1))
}
/// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`).

View file

@ -84,6 +84,8 @@ struct PlayerScreen: View {
@State private var hideTask: Task<Void, Never>?
@State private var heartbeatTask: Task<Void, Never>?
@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))
}

View file

@ -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 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: t)
if l <= 0, knownVodDurationMs > 3000 {
lengthMs = Int32(min(knownVodDurationMs, Int64(Int32.max)))
}
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

View file

@ -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")
}