Fix ClearKey live/events on VLCKit 4 (adaptive demux, async proxy, no PiP vout).

This commit is contained in:
Jos Vooges | STH 2026-09-19 15:50:44 +02:00
parent 878aa86121
commit 77ddc8b6a3
4 changed files with 179 additions and 107 deletions

View file

@ -337,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -352,7 +352,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.3;
MARKETING_VERSION = 0.5.4;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -370,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -385,7 +385,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.3;
MARKETING_VERSION = 0.5.4;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -31,39 +31,56 @@ final class ClearKeyDashProxy {
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL {
let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else {
throw ProxyError.noKeys
}
stop()
remoteManifest = manifestURL
keyMap = map
cryptoState = CencMp4Decryptor.SessionState()
generation += 1
let gen = generation
let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in
queue.async {
self.stopLocked()
self.remoteManifest = manifestURL
self.keyMap = map
self.cryptoState = CencMp4Decryptor.SessionState()
self.generation += 1
final class ResumeOnce: @unchecked Sendable {
private var done = false
private let lock = NSLock()
func run(_ body: () -> Void) {
lock.lock()
defer { lock.unlock() }
guard !done else { return }
done = true
body()
}
}
let once = ResumeOnce()
do {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: .any)
self.listener = listener
let ready = DispatchSemaphore(value: 0)
var readyError: Error?
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
if let p = listener.port?.rawValue {
let p = listener.port?.rawValue ?? 0
self.port = p
proxyLog.info("listener ready on port \(p, privacy: .public)")
once.run {
if p > 0 {
cont.resume(returning: p)
} else {
cont.resume(throwing: ProxyError.bindFailed)
}
}
proxyLog.info("listener ready on port \(self.port, privacy: .public)")
ready.signal()
case .failed(let err):
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
readyError = err
ready.signal()
once.run { cont.resume(throwing: err) }
default:
break
}
@ -72,36 +89,38 @@ final class ClearKeyDashProxy {
listener.newConnectionHandler = { [weak self] conn in
self?.handle(connection: conn)
}
listener.start(queue: self.queue)
listener.start(queue: queue)
_ = ready.wait(timeout: .now() + 5)
if let readyError { throw readyError }
guard port > 0 else { throw ProxyError.bindFailed }
self.queue.asyncAfter(deadline: .now() + 5) {
once.run { cont.resume(throwing: ProxyError.bindFailed) }
}
} catch {
once.run { cont.resume(throwing: error) }
}
}
}
// Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen).
let warm = DispatchSemaphore(value: 0)
var warmError: Error?
Task {
do {
let (body, _) = try await self.payload(for: "/manifest.mpd")
guard !body.isEmpty, gen == self.generation else {
guard boundPort > 0 else { throw ProxyError.bindFailed }
let gen = generation
let (body, _) = try await payload(for: "/manifest.mpd")
guard !body.isEmpty, gen == generation else {
throw ProxyError.emptyManifest
}
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
} catch {
warmError = error
proxyLog.error("warmup failed: \(error.localizedDescription, privacy: .public)")
}
warm.signal()
}
_ = warm.wait(timeout: .now() + 20)
if let warmError { throw warmError }
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
guard let local = URL(string: "http://127.0.0.1:\(boundPort)/manifest.mpd") else {
throw ProxyError.bindFailed
}
return local
}
func stop() {
queue.sync { stopLocked() }
}
private func stopLocked() {
generation += 1
listener?.cancel()
listener = nil
@ -303,7 +322,7 @@ final class ClearKeyDashProxy {
var req = URLRequest(url: url)
req.timeoutInterval = 25
req.cachePolicy = .reloadIgnoringLocalCacheData
req.setValue("VMC-iOS-ClearKey/0.5.2", forHTTPHeaderField: "User-Agent")
req.setValue("VMC-iOS-ClearKey/0.5.4", forHTTPHeaderField: "User-Agent")
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {

View file

@ -401,14 +401,14 @@ struct PlayerScreen: View {
error = nil
if request.isLive {
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
player.playLive(
await player.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys
)
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
let waitNs: UInt64 = request.needsClearKey ? 10_000_000_000 : 2_500_000_000
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
try? await Task.sleep(nanoseconds: waitNs)
if request.needsClearKey && (player.hasError || !player.isPlaying) {
try? await Task.sleep(nanoseconds: 6_000_000_000)
@ -423,8 +423,10 @@ struct PlayerScreen: View {
}
try? await Task.sleep(nanoseconds: 400_000_000)
loading = false
if !player.hasError {
player.refreshTracks()
}
}
private func startHeartbeatIfNeeded() {
heartbeatTask?.cancel()

View file

@ -30,9 +30,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private let library: VLCLibrary
let mediaPlayer: VLCMediaPlayer
private let drawableHost = VLCPipDrawableHost()
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
private weak var plainVideoView: UIView?
private var lengthMs: Int32 = 0
private var tick: AnyCancellable?
private var clearKeyProxyActive = false
private var liveMode = false
private var livePlayURL: URL?
private var livePlayFormat: String?
private var liveStartedAt: Date?
@ -42,13 +45,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var sceneObserver: NSObjectProtocol?
override init() {
// Eigen library-instance zodat opties/PiP-vout stabiel blijven.
library = VLCLibrary(options: [])
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
library = VLCLibrary.sharedLibrary()
mediaPlayer = VLCMediaPlayer(library: library)
super.init()
mediaPlayer.delegate = self
drawableHost.controller = self
mediaPlayer.drawable = drawableHost
configureAudioSession()
observeSceneForAutoPip()
}
@ -60,9 +62,23 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
func attach(to view: UIView) {
plainVideoView = view
view.backgroundColor = .black
applyDrawable()
}
/// Live/ClearKey → plain UIView. VOD → PiP-drawable (AVSampleBuffer-pad).
private func applyDrawable() {
guard let view = plainVideoView else { return }
if liveMode || clearKeyProxyActive {
pipPossible = false
drawableHost.detachVideo()
mediaPlayer.drawable = view
} else {
drawableHost.attach(to: view)
mediaPlayer.drawable = drawableHost
}
}
func startPictureInPicture() {
pipWindow?.startPictureInPicture()
@ -83,7 +99,15 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
stateText = "Verbinden…"
subtitleTracks = []
audioTracks = []
let media = VLCMedia(url: url)
liveMode = false
clearKeyProxyActive = false
applyDrawable()
guard let media = VLCMedia(url: url) else {
hasError = true
errorMessage = "Kon media niet openen."
stateText = "Fout"
return
}
media.delegate = self
media.addOption(":network-caching=2500")
media.addOption(":http-reconnect")
@ -118,13 +142,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
fallback: URL? = nil,
format: String? = nil,
clearKeys: [ClearKeyEntry] = []
) {
) async {
hasError = false
errorMessage = nil
stateText = "Live laden…"
liveRetryCount = 0
everPlayed = false
liveStartedAt = Date()
liveMode = true
var playURL = url
var playFormat = format
@ -132,11 +157,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if !clearKeys.isEmpty {
do {
// VLCKit speelt geen CENC/ClearKey zelf; lokale proxy decrypt segmenten.
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
stateText = "ClearKey laden…"
// Async: blokkeer de main thread niet (watchdog-crash).
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash"
clearKeyProxyActive = true
stateText = "ClearKey laden…"
} catch {
hasError = true
errorMessage = dutchClearKeyError(error)
@ -145,6 +170,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
}
applyDrawable()
livePlayURL = playURL
livePlayFormat = playFormat
startLiveMedia(url: playURL, format: playFormat)
@ -159,7 +185,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
private func startLiveMedia(url: URL, format: String?) {
let media = VLCMedia(url: url)
guard let media = VLCMedia(url: url) else {
hasError = true
errorMessage = "Kon live-stream niet openen."
stateText = "Fout"
return
}
media.delegate = self
Self.applyLiveOptions(to: media, url: url, format: format)
mediaPlayer.media = media
@ -204,22 +235,19 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
url: URL,
format: String?
) {
media.addOption(":network-caching=1200")
media.addOption(":network-caching=1800")
media.addOption(":http-reconnect")
media.addOption(":live-caching=1000")
media.addOption(":live-caching=1500")
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
media.addOption(":adaptive-logic=highest")
media.addOption(":adaptive-use-access")
let fmt = (format ?? "").lowercased()
let path = url.absoluteString.lowercased()
let isDash = fmt == "dash" || path.contains(".mpd")
let isHls = fmt == "hls" || path.contains(".m3u8") || path.contains("m3u8")
if isDash {
media.addOption(":demux=dash")
media.addOption(":adaptive-logic=highest")
media.addOption(":adaptive-use-access")
} else if isHls {
media.addOption(":demux=hls")
if isHls {
// HLS mag nog steeds via adaptive; geen aparte demux forceren.
}
}
@ -284,8 +312,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
func refreshTracks() {
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
guard mediaPlayer.media != nil else {
subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
audioTracks = []
return
}
let textList = mediaPlayer.textTracks
let audioList = mediaPlayer.audioTracks
var subs: [PlayerTrack] = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
for (index, track) in mediaPlayer.textTracks.enumerated() {
var selectedSub = PlayerTrack.offId
for (index, track) in textList.enumerated() {
let label = Self.displayTrackLabel(
rawName: track.trackName,
fallbackIndex: index + 1,
@ -294,15 +332,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
descriptionHint: track.trackDescription
)
subs.append(PlayerTrack(id: track.trackId, name: label))
if track.isSelected { currentSubtitleId = track.trackId }
}
if !mediaPlayer.textTracks.contains(where: \.isSelected) {
currentSubtitleId = PlayerTrack.offId
if track.isSelected { selectedSub = track.trackId }
}
subtitleTracks = subs
currentSubtitleId = selectedSub
var audios: [PlayerTrack] = []
for (index, track) in mediaPlayer.audioTracks.enumerated() {
var selectedAudio = currentAudioId
for (index, track) in audioList.enumerated() {
let label = Self.displayTrackLabel(
rawName: track.trackName,
fallbackIndex: index + 1,
@ -311,12 +348,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
descriptionHint: track.trackDescription
)
audios.append(PlayerTrack(id: track.trackId, name: label))
if track.isSelected { currentAudioId = track.trackId }
if track.isSelected { selectedAudio = track.trackId }
}
if currentAudioId.isEmpty, let first = audios.first {
currentAudioId = first.id
if selectedAudio.isEmpty, let first = audios.first {
selectedAudio = first.id
}
audioTracks = audios
currentAudioId = selectedAudio
}
private enum TrackKind { case subtitle, audio }
@ -432,7 +470,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
func stop() {
tick?.cancel()
tick = nil
if pipActive {
stopPictureInPicture()
}
mediaPlayer.stop()
mediaPlayer.media = nil
livePlayURL = nil
@ -440,6 +480,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveStartedAt = nil
liveRetryCount = 0
everPlayed = false
liveMode = false
pipWindow = nil
pipPossible = false
pipActive = false
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
@ -452,7 +496,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isPlaying = self.mediaPlayer.isPlaying
if self.pipPossible {
self.invalidatePipPlaybackState()
}
switch newState {
case .buffering: self.stateText = "Bufferen…"
case .opening: self.stateText = "Openen…"
@ -468,7 +514,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if self.clearKeyProxyActive && !self.everPlayed {
self.stateText = "Verbinden…"
self.retryLiveIfNeeded()
} else {
} else if !self.hasError {
self.stateText = "Gestopt"
}
case .error:
@ -606,7 +652,12 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
}
}
// VLCDrawable
func detachVideo() {
videoSubview?.removeFromSuperview()
// Houd referentie; VLC kan hem opnieuw addSubview'en.
}
// VLCDrawable (VLCKit 4: methods, not properties)
func addSubview(_ view: UIView) {
videoSubview = view
guard let container else { return }
@ -618,15 +669,16 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
var bounds: CGRect {
func bounds() -> CGRect {
container?.bounds ?? .zero
}
// VLCPictureInPictureDrawable
var mediaController: VLCPictureInPictureMediaControlling { self }
func mediaController() -> (any VLCPictureInPictureMediaControlling)? { self }
func pictureInPictureReady() -> (VLCPictureInPictureWindowControlling) -> Void {
func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)? {
{ [weak self] pip in
guard let pip else { return }
self?.controller?.onPictureInPictureReady(pip)
}
}
@ -640,13 +692,12 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
controller?.mediaPlayer.pause()
}
@objc(seekBy:completion:)
func seekBy(_ offset: Int64, completion: @escaping () -> Void) {
func seek(by offset: Int64, completion: @escaping () -> Void) {
guard let player = controller?.mediaPlayer else {
completion()
return
}
let ok = player.jump(withOffset: Int(offset), completion: completion)
let ok = player.jump(withOffset: Int32(clamping: offset), completion: completion)
if !ok { completion() }
}