iOS 0.5.49: F1 VOD seek by trimming HLS playlist; scrub slider only seeks on release.
This commit is contained in:
parent
011891c89c
commit
e6607654a2
5 changed files with 113 additions and 29 deletions
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue