Restore working iOS 0.5.35 player and fix Odido redirected DASH on iOS (0.5.41).

Recovered the ClearKey AVPlayer/proxy stack from the Mac stash, keep later UI fixes, resolve DASH segments against the redirected manifest URL and XML-unescape MPD values.
This commit is contained in:
Jos Vooges | STH 2026-09-27 23:34:12 +02:00
parent 221b98190a
commit 89ceccb557
12 changed files with 1069 additions and 404 deletions

View file

@ -341,7 +341,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 = 52; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -356,7 +356,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.39; MARKETING_VERSION = 0.5.41;
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)";
@ -374,7 +374,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 = 52; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -389,7 +389,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.39; MARKETING_VERSION = 0.5.41;
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

@ -21,6 +21,8 @@ final class AVLivePlayerController: NSObject, ObservableObject {
@Published var subtitleOptions: [AVMediaOptionItem] = [] @Published var subtitleOptions: [AVMediaOptionItem] = []
@Published var currentAudioId: String? @Published var currentAudioId: String?
@Published var currentSubtitleId: String? @Published var currentSubtitleId: String?
/// True zodra we minstens één keer echt speelden — timeout mag dan niet meer afbreken.
private var everPlayed = false
let player = AVPlayer() let player = AVPlayer()
private var proxySession = 0 private var proxySession = 0
@ -53,6 +55,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
hasError = false hasError = false
errorMessage = nil errorMessage = nil
hasVideo = false hasVideo = false
everPlayed = false
audioOptions = [] audioOptions = []
subtitleOptions = [] subtitleOptions = []
currentAudioId = nil currentAudioId = nil
@ -130,11 +133,25 @@ final class AVLivePlayerController: NSObject, ObservableObject {
} }
func refreshMediaOptions() { func refreshMediaOptions() {
// ClearKey live: sporen uit de proxy (niet HLS multi-rendition — dat gaf -12312).
let tracks = ClearKeyDashProxy.shared.availableAudioTracks()
if !tracks.isEmpty {
audioOptions = tracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) }
currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? tracks.first?.id
}
guard let item = player.currentItem else { return } guard let item = player.currentItem else { return }
Task { await loadMediaOptions(for: item) } Task { await loadMediaOptions(for: item) }
} }
func selectAudio(id: String) { func selectAudio(id: String) {
// Proxy-spoor wisselen + item herladen (geen seamless HLS AUDIO switch).
if ClearKeyDashProxy.shared.setPreferredAudio(id: id) {
currentAudioId = id
hasError = false
errorMessage = nil
Task { await reloadCurrentItemKeepingProxy() }
return
}
guard let item = player.currentItem, let group = audioGroup, guard let item = player.currentItem, let group = audioGroup,
let option = audioOptionById[id] else { return } let option = audioOptionById[id] else { return }
item.select(option, in: group) item.select(option, in: group)
@ -152,6 +169,15 @@ final class AVLivePlayerController: NSObject, ObservableObject {
} }
} }
/// Herlaad dezelfde lokale HLS-URL na audio-wissel zonder proxy te stoppen.
private func reloadCurrentItemKeepingProxy() async {
guard let asset = player.currentItem?.asset as? AVURLAsset else { return }
let url = asset.url
let gen = generation
stateText = "Audio wisselen…"
await startItem(url: url, gen: gen)
}
// MARK: - Private // MARK: - Private
private func startItem(url: URL, gen: Int) async { private func startItem(url: URL, gen: Int) async {
@ -162,7 +188,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
AVURLAssetAllowsConstrainedNetworkAccessKey: true, AVURLAssetAllowsConstrainedNetworkAccessKey: true,
]) ])
let item = AVPlayerItem(asset: asset) let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 8 item.preferredForwardBufferDuration = 6
clearObservers() clearObservers()
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
@ -173,8 +199,10 @@ final class AVLivePlayerController: NSObject, ObservableObject {
self.stateText = "Afspelen" self.stateText = "Afspelen"
self.hasError = false self.hasError = false
self.refreshHasVideo(from: item) self.refreshHasVideo(from: item)
await self.loadMediaOptions(for: item) // Eerst play — loadMediaOptions mag start niet blokkeren (Prime/HBO voelde traag).
self.player.play() self.player.play()
self.everPlayed = true
await self.loadMediaOptions(for: item)
case .failed: case .failed:
self.fail(from: item.error, gen: gen) self.fail(from: item.error, gen: gen)
default: default:
@ -189,6 +217,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: case .playing:
self.isPlaying = true self.isPlaying = true
self.everPlayed = true
self.stateText = "Afspelen" self.stateText = "Afspelen"
self.hasError = false self.hasError = false
if let item = player.currentItem { if let item = player.currentItem {
@ -196,7 +225,8 @@ final class AVLivePlayerController: NSObject, ObservableObject {
} }
case .waitingToPlayAtSpecifiedRate: case .waitingToPlayAtSpecifiedRate:
self.isPlaying = false self.isPlaying = false
self.stateText = "Bufferen…" // Na first-play: bufferen is normaal — geen "Fout"-timeout.
self.stateText = self.everPlayed ? "Bufferen…" : "Bufferen…"
case .paused: case .paused:
self.isPlaying = false self.isPlaying = false
if !self.hasError { self.stateText = "Pauze" } if !self.hasError { self.stateText = "Pauze" }
@ -228,6 +258,8 @@ final class AVLivePlayerController: NSObject, ObservableObject {
if let events = item.errorLog()?.events, let last = events.last { if let events = item.errorLog()?.events, let last = events.last {
let comment = last.errorComment ?? last.errorDomain let comment = last.errorComment ?? last.errorDomain
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")") self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
// -12312 bij audio-wissel: niet meteen als fatale UI-fout tonen.
if last.errorStatusCode == -12312 { return }
} }
} }
} }
@ -236,20 +268,21 @@ final class AVLivePlayerController: NSObject, ObservableObject {
player.automaticallyWaitsToMinimizeStalling = true player.automaticallyWaitsToMinimizeStalling = true
player.play() player.play()
// Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken. // Timeout alleen als er NOOIT iets speelde — nooit een werkende/bufferende stream afbreken.
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 15_000_000_000) try? await Task.sleep(nanoseconds: 20_000_000_000)
guard self.generation == gen, !self.stopping else { return } guard self.generation == gen, !self.stopping else { return }
if let item = self.player.currentItem { if let item = self.player.currentItem {
self.refreshHasVideo(from: item) self.refreshHasVideo(from: item)
} }
if !self.isPlaying && !self.hasError { if self.everPlayed || self.isPlaying { return }
if !self.hasError {
if let err = item.error { if let err = item.error {
self.fail(from: err, gen: gen) self.fail(from: err, gen: gen)
} else { } else {
self.hasError = true self.hasError = true
self.errorMessage = self.bestErrorMessage(for: item) self.errorMessage = self.bestErrorMessage(for: item)
?? "Stream opende niet binnen 15s (ClearKey/HLS)." ?? "Stream opende niet binnen 20s (ClearKey/HLS)."
self.stateText = "Fout" self.stateText = "Fout"
} }
} }
@ -270,6 +303,13 @@ final class AVLivePlayerController: NSObject, ObservableObject {
private func loadMediaOptions(for item: AVPlayerItem) async { private func loadMediaOptions(for item: AVPlayerItem) async {
let asset = item.asset let asset = item.asset
do { do {
let proxyTracks = ClearKeyDashProxy.shared.availableAudioTracks()
if !proxyTracks.isEmpty {
audioOptions = proxyTracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) }
audioOptionById = [:]
audioGroup = nil
currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? proxyTracks.first?.id
} else {
let audible = try await asset.loadMediaSelectionGroup(for: .audible) let audible = try await asset.loadMediaSelectionGroup(for: .audible)
audioGroup = audible audioGroup = audible
if let audible { if let audible {
@ -293,6 +333,7 @@ final class AVLivePlayerController: NSObject, ObservableObject {
audioOptions = [] audioOptions = []
audioOptionById = [:] audioOptionById = [:]
} }
}
let legible = try await asset.loadMediaSelectionGroup(for: .legible) let legible = try await asset.loadMediaSelectionGroup(for: .legible)
subtitleGroup = legible subtitleGroup = legible
@ -325,6 +366,12 @@ final class AVLivePlayerController: NSObject, ObservableObject {
private func fail(from error: Error?, gen: Int) { private func fail(from error: Error?, gen: Int) {
guard generation == gen, !stopping else { return } guard generation == gen, !stopping else { return }
let ns = (error as NSError?)
// Playlist-mismatch tijdens live/audio-wissel — negeren als we nog (opnieuw) laden.
if let ns, ns.domain == "CoreMediaErrorDomain", ns.code == -12312 {
proxyLogState("ignore -12312: \(ns.localizedDescription)")
return
}
hasError = true hasError = true
if let error { if let error {
errorMessage = dutchAVError(error) errorMessage = dutchAVError(error)
@ -333,13 +380,24 @@ final class AVLivePlayerController: NSObject, ObservableObject {
} else { } else {
errorMessage = "Kon stream niet openen" errorMessage = "Kon stream niet openen"
} }
// Toon geen -12312 als enige melding.
if let msg = errorMessage, msg.contains("-12312") {
errorMessage = nil
hasError = false
return
}
stateText = "Fout" stateText = "Fout"
isPlaying = false isPlaying = false
} }
private func bestErrorMessage(for item: AVPlayerItem) -> String? { private func bestErrorMessage(for item: AVPlayerItem) -> String? {
if let err = item.error { return dutchAVError(err) } if let err = item.error {
let ns = err as NSError
if ns.domain == "CoreMediaErrorDomain", ns.code == -12312 { return nil }
return dutchAVError(err)
}
if let events = item.errorLog()?.events, let last = events.last { if let events = item.errorLog()?.events, let last = events.last {
if last.errorStatusCode == -12312 { return nil }
let code = last.errorStatusCode let code = last.errorStatusCode
let comment = last.errorComment ?? last.errorDomain let comment = last.errorComment ?? last.errorDomain
return "Afspeelfout (\(code)): \(comment)" return "Afspeelfout (\(code)): \(comment)"
@ -394,24 +452,55 @@ final class AVLivePlayerController: NSObject, ObservableObject {
} }
} }
struct AVLivePlayerView: UIViewControllerRepresentable { /// AVPlayerLayer i.p.v. AVPlayerViewController:
/// - schaalt mee bij rotatie zonder seek/rebind (geen haperen)
/// - voorkomt ESPN “alleen audio” na landscape (AVPlayerViewController-compositor-bug)
struct AVLivePlayerView: UIViewRepresentable {
let controller: AVLivePlayerController let controller: AVLivePlayerController
func makeUIViewController(context: Context) -> AVPlayerViewController { func makeUIView(context: Context) -> AVLivePlayerLayerView {
let vc = AVPlayerViewController() let view = AVLivePlayerLayerView()
vc.player = controller.player view.player = controller.player
vc.showsPlaybackControls = false return view
vc.videoGravity = .resizeAspect
vc.allowsPictureInPicturePlayback = true
if #available(iOS 14.2, *) {
vc.canStartPictureInPictureAutomaticallyFromInline = true
}
return vc
} }
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { func updateUIView(_ uiView: AVLivePlayerLayerView, context: Context) {
if vc.player !== controller.player { if uiView.player !== controller.player {
vc.player = controller.player uiView.player = controller.player
} }
} }
static func dismantleUIView(_ uiView: AVLivePlayerLayerView, coordinator: ()) {
uiView.player = nil
}
}
final class AVLivePlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
var player: AVPlayer? {
get { playerLayer.player }
set {
playerLayer.player = newValue
playerLayer.videoGravity = .resizeAspect
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
isUserInteractionEnabled = false
playerLayer.videoGravity = .resizeAspect
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
} }

View file

@ -51,7 +51,11 @@ enum AesCtr {
return out return out
} }
/// CBCS pattern: decrypt `crypt` 16-byte blocks, skip `skip` blocks; leftover <16 clear. /// CBCS pattern decrypt — gelijk aan Chromium `DecryptWithPattern` (ISO 23001-7 §10.4):
/// - IV wordt één keer gezet per protected range (subsample), niet per pattern.
/// - Crypt-blokken vormen één CBC-keten; skip-blokken blijven clear en updaten de IV niet.
/// - Pattern 0:0 = alle volle blokken encrypted (geen pattern).
/// - Partiële trailing bytes blijven clear.
static func cryptCbcs( static func cryptCbcs(
key: Data, key: Data,
iv: Data, iv: Data,
@ -60,31 +64,59 @@ enum AesCtr {
skipByteBlock: Int skipByteBlock: Int
) -> Data? { ) -> Data? {
guard key.count == 16 else { return nil } guard key.count == 16 else { return nil }
if cryptByteBlock <= 0 { if data.isEmpty { return data }
return data
}
let cryptLen = 16 * cryptByteBlock
let skipLen = 16 * max(skipByteBlock, 0)
var result = data
var offset = 0
var blockIv = Data(count: 16)
let ivCopy = min(iv.count, 16)
blockIv.replaceSubrange(0..<ivCopy, with: iv.prefix(ivCopy))
while offset < result.count { let blockSize = 16
let remaining = result.count - offset let totalBlocks = data.count / blockSize
if remaining < cryptLen { break } guard totalBlocks > 0 else { return data }
let slice = result.subdata(in: offset..<(offset + cryptLen))
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: slice) else { return nil } var cryptBlocks = cryptByteBlock
result.replaceSubrange(offset..<(offset + cryptLen), with: plain) var skipBlocks = skipByteBlock
// Next IV = last ciphertext block (CBC chain within crypt run). // ISO/Chromium: 0:0 → alles encrypted als één CBC-run.
blockIv = slice.suffix(16) if cryptBlocks == 0 && skipBlocks == 0 {
offset += cryptLen cryptBlocks = totalBlocks
let skip = min(skipLen, result.count - offset) skipBlocks = 0
offset += skip
// Pattern: IV resets per subsample in caller; within subsample after skip, continue with updated IV.
} }
return result if cryptBlocks <= 0 { return data }
var out = data
var blockIv = Data(count: 16)
let ivn = min(iv.count, 16)
blockIv.replaceSubrange(0..<ivn, with: iv.prefix(ivn))
var blocksProcessed = 0
var offset = 0
// Chromium: start met encrypted run (toggle van false → true).
var isCryptRun = false
while blocksProcessed < totalBlocks {
isCryptRun = !isCryptRun
let runSize = isCryptRun ? cryptBlocks : skipBlocks
if runSize == 0 { continue }
let blocksToProcess = min(runSize, totalBlocks - blocksProcessed)
if blocksToProcess == 0 { continue }
let byteCount = blocksToProcess * blockSize
let range = offset..<(offset + byteCount)
if isCryptRun {
let cipher = out.subdata(in: range)
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: cipher) else {
return nil
}
out.replaceSubrange(range, with: plain)
// CBC-keten over crypt-runs: IV = laatste ciphertext-blok.
if cipher.count >= blockSize {
blockIv.replaceSubrange(0..<blockSize, with: cipher.suffix(blockSize))
}
}
// Skip-run: clear laten; IV niet aanpassen.
offset += byteCount
blocksProcessed += blocksToProcess
}
return out
} }
private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> Data? { private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> Data? {

View file

@ -19,6 +19,7 @@ enum CencMp4Decryptor {
@discardableResult @discardableResult
static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool { static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool {
guard !data.isEmpty, !keys.isEmpty else { return false } guard !data.isEmpty, !keys.isEmpty else { return false }
// Nooit crashen op corrupte segmenten — liever ongedecrypt doorgeven dan app-kill.
var touched = false var touched = false
if containsBox(data, type: "moov") { if containsBox(data, type: "moov") {
harvestAndClearInit(&data, keys: keys, state: &state) harvestAndClearInit(&data, keys: keys, state: &state)
@ -67,19 +68,45 @@ enum CencMp4Decryptor {
let containers: Set<String> = [ let containers: Set<String> = [
"moov", "trak", "mdia", "minf", "stbl", "moof", "traf", "moov", "trak", "mdia", "minf", "stbl", "moof", "traf",
"mvex", "edts", "sinf", "schi", "dinf", "udta", "meta", "mvex", "edts", "sinf", "schi", "dinf", "udta", "meta",
// Sample-entries bevatten sinf/avcC; stsd is FullBox met entry_count.
"stsd", "dref",
"encv", "enca",
"avc1", "avc3", "hev1", "hvc1", "dvh1", "mp4a",
] ]
while offset + 8 <= to { while offset + 8 <= to {
guard let box = readBox(data, at: offset, limit: to) else { break } guard let box = readBox(data, at: offset, limit: to) else { break }
let cont = visit(box) let cont = visit(box)
if !cont { return } if !cont { return }
if recursive, containers.contains(box.type), box.payloadLength > 0 { if recursive, containers.contains(box.type), box.payloadLength > 0 {
walk(data, from: box.payloadStart, to: box.payloadEnd, recursive: true, visit: visit) let childFrom = walkChildrenStart(box)
if childFrom < box.payloadEnd {
walk(data, from: childFrom, to: box.payloadEnd, recursive: true, visit: visit)
}
} }
offset = box.payloadEnd offset = box.payloadEnd
if box.size <= 0 { break } if box.size <= 0 { break }
} }
} }
/// Skip FullBox / SampleEntry vaste headers zodat child-boxes (sinf, tenc, avcC) bereikbaar zijn.
private static func walkChildrenStart(_ box: Box) -> Int {
switch box.type {
case "stsd", "dref":
// FullBox (4) + entry_count (4)
return box.payloadStart + 8
case "meta":
return box.payloadStart + 4
case "encv", "avc1", "avc3", "hev1", "hvc1", "dvh1":
// VisualSampleEntry: boxhdr(8) + SampleEntry(8) + visual(70) = 86
return box.start + 86
case "enca", "mp4a":
// AudioSampleEntry: boxhdr(8) + SampleEntry(8) + audio(20) = 36
return box.start + 36
default:
return box.payloadStart
}
}
private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? { private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? {
guard offset + 8 <= limit else { return nil } guard offset + 8 <= limit else { return nil }
var size = Int(readU32(data, offset)) var size = Int(readU32(data, offset))
@ -98,19 +125,23 @@ enum CencMp4Decryptor {
} }
private static func readU16(_ data: Data, _ o: Int) -> UInt16 { private static func readU16(_ data: Data, _ o: Int) -> UInt16 {
UInt16(data[o]) << 8 | UInt16(data[o + 1]) guard o >= 0, o + 2 <= data.count else { return 0 }
return UInt16(data[o]) << 8 | UInt16(data[o + 1])
} }
private static func readU32(_ data: Data, _ o: Int) -> UInt32 { private static func readU32(_ data: Data, _ o: Int) -> UInt32 {
UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3]) guard o >= 0, o + 4 <= data.count else { return 0 }
return UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3])
} }
private static func readU64(_ data: Data, _ o: Int) -> UInt64 { private static func readU64(_ data: Data, _ o: Int) -> UInt64 {
(UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4)) guard o >= 0, o + 8 <= data.count else { return 0 }
return (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4))
} }
private static func readFourCC(_ data: Data, _ o: Int) -> String { private static func readFourCC(_ data: Data, _ o: Int) -> String {
String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????" guard o >= 0, o + 4 <= data.count else { return "????" }
return String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????"
} }
private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) { private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) {
@ -122,6 +153,10 @@ enum CencMp4Decryptor {
// MARK: - Init segment // MARK: - Init segment
private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) { private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) {
// Elke init is de bron van waarheid voor deze lane (CMAF track_ID is vaak 1 voor A én V).
state.byTrackId.removeAll(keepingCapacity: true)
state.fallback = nil
// Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak. // Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak.
for trak in findBoxes(data, type: "trak", recursive: true) { for trak in findBoxes(data, type: "trak", recursive: true) {
var trackId: UInt32? var trackId: UInt32?
@ -221,7 +256,12 @@ enum CencMp4Decryptor {
if let trackId { if let trackId {
state.byTrackId[trackId] = crypto state.byTrackId[trackId] = crypto
} }
// Fallback: prefer video-achtige crypto (cbcs / pattern) boven pure audio-cenc.
let looksVideo = crypto.scheme == "cbcs" || crypto.scheme == "cbc1"
|| crypto.cryptByteBlock > 0 || crypto.constantIv != nil
if state.fallback == nil || looksVideo {
state.fallback = crypto state.fallback = crypto
}
_ = keys.key(forKid: crypto.kid) _ = keys.key(forKid: crypto.kid)
} }
} }
@ -272,10 +312,14 @@ enum CencMp4Decryptor {
var dataOffsetPresent = false var dataOffsetPresent = false
var sampleSizes: [Int] = [] var sampleSizes: [Int] = []
var sencSamples: [SencSample] = [] var sencSamples: [SencSample] = []
var perSampleIvSize = state.fallback?.perSampleIvSize ?? 8 var perSampleIvSize = 8
if let known = state.byTrackId.values.first, state.byTrackId.count == 1 {
perSampleIvSize = known.perSampleIvSize
}
// tfhd // tfhd
for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) { for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) {
guard tfhd.payloadLength >= 8 else { continue }
let versionFlags = readU32(data, tfhd.payloadStart) let versionFlags = readU32(data, tfhd.payloadStart)
let flags = versionFlags & 0x00FF_FFFF let flags = versionFlags & 0x00FF_FFFF
var o = tfhd.payloadStart + 4 var o = tfhd.payloadStart + 4
@ -284,11 +328,16 @@ enum CencMp4Decryptor {
perSampleIvSize = crypto.perSampleIvSize perSampleIvSize = crypto.perSampleIvSize
} }
if flags & 0x000001 != 0 { // base-data-offset if flags & 0x000001 != 0 { // base-data-offset
guard o + 8 <= tfhd.payloadEnd else { break }
baseDataOffset = Int64(readU64(data, o)); o += 8 baseDataOffset = Int64(readU64(data, o)); o += 8
dataOffsetPresent = true dataOffsetPresent = true
} }
if flags & 0x000002 != 0 { o += 4 } // sample-description-index if flags & 0x000002 != 0 {
guard o + 4 <= tfhd.payloadEnd else { break }
o += 4
}
if flags & 0x000008 != 0 { if flags & 0x000008 != 0 {
guard o + 4 <= tfhd.payloadEnd else { break }
defaultSampleSize = readU32(data, o); o += 4 defaultSampleSize = readU32(data, o); o += 4
} }
_ = versionFlags _ = versionFlags
@ -296,30 +345,42 @@ enum CencMp4Decryptor {
// trun // trun
for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) { for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) {
guard trun.payloadLength >= 8 else { continue }
let versionFlags = readU32(data, trun.payloadStart) let versionFlags = readU32(data, trun.payloadStart)
let version = Int(versionFlags >> 24) let version = Int(versionFlags >> 24)
let flags = versionFlags & 0x00FF_FFFF let flags = versionFlags & 0x00FF_FFFF
var o = trun.payloadStart + 4 var o = trun.payloadStart + 4
let sampleCount = Int(readU32(data, o)); o += 4 let sampleCount = Int(readU32(data, o)); o += 4
guard sampleCount >= 0, sampleCount < 100_000 else { continue }
var dataOffset: Int32 = 0 var dataOffset: Int32 = 0
if flags & 0x000001 != 0 { if flags & 0x000001 != 0 {
guard o + 4 <= trun.payloadEnd else { break }
dataOffset = Int32(bitPattern: readU32(data, o)); o += 4 dataOffset = Int32(bitPattern: readU32(data, o)); o += 4
dataOffsetPresent = true dataOffsetPresent = true
} }
if flags & 0x000004 != 0 { o += 4 } // first-sample-flags if flags & 0x000004 != 0 {
guard o + 4 <= trun.payloadEnd else { break }
o += 4
}
sampleSizes.removeAll(keepingCapacity: true) sampleSizes.removeAll(keepingCapacity: true)
for _ in 0..<sampleCount { for _ in 0..<sampleCount {
if flags & 0x000100 != 0 { // sample-duration if flags & 0x000100 != 0 {
guard o + 4 <= trun.payloadEnd else { break }
o += 4 o += 4
} }
var size = Int(defaultSampleSize) var size = Int(defaultSampleSize)
if flags & 0x000200 != 0 { if flags & 0x000200 != 0 {
guard o + 4 <= trun.payloadEnd else { break }
size = Int(readU32(data, o)); o += 4 size = Int(readU32(data, o)); o += 4
} }
sampleSizes.append(size) sampleSizes.append(max(0, size))
if flags & 0x000400 != 0 { o += 4 } // sample-flags if flags & 0x000400 != 0 {
guard o + 4 <= trun.payloadEnd else { break }
o += 4
}
if flags & 0x000800 != 0 { if flags & 0x000800 != 0 {
o += version == 0 ? 4 : 4 // sample-composition-time-offset guard o + 4 <= trun.payloadEnd else { break }
o += 4
} }
} }
if dataOffsetPresent { if dataOffsetPresent {
@ -340,8 +401,10 @@ enum CencMp4Decryptor {
_ = version _ = version
} }
// senc // Prefer track-crypto uit DÉZE lane. Fallback alleen binnen dezelfde lane.
let crypto = state.byTrackId[trackId] ?? state.fallback let crypto: TrackCrypto? = state.byTrackId[trackId]
?? (state.byTrackId.count == 1 ? state.byTrackId.values.first : nil)
?? state.fallback
if let crypto { if let crypto {
perSampleIvSize = crypto.perSampleIvSize perSampleIvSize = crypto.perSampleIvSize
} }
@ -367,9 +430,10 @@ enum CencMp4Decryptor {
continue continue
} }
// Exacte KID-match, anders primaryKey als er maar één key is.
let key: Data? = { let key: Data? = {
if let crypto, let k = keys.key(forKid: crypto.kid) { return k } guard let crypto else { return keys.primaryKey }
return keys.primaryKey return keys.key(forKid: crypto.kid)
}() }()
guard let key else { guard let key else {
for sz in sampleSizes { mdatCursor += sz } for sz in sampleSizes { mdatCursor += sz }
@ -380,6 +444,7 @@ enum CencMp4Decryptor {
let cryptBlocks = crypto?.cryptByteBlock ?? 0 let cryptBlocks = crypto?.cryptByteBlock ?? 0
let skipBlocks = crypto?.skipByteBlock ?? 0 let skipBlocks = crypto?.skipByteBlock ?? 0
var didDecrypt = false
for i in 0..<sampleSizes.count { for i in 0..<sampleSizes.count {
let size = sampleSizes[i] let size = sampleSizes[i]
guard size > 0, mdatCursor + size <= data.count else { break } guard size > 0, mdatCursor + size <= data.count else { break }
@ -398,10 +463,17 @@ enum CencMp4Decryptor {
) { ) {
sample = decrypted sample = decrypted
data.replaceSubrange(sampleRange, with: sample) data.replaceSubrange(sampleRange, with: sample)
didDecrypt = true
} }
} }
mdatCursor += size mdatCursor += size
} }
// AVPlayer/VLC mogen geen tweede decrypt-poging doen op clear samples.
if didDecrypt {
for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) {
writeFourCC(&data, senc.start + 4, "free")
}
}
} }
} }
@ -466,11 +538,17 @@ enum CencMp4Decryptor {
let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF
var o = saiz.payloadStart + 4 var o = saiz.payloadStart + 4
if saizFlags & 0x1 != 0 { o += 8 } // aux_info_type + parameter if saizFlags & 0x1 != 0 {
guard o + 8 <= saiz.payloadEnd else { return [] }
o += 8
}
guard o + 5 <= saiz.payloadEnd else { return [] }
let defaultSize = Int(data[o]); o += 1 let defaultSize = Int(data[o]); o += 1
let count = Int(readU32(data, o)); o += 4 let count = Int(readU32(data, o)); o += 4
guard count >= 0, count < 100_000 else { return [] }
var sizes: [Int] = [] var sizes: [Int] = []
if defaultSize == 0 { if defaultSize == 0 {
guard o + count <= saiz.payloadEnd else { return [] }
for _ in 0..<count { for _ in 0..<count {
sizes.append(Int(data[o])); o += 1 sizes.append(Int(data[o])); o += 1
} }
@ -545,7 +623,7 @@ enum CencMp4Decryptor {
key: key, key: key,
iv: iv, iv: iv,
subsamples: subsamples, subsamples: subsamples,
cryptByteBlock: max(cryptByteBlock, 1), cryptByteBlock: cryptByteBlock,
skipByteBlock: skipByteBlock skipByteBlock: skipByteBlock
) )
} }
@ -576,6 +654,7 @@ enum CencMp4Decryptor {
cryptByteBlock: Int, cryptByteBlock: Int,
skipByteBlock: Int skipByteBlock: Int
) -> Data? { ) -> Data? {
// Per subsample: IV reset (Chromium/ISO). Pattern chaint binnen de protected range.
var out = sample var out = sample
if let subsamples { if let subsamples {
var cursor = 0 var cursor = 0
@ -609,17 +688,23 @@ enum CencMp4Decryptor {
sampleCount: Int, sampleCount: Int,
subsamples: [(clear: Int, protected: Int)]? subsamples: [(clear: Int, protected: Int)]?
) -> [Range<Int>]? { ) -> [Range<Int>]? {
guard sampleCount > 0 else { return nil }
guard let subsamples else { guard let subsamples else {
return [0..<sampleCount] return [0..<sampleCount]
} }
var ranges: [Range<Int>] = [] var ranges: [Range<Int>] = []
var cursor = 0 var cursor = 0
for sub in subsamples { for sub in subsamples {
cursor += sub.clear cursor += max(0, sub.clear)
if sub.protected > 0 { let prot = max(0, sub.protected)
ranges.append(cursor..<(cursor + sub.protected)) if prot > 0 {
let end = min(cursor + prot, sampleCount)
if cursor < end {
ranges.append(cursor..<end)
} }
cursor += sub.protected }
cursor += prot
if cursor >= sampleCount { break }
} }
return ranges return ranges
} }

View file

@ -30,14 +30,17 @@ final class ClearKeyDashProxy {
} }
private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy") private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy")
/// Beschermt alle gedeelde session-state. payload() draait concurrent via Task{} — /// Beschermt alle gedeelde session-state. Recursive: mediaPlaylist → resolveTemplateURL
/// zonder deze lock → data races / EXC_BAD_ACCESS. /// → withManifestQuery mag genest onder withState draaien (anders deadlock = app hangt).
private let stateLock = NSLock() private let stateLock = NSRecursiveLock()
private let cryptoLock = NSLock() private let cryptoLock = NSLock()
private var listener: NWListener? private var listener: NWListener?
private var port: UInt16 = 0 private var port: UInt16 = 0
private var remoteManifest: URL? private var remoteManifest: URL?
/// Auth-query van het manifest (bijv. Akamai hdnts) — plakken op relatieve segment-URLs. /// Eind-URL na redirects (Odido: lb → edge-sessiepad). Segmenten resolven hiertegen en
/// refreshes blijven op deze URL, anders wisselt het sessiepad per refresh (403 / seq-breuk).
private var resolvedManifest: URL?
/// Auth-query van het manifest (bijv. Akamai `hdnts=…`) — plakken op relatieve segment-URLs.
private var manifestQuery: String? private var manifestQuery: String?
private var keyMap: ClearKeyKeyMap? private var keyMap: ClearKeyKeyMap?
private var cryptoVideo = CencMp4Decryptor.SessionState() private var cryptoVideo = CencMp4Decryptor.SessionState()
@ -54,12 +57,13 @@ final class ClearKeyDashProxy {
/// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312). /// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312).
private var segmentSeqByKey: [String: Int] = [:] private var segmentSeqByKey: [String: Int] = [:]
private var nextStableSeq = 1 private var nextStableSeq = 1
/// Laatste niet-lege media-playlist per pad (lege refresh → -12312). /// Laatste niet-lege media-playlist per pad (lege refresh → -12312 / [].)
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).
private var cachedMpdXML: String? private var cachedMpdXML: String?
private var cachedMpdAt: Date? private var cachedMpdAt: Date?
private let mpdCacheTTL: TimeInterval = 1.0 private let mpdCacheTTL: TimeInterval = 0.8
private let urlSession: URLSession = { private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 15 cfg.timeoutIntervalForRequest = 15
@ -70,6 +74,7 @@ final class ClearKeyDashProxy {
}() }()
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt). /// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
/// Voorheen alles via één gate → segmenten blokkeerden live-refresh → stall na ~10s + zware buffer.
private let playlistGate = PayloadGate() private let playlistGate = PayloadGate()
private init() {} private init() {}
@ -95,6 +100,7 @@ final class ClearKeyDashProxy {
let name: String let name: String
} }
/// Audio-sporen uit de huidige DASH-sessie (voor UI; niet via HLS multi-rendition).
func availableAudioTracks() -> [ProxyAudioTrack] { func availableAudioTracks() -> [ProxyAudioTrack] {
withState { withState {
dashAudioReps.enumerated().map { i, a in dashAudioReps.enumerated().map { i, a in
@ -107,6 +113,7 @@ final class ClearKeyDashProxy {
withState { dashAudioRep?.id } withState { dashAudioRep?.id }
} }
/// Wissel actief audio-spoor. Caller herlaadt AVPlayer-item (geen HLS rendition-switch).
@discardableResult @discardableResult
func setPreferredAudio(id: String) -> Bool { func setPreferredAudio(id: String) -> Bool {
withState { withState {
@ -116,6 +123,7 @@ final class ClearKeyDashProxy {
cryptoLock.lock() cryptoLock.lock()
cryptoAudio = CencMp4Decryptor.SessionState() cryptoAudio = CencMp4Decryptor.SessionState()
cryptoLock.unlock() cryptoLock.unlock()
// Alleen audio-playlist-cache legen — video MEDIA-SEQUENCE niet resetten (crash/stotter).
lastMediaPlaylistByPath["/hls/a.m3u8"] = nil lastMediaPlaylistByPath["/hls/a.m3u8"] = nil
proxyLog.info("preferred audio → \(match.id, privacy: .public)") proxyLog.info("preferred audio → \(match.id, privacy: .public)")
return true return true
@ -143,8 +151,10 @@ final class ClearKeyDashProxy {
let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in
queue.async { queue.async {
self.withState {
self.stopLocked() self.stopLocked()
self.remoteManifest = manifestURL self.remoteManifest = manifestURL
self.resolvedManifest = nil
self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery
self.keyMap = map self.keyMap = map
self.isHlsMode = hls self.isHlsMode = hls
@ -163,7 +173,8 @@ final class ClearKeyDashProxy {
self.cryptoShared = CencMp4Decryptor.SessionState() self.cryptoShared = CencMp4Decryptor.SessionState()
self.cryptoLock.unlock() self.cryptoLock.unlock()
self.generation += 1 self.generation += 1
let session = self.generation }
let session = self.withState { self.generation }
let once = ResumeOnce() let once = ResumeOnce()
@ -171,14 +182,14 @@ final class ClearKeyDashProxy {
let params = NWParameters.tcp let params = NWParameters.tcp
params.allowLocalEndpointReuse = true params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: .any) let listener = try NWListener(using: params, on: .any)
self.listener = listener self.withState { self.listener = listener }
listener.stateUpdateHandler = { [weak self] state in listener.stateUpdateHandler = { [weak self] state in
guard let self else { return } guard let self else { return }
switch state { switch state {
case .ready: case .ready:
let p = listener.port?.rawValue ?? 0 let p = listener.port?.rawValue ?? 0
self.port = p self.withState { self.port = p }
proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)") proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)")
once.run { once.run {
if p > 0 { if p > 0 {
@ -326,6 +337,7 @@ final class ClearKeyDashProxy {
listener = nil listener = nil
port = 0 port = 0
remoteManifest = nil remoteManifest = nil
resolvedManifest = nil
manifestQuery = nil manifestQuery = nil
keyMap = nil keyMap = nil
isHlsMode = false isHlsMode = false
@ -489,13 +501,13 @@ final class ClearKeyDashProxy {
let query = split.count > 1 ? split[1] : nil let query = split.count > 1 ? split[1] : nil
if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" { if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" {
let raw = try await fetch(remoteManifest) let (raw, manifestBase) = try await fetchManifest(remoteManifest)
if isHlsMode || Self.looksLikeHls(raw) { if isHlsMode || Self.looksLikeHls(raw) {
let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self) let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self)
guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else { guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else {
throw ProxyError.emptyManifest throw ProxyError.emptyManifest
} }
let rewritten = rewriteHls(text, playlistURL: remoteManifest) let rewritten = rewriteHls(text, playlistURL: manifestBase)
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl") return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
} }
let xml: String = { let xml: String = {
@ -507,10 +519,10 @@ final class ClearKeyDashProxy {
} }
// AVPlayer: DASH → HLS master (+ media playlists via /hls/*). // AVPlayer: DASH → HLS master (+ media playlists via /hls/*).
if preferHlsOutput || pathOnly == "/manifest.m3u8" { if preferHlsOutput || pathOnly == "/manifest.m3u8" {
let hls = try withState { try dashToHlsMaster(xml, mpdURL: remoteManifest) } let hls = try withState { try dashToHlsMaster(xml, mpdURL: manifestBase) }
return (Data(hls.utf8), "application/vnd.apple.mpegurl") return (Data(hls.utf8), "application/vnd.apple.mpegurl")
} }
let rewritten = rewriteMpd(xml, mpdURL: remoteManifest) let rewritten = rewriteMpd(xml, mpdURL: manifestBase)
if !rewritten.localizedCaseInsensitiveContains("<MPD") { if !rewritten.localizedCaseInsensitiveContains("<MPD") {
throw ProxyError.emptyManifest throw ProxyError.emptyManifest
} }
@ -842,6 +854,10 @@ final class ClearKeyDashProxy {
} }
private func fetch(_ url: URL) async throws -> Data { private func fetch(_ url: URL) async throws -> Data {
try await fetchFollowingRedirects(url).data
}
private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.timeoutInterval = 15 req.timeoutInterval = 15
req.cachePolicy = .reloadIgnoringLocalCacheData req.cachePolicy = .reloadIgnoringLocalCacheData
@ -851,7 +867,25 @@ final class ClearKeyDashProxy {
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw ProxyError.upstream(http.statusCode) throw ProxyError.upstream(http.statusCode)
} }
return data return (data, resp.url ?? url)
}
/// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel.
private func fetchManifest(_ original: URL) async throws -> (data: Data, baseURL: URL) {
let sticky = withState { resolvedManifest }
if let sticky, sticky != original {
if let got = try? await fetchFollowingRedirects(sticky) {
withState { resolvedManifest = got.finalURL }
return (got.data, got.finalURL)
}
proxyLog.error("sticky manifest failed — re-resolving via original URL")
}
let got = try await fetchFollowingRedirects(original)
withState { resolvedManifest = got.finalURL }
if got.finalURL != original {
proxyLog.info("manifest redirected to \(got.finalURL.host ?? "?", privacy: .public)")
}
return (got.data, got.finalURL)
} }
private func rewriteMpd(_ xml: String, mpdURL: URL) -> String { private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
@ -881,8 +915,8 @@ final class ClearKeyDashProxy {
let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
for match in matches.reversed() { for match in matches.reversed() {
guard match.numberOfRanges >= 2 else { continue } guard match.numberOfRanges >= 2 else { continue }
let content = ns.substring(with: match.range(at: 1)) let content = Self.xmlUnescape(ns.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines))
if content.contains("127.0.0.1:\(port)") { if content.contains("127.0.0.1:\(port)") {
continue continue
} }
@ -1309,12 +1343,12 @@ final class ClearKeyDashProxy {
let s = String(block[initMatch]) let s = String(block[initMatch])
if let q1 = s.range(of: "sourceURL=\"")?.upperBound, if let q1 = s.range(of: "sourceURL=\"")?.upperBound,
let q2 = s[q1...].firstIndex(of: "\"") { let q2 = s[q1...].firstIndex(of: "\"") {
out.initialization = String(s[q1..<q2]) out.initialization = xmlUnescape(String(s[q1..<q2]))
} }
} else if let initMatch = block.range(of: #"sourceURL="([^"]+)""#, options: [.regularExpression, .caseInsensitive]) { } else if let initMatch = block.range(of: #"sourceURL="([^"]+)""#, options: [.regularExpression, .caseInsensitive]) {
let s = String(block[initMatch]) let s = String(block[initMatch])
if let q1 = s.firstIndex(of: "\""), let q2 = s.lastIndex(of: "\""), q1 < q2 { if let q1 = s.firstIndex(of: "\""), let q2 = s.lastIndex(of: "\""), q1 < q2 {
out.initialization = String(s[s.index(after: q1)..<q2]) out.initialization = xmlUnescape(String(s[s.index(after: q1)..<q2]))
} }
} }
if let urlRegex = try? NSRegularExpression( if let urlRegex = try? NSRegularExpression(
@ -1324,7 +1358,7 @@ final class ClearKeyDashProxy {
let ns = block as NSString let ns = block as NSString
for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) { for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) {
guard m.numberOfRanges >= 2 else { continue } guard m.numberOfRanges >= 2 else { continue }
let media = ns.substring(with: m.range(at: 1)) let media = xmlUnescape(ns.substring(with: m.range(at: 1)))
if !media.isEmpty { out.segmentListMedia.append(media) } if !media.isEmpty { out.segmentListMedia.append(media) }
} }
} }
@ -1338,7 +1372,18 @@ final class ClearKeyDashProxy {
} }
let block = String(xml[r]) let block = String(xml[r])
guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil } guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil }
return String(block[block.index(after: start)..<end]).trimmingCharacters(in: .whitespacesAndNewlines) return xmlUnescape(String(block[block.index(after: start)..<end]).trimmingCharacters(in: .whitespacesAndNewlines))
}
/// MPD is XML: `&amp;` in BaseURL/template-URLs moet `&` worden, anders weigert de CDN (Odido: 403).
private static func xmlUnescape(_ s: String) -> String {
guard s.contains("&") else { return s }
return s
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&amp;", with: "&")
} }
private static func xmlAttr(_ attrs: String, _ name: String) -> String? { private static func xmlAttr(_ attrs: String, _ name: String) -> String? {
@ -1351,7 +1396,7 @@ final class ClearKeyDashProxy {
let ns = attrs as NSString let ns = attrs as NSString
if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)), if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)),
m.numberOfRanges >= 2 { m.numberOfRanges >= 2 {
return ns.substring(with: m.range(at: 1)) return xmlUnescape(ns.substring(with: m.range(at: 1)))
} }
} }
return nil return nil
@ -1570,19 +1615,21 @@ final class ClearKeyDashProxy {
let remote = withState { remoteManifest } let remote = withState { remoteManifest }
guard let remoteManifest = remote else { throw ProxyError.notStarted } guard let remoteManifest = remote else { throw ProxyError.notStarted }
let cached: String? = withState { let cached: (xml: String, base: URL)? = withState {
if let xml = cachedMpdXML, let at = cachedMpdAt, if let xml = cachedMpdXML, let at = cachedMpdAt,
Date().timeIntervalSince(at) < mpdCacheTTL { Date().timeIntervalSince(at) < mpdCacheTTL {
return xml return (xml, resolvedManifest ?? remoteManifest)
} }
return nil return nil
} }
let xml: String let xml: String
let manifestBase: URL
if let cached { if let cached {
xml = cached xml = cached.xml
manifestBase = cached.base
} else { } else {
let raw = try await fetch(remoteManifest) let (raw, base) = try await fetchManifest(remoteManifest)
let fetched: String = { let fetched: String = {
if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s } if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s }
return String(decoding: raw, as: UTF8.self) return String(decoding: raw, as: UTF8.self)
@ -1592,9 +1639,10 @@ final class ClearKeyDashProxy {
cachedMpdAt = Date() cachedMpdAt = Date()
} }
xml = fetched xml = fetched
manifestBase = base
} }
let reps = parseDashReps(xml, mpdURL: remoteManifest) let reps = parseDashReps(xml, mpdURL: manifestBase)
let videos = reps.filter { !$0.isAudio }.map(\.rep) let videos = reps.filter { !$0.isAudio }.map(\.rep)
let audios = reps.filter(\.isAudio).map(\.rep) let audios = reps.filter(\.isAudio).map(\.rep)
@ -1827,4 +1875,3 @@ final class ClearKeyDashProxy {
} }
} }
} }

View file

@ -22,16 +22,27 @@ struct ClearKeyKeyMap {
var isEmpty: Bool { keysByKid.isEmpty } var isEmpty: Bool { keysByKid.isEmpty }
/// Eerste key (streams hebben meestal één kid/key-paar). /// Unieke key-waarden in de map (meestal 1).
var primaryKey: Data? { keysByKid.values.first } var uniqueKeyCount: Int {
Set(keysByKid.values.map { $0.base64EncodedString() }).count
}
/// Eerste key — alleen veilig als er precies één unieke key is.
var primaryKey: Data? {
guard uniqueKeyCount == 1 else { return nil }
return keysByKid.values.first
}
func key(forKid kid: Data) -> Data? { func key(forKid kid: Data) -> Data? {
guard kid.count == 16 else { return primaryKey } if kid.count == 16 {
let hex = kid.map { String(format: "%02x", $0) }.joined() let hex = kid.map { String(format: "%02x", $0) }.joined()
if let k = keysByKid[hex] { return k } if let k = keysByKid[hex] { return k }
let swapped = Self.swapUuidEndianness(kid) let swapped = Self.swapUuidEndianness(kid)
let swappedHex = swapped.map { String(format: "%02x", $0) }.joined() let swappedHex = swapped.map { String(format: "%02x", $0) }.joined()
return keysByKid[swappedHex] ?? primaryKey if let k = keysByKid[swappedHex] { return k }
}
// Streams met één key (Android parity): gebruik die key als KID-match faalt.
return primaryKey
} }
static func sanitizeHex(_ raw: String) -> String { static func sanitizeHex(_ raw: String) -> String {

View file

@ -18,6 +18,8 @@ struct PlayRequest: Identifiable {
let watchSessionId: String? let watchSessionId: String?
let watchKind: String? let watchKind: String?
let watchId: String? let watchId: String?
/// Provider-/titel-logo voor laad-animatie (events, films, series).
let logoUrl: String?
static func vod( static func vod(
url: URL, url: URL,
@ -28,13 +30,15 @@ struct PlayRequest: Identifiable {
episodeId: String?, episodeId: String?,
seriesId: String?, seriesId: String?,
startMs: Int64, startMs: Int64,
subtitleFileURL: URL? = nil subtitleFileURL: URL? = nil,
logoUrl: String? = nil
) -> PlayRequest { ) -> PlayRequest {
PlayRequest( PlayRequest(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId, movieId: movieId, episodeId: episodeId, seriesId: seriesId,
startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL, startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL,
format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil,
logoUrl: logoUrl
) )
} }
@ -46,14 +50,15 @@ struct PlayRequest: Identifiable {
drm: DrmInfo? = nil, drm: DrmInfo? = nil,
watchSessionId: String? = nil, watchSessionId: String? = nil,
watchKind: String? = nil, watchKind: String? = nil,
watchId: String? = nil watchId: String? = nil,
logoUrl: String? = nil
) -> PlayRequest { ) -> PlayRequest {
PlayRequest( PlayRequest(
url: url, title: title, mediaType: "live", mediaFileId: nil, url: url, title: title, mediaType: "live", mediaFileId: nil,
movieId: nil, episodeId: nil, seriesId: nil, movieId: nil, episodeId: nil, seriesId: nil,
startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil, startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil,
format: format, drm: drm, watchSessionId: watchSessionId, format: format, drm: drm, watchSessionId: watchSessionId,
watchKind: watchKind, watchId: watchId watchKind: watchKind, watchId: watchId, logoUrl: logoUrl
) )
} }
@ -69,7 +74,7 @@ struct PlayerScreen: View {
let request: PlayRequest let request: PlayRequest
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact). /// ClearKey: DASH→AVPlayer+proxy; native HLS (ESPN)→VLC+proxy. FTA live + VOD → VLC.
@StateObject private var vlc = VLCPlayerController() @StateObject private var vlc = VLCPlayerController()
@StateObject private var av = AVLivePlayerController() @StateObject private var av = AVLivePlayerController()
@State private var controlsVisible = true @State private var controlsVisible = true
@ -85,14 +90,25 @@ struct PlayerScreen: View {
@State private var subsError: String? @State private var subsError: String?
@State private var pendingStop: Task<Void, Never>? @State private var pendingStop: Task<Void, Never>?
/// ClearKey live: DASH→AVPlayer; native HLS (ESPN)→VLC (AV bevriest bij rotatie).
@State private var clearKeyPreferAV = true
private var isLive: Bool { request.isLive } private var isLive: Bool { request.isLive }
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey } private var useClearKeyAV: Bool { request.isLive && request.needsClearKey && clearKeyPreferAV }
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText } private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying } private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError } private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage } private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
/// Alleen start/buffer vóór first frame — overlay mag playback niet blokkeren.
private var showLoadingChrome: Bool {
guard !playerHasError else { return false }
if loading && !isPlaying { return true }
if useClearKeyAV, !av.hasVideo, !av.isPlaying { return true }
return false
}
var body: some View { var body: some View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
@ -116,18 +132,16 @@ struct PlayerScreen: View {
.ignoresSafeArea() .ignoresSafeArea()
} }
// Speler eet touches op — vang tikken altijd zelf af.
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.ignoresSafeArea() .ignoresSafeArea()
.onTapGesture { toggleControls() } .onTapGesture { toggleControls() }
.allowsHitTesting(!controlsVisible) .allowsHitTesting(!controlsVisible)
if loading && !isPlaying { if showLoadingChrome {
ProgressView(stateText) PlayerLoadingChrome(logoUrl: request.logoUrl, status: stateText)
.tint(.white)
.foregroundStyle(.white)
.allowsHitTesting(false) .allowsHitTesting(false)
.transition(.opacity)
} }
if let error { if let error {
@ -179,6 +193,19 @@ struct PlayerScreen: View {
} }
} }
/// 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 }
let viewAspect = container.width / container.height
if viewAspect > aspect {
let h = container.height
return CGSize(width: h * aspect, height: h)
} else {
let w = container.width
return CGSize(width: w, height: w / aspect)
}
}
private var controlsOverlay: some View { private var controlsOverlay: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
@ -201,7 +228,7 @@ struct PlayerScreen: View {
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
} }
Spacer() Spacer()
if !useClearKeyAV && vlc.pipPossible { if !isLive, vlc.pipPossible {
Button { Button {
vlc.togglePictureInPicture() vlc.togglePictureInPicture()
scheduleHide() scheduleHide()
@ -215,11 +242,17 @@ struct PlayerScreen: View {
.background(.black.opacity(0.4), in: Circle()) .background(.black.opacity(0.4), in: Circle())
} }
} }
if !request.isLive {
Button { Button {
showTracks = true showTracks = true
scheduleHide(cancelOnly: true) scheduleHide(cancelOnly: true)
if useClearKeyAV {
av.refreshMediaOptions()
} else {
vlc.refreshTracks()
}
if !isLive {
Task { await loadSubtitles() } Task { await loadSubtitles() }
}
} label: { } label: {
Image(systemName: "captions.bubble.fill") Image(systemName: "captions.bubble.fill")
.font(.title3) .font(.title3)
@ -228,7 +261,6 @@ struct PlayerScreen: View {
.background(.black.opacity(0.4), in: Circle()) .background(.black.opacity(0.4), in: Circle())
} }
} }
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, 12)
@ -237,7 +269,7 @@ struct PlayerScreen: View {
.onTapGesture { hideControls() } .onTapGesture { hideControls() }
VStack(spacing: 14) { VStack(spacing: 14) {
if !request.isLive { if !isLive {
Slider( Slider(
value: Binding( value: Binding(
get: { Double(vlc.position) }, get: { Double(vlc.position) },
@ -258,7 +290,7 @@ struct PlayerScreen: View {
} }
HStack(spacing: 36) { HStack(spacing: 36) {
if !request.isLive { if !isLive {
Button { Button {
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0)) vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
scheduleHide() scheduleHide()
@ -275,7 +307,7 @@ struct PlayerScreen: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.shadow(radius: 8) .shadow(radius: 8)
} }
if !request.isLive { if !isLive {
Button { Button {
vlc.seek(toMs: vlc.currentPositionMs() + 10_000) vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
scheduleHide() scheduleHide()
@ -295,6 +327,61 @@ struct PlayerScreen: View {
private var tracksSheet: some View { private var tracksSheet: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
if useClearKeyAV {
Picker("", selection: $trackTab) {
Text("Audio").tag(0)
Text("Ondertitel").tag(1)
}
.pickerStyle(.segmented)
.padding()
if trackTab == 0 {
List {
if av.audioOptions.isEmpty {
Text("Geen audio-sporen gevonden")
.foregroundStyle(McColors.textDim)
.listRowBackground(McColors.bgElevated)
}
ForEach(av.audioOptions) { track in
Button {
av.selectAudio(id: track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == av.currentAudioId {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
}
.listRowBackground(McColors.bgElevated)
}
}
.scrollContentBackground(.hidden)
} else {
List {
ForEach(av.subtitleOptions) { track in
Button {
av.selectSubtitle(id: track.id == "off" ? nil : track.id)
showTracks = false
scheduleHide()
} label: {
HStack {
Text(track.name).foregroundStyle(McColors.text)
Spacer()
if track.id == (av.currentSubtitleId ?? "off") {
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
}
}
}
.listRowBackground(McColors.bgElevated)
}
}
.scrollContentBackground(.hidden)
}
} else {
Picker("", selection: $trackTab) { Picker("", selection: $trackTab) {
Text("In bestand").tag(0) Text("In bestand").tag(0)
Text("Downloaden").tag(1) Text("Downloaden").tag(1)
@ -325,7 +412,13 @@ struct PlayerScreen: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
} else if trackTab == 1 { } else if trackTab == 1 {
Group { Group {
if subsLoading { if isLive {
ContentUnavailableView(
"Alleen bij VOD",
systemImage: "arrow.down.circle",
description: Text("OpenSubtitles is beschikbaar bij films/series.")
)
} else if subsLoading {
ProgressView("Zoeken…").padding(.top, 40) ProgressView("Zoeken…").padding(.top, 40)
} else if let subsError { } else if let subsError {
Text(subsError).foregroundStyle(McColors.danger).padding() Text(subsError).foregroundStyle(McColors.danger).padding()
@ -377,6 +470,7 @@ struct PlayerScreen: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
} }
} }
}
.background(McColors.bg) .background(McColors.bg)
.navigationTitle("Sporen") .navigationTitle("Sporen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -385,7 +479,14 @@ struct PlayerScreen: View {
Button("Sluiten") { showTracks = false } Button("Sluiten") { showTracks = false }
} }
} }
.onAppear { vlc.refreshTracks() } .onAppear {
if useClearKeyAV {
av.refreshMediaOptions()
trackTab = 0
} else {
vlc.refreshTracks()
}
}
} }
} }
@ -441,8 +542,33 @@ struct PlayerScreen: View {
private func start() async { private func start() async {
loading = true loading = true
error = nil error = nil
if useClearKeyAV { if request.isLive && request.needsClearKey {
let keys = request.drm?.keys ?? [] let keys = request.drm?.keys ?? []
let fmt = (request.format ?? "").lowercased()
// ESPN e.d.: native HLS + aparte AUDIO-rendition → AVPlayer bevriest beeld bij rotatie.
// DASH→HLS (Prime/HBO/Viaplay/Ziggo) blijft op AVPlayer (rotatie is daar vloeiend).
let nativeHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(request.url)
if nativeHls {
clearKeyPreferAV = false
await vlc.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys,
preferHls: true
)
let maxWaitNs: UInt64 = 18_000_000_000
let stepNs: UInt64 = 200_000_000
var waited: UInt64 = 0
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if vlc.hasError || !vlc.isPlaying {
error = vlc.errorMessage ?? "Kon deze ClearKey-livestream niet starten."
}
} else {
clearKeyPreferAV = true
await av.play( await av.play(
url: request.url, url: request.url,
fallback: request.fallbackUrl, fallback: request.fallbackUrl,
@ -457,10 +583,32 @@ struct PlayerScreen: View {
try? await Task.sleep(nanoseconds: stepNs) try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs waited += stepNs
} }
if !av.isPlaying || av.hasError { if av.isPlaying && !av.hasError {
error = av.errorMessage loading = false
return
}
let avFailHint = av.errorMessage
av.stop()
clearKeyPreferAV = false
try? await Task.sleep(nanoseconds: 250_000_000)
await vlc.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: keys,
preferHls: true
)
waited = 0
while waited < 8_000_000_000 && !vlc.isPlaying && !vlc.hasError {
try? await Task.sleep(nanoseconds: stepNs)
waited += stepNs
}
if vlc.hasError || !vlc.isPlaying {
error = vlc.errorMessage
?? avFailHint
?? "Kon deze ClearKey-livestream niet starten." ?? "Kon deze ClearKey-livestream niet starten."
} }
}
} else if isLive { } else if isLive {
await vlc.playLive( await vlc.playLive(
url: request.url, url: request.url,
@ -477,6 +625,8 @@ struct PlayerScreen: View {
} }
if vlc.hasError { if vlc.hasError {
error = vlc.errorMessage ?? "Kon deze livestream niet starten." error = vlc.errorMessage ?? "Kon deze livestream niet starten."
} else if !vlc.isPlaying {
error = vlc.errorMessage ?? "Livestream startte niet (geen beeld/geluid)."
} }
} else { } else {
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL) vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
@ -560,18 +710,6 @@ struct PlayerScreen: View {
subsError = error.localizedDescription subsError = error.localizedDescription
} }
} }
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
let containerAspect = container.width / container.height
if containerAspect > aspect {
let h = container.height
return CGSize(width: h * aspect, height: h)
} else {
let w = container.width
return CGSize(width: w, height: w / aspect)
}
}
} }
struct PlayLauncher: View { struct PlayLauncher: View {
@ -583,6 +721,7 @@ struct PlayLauncher: View {
let episodeId: String? let episodeId: String?
let seriesId: String? let seriesId: String?
let startMs: Int64 let startMs: Int64
var logoUrl: String? = nil
@State private var request: PlayRequest? @State private var request: PlayRequest?
@State private var error: String? @State private var error: String?
@ -594,7 +733,7 @@ struct PlayLauncher: View {
if let request { if let request {
PlayerScreen(request: request) PlayerScreen(request: request)
} else if loading { } else if loading {
ProgressView("Stream voorbereiden…").tint(.white).foregroundStyle(.white) PlayerLoadingChrome(logoUrl: logoUrl, status: "Stream voorbereiden…")
} else if let error { } else if let error {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding() Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding()
@ -634,7 +773,8 @@ struct PlayLauncher: View {
request = .vod( request = .vod(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs, movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs,
subtitleFileURL: subtitleURL subtitleFileURL: subtitleURL,
logoUrl: logoUrl
) )
loading = false loading = false
} catch { } catch {
@ -643,3 +783,65 @@ struct PlayLauncher: View {
} }
} }
} }
/// Centraal logo (provider / film / serie) met zachte pulse i.p.v. kale ProgressView.
struct PlayerLoadingChrome: View {
let logoUrl: String?
let status: String
@State private var pulse = false
@State private var glow = false
private var hasLogo: Bool {
guard let logoUrl, !logoUrl.isEmpty, URL(string: logoUrl) != nil else { return false }
return true
}
var body: some View {
ZStack {
Color.black.opacity(0.55).ignoresSafeArea()
VStack(spacing: 22) {
if hasLogo {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.white.opacity(glow ? 0.18 : 0.06),
Color.clear,
],
center: .center,
startRadius: 8,
endRadius: 140
)
)
.frame(width: 280, height: 280)
.scaleEffect(glow ? 1.08 : 0.92)
RemoteImage(url: logoUrl, contentMode: .fit, opaqueBackground: false)
.frame(maxWidth: 240, maxHeight: 96)
.scaleEffect(pulse ? 1.04 : 0.96)
.opacity(pulse ? 1.0 : 0.72)
.shadow(color: .black.opacity(0.55), radius: 18, y: 8)
}
.onAppear {
withAnimation(.easeInOut(duration: 1.25).repeatForever(autoreverses: true)) {
pulse = true
glow = true
}
}
} else {
ProgressView()
.tint(.white)
.scaleEffect(1.2)
}
Text(status)
.font(McFonts.caption)
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
}
}
}
}

View file

@ -32,9 +32,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private let drawableHost = VLCPipDrawableHost() private let drawableHost = VLCPipDrawableHost()
/// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy). /// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy).
private weak var plainVideoView: UIView? private weak var plainVideoView: UIView?
/// Sterke ref zolang er gespeeld wordt — voorkomt crash als SwiftUI de surface al freeggeeft.
private var retainedSurface: UIView?
private var lengthMs: Int32 = 0 private var lengthMs: Int32 = 0
private var tick: AnyCancellable? private var tick: AnyCancellable?
private var clearKeyProxyActive = false private var clearKeyProxyActive = false
private var clearKeyProxySession = 0
private var liveMode = false private var liveMode = false
private var livePlayURL: URL? private var livePlayURL: URL?
private var livePlayFormat: String? private var livePlayFormat: String?
@ -45,9 +48,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var sceneObserver: NSObjectProtocol? private var sceneObserver: NSObjectProtocol?
/// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld). /// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld).
private weak var attachedView: UIView? private weak var attachedView: UIView?
/// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start. /// VOD: altijd PiP-capable drawable (officieel VLCKit-patroon). Live/ClearKey: plain UIView.
private var usePipDrawable = false private var usePipDrawable = false
private var stopping = false private var stopping = false
private var stopGeneration = 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).
@ -64,46 +68,110 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if let sceneObserver { if let sceneObserver {
NotificationCenter.default.removeObserver(sceneObserver) NotificationCenter.default.removeObserver(sceneObserver)
} }
mediaPlayer.drawable = nil
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.drawable = nil
if clearKeyProxyActive { if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop() ClearKeyDashProxy.shared.stop(session: clearKeyProxySession)
} }
} }
func attach(to view: UIView) { func attach(to view: UIView) {
if attachedView === view { return } guard !stopping else { return }
attachedView = view attachedView = view
plainVideoView = view plainVideoView = view
retainedSurface = view
view.backgroundColor = .black view.backgroundColor = .black
applyDrawable()
// PiP-restore: container mag wisselen zonder mediaPlayer.drawable te resetten.
if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost {
drawableHost.attach(to: view)
return
}
if mediaPlayer.drawable as AnyObject? === view {
return
}
// Nooit mid-play herbinden als er al een geldige drawable hangt.
if mediaPlayer.drawable != nil, mediaPlayer.media != nil {
if usePipDrawable {
drawableHost.attach(to: view)
}
return
}
applyDrawable(force: mediaPlayer.drawable == nil)
} }
/// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable. /// Layout-callback: bind alleen als size OK; sta herstel toe als eerder op 0×0 gebonden.
private func applyDrawable() { func surfaceDidLayout(_ view: UIView) {
guard let view = plainVideoView else { return } guard !stopping else { return }
let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive plainVideoView = view
if wantPip { attachedView = view
retainedSurface = view
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost {
drawableHost.attach(to: view) drawableHost.attach(to: view)
if mediaPlayer.drawable as AnyObject? !== drawableHost { return
}
if mediaPlayer.drawable == nil {
applyDrawable(force: true)
}
}
/// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start).
private func applyDrawable(force: Bool = false) {
guard !stopping else { return }
guard let view = plainVideoView else { return }
// Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only).
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
let wantPipHost = !liveMode && !clearKeyProxyActive
usePipDrawable = wantPipHost
if wantPipHost {
drawableHost.attach(to: view)
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
mediaPlayer.drawable = drawableHost mediaPlayer.drawable = drawableHost
} }
if pipWindow != nil {
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
}
} else { } else {
// ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play.
if mediaPlayer.drawable as AnyObject? === view, !force { return }
if mediaPlayer.media != nil, mediaPlayer.drawable != nil, !force { return }
drawableHost.detachVideo() drawableHost.detachVideo()
if mediaPlayer.drawable as AnyObject? !== view {
mediaPlayer.drawable = view mediaPlayer.drawable = view
}
if liveMode || clearKeyProxyActive {
pipPossible = false pipPossible = false
} }
} }
}
func startPictureInPicture() { func startPictureInPicture() {
preparePipDrawableThen { guard !liveMode, !clearKeyProxyActive, !stopping else { return }
self.pipWindow?.startPictureInPicture() // FreeType-subs zitten niet in VLCKit sample-buffer PiP (Apple/VLC-beperking).
if currentSubtitleId != PlayerTrack.offId {
stateText = "PiP zonder ondertiteling"
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
guard let self, !self.stopping, self.stateText == "PiP zonder ondertiteling" else { return }
self.stateText = self.isPlaying ? "Afspelen" : self.stateText
} }
} }
if let pipWindow {
pipWindow.startPictureInPicture()
return
}
applyDrawable(force: true)
let gen = stopGeneration
var attempts = 0
func tryStart() {
guard self.stopGeneration == gen, !self.stopping else { return }
guard !self.liveMode, !self.clearKeyProxyActive else { return }
attempts += 1
if let win = self.pipWindow {
win.startPictureInPicture()
} else if attempts < 20 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: tryStart)
}
func stopPictureInPicture() { func stopPictureInPicture() {
pipWindow?.stopPictureInPicture() pipWindow?.stopPictureInPicture()
@ -114,34 +182,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
else { startPictureInPicture() } else { startPictureInPicture() }
} }
private func preparePipDrawableThen(_ work: @escaping () -> Void) {
if usePipDrawable, pipWindow != nil {
work()
return
}
usePipDrawable = true
applyDrawable()
// pictureInPictureReady komt async; kort wachten op pipWindow.
var attempts = 0
func tryStart() {
attempts += 1
if self.pipWindow != nil || attempts >= 12 {
work()
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart)
}
private func restorePlainDrawableAfterPip() {
guard !liveMode, !clearKeyProxyActive, !stopping else { return }
usePipDrawable = false
applyDrawable()
applySubtitleStyle()
}
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
// Annuleer pending finishStop van eerdere/spurious onDisappear.
stopGeneration &+= 1
stopping = false
hasError = false hasError = false
errorMessage = nil errorMessage = nil
stateText = "Verbinden…" stateText = "Verbinden…"
@ -149,9 +193,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
audioTracks = [] audioTracks = []
liveMode = false liveMode = false
clearKeyProxyActive = false clearKeyProxyActive = false
usePipDrawable = false plainVideoView?.layoutIfNeeded()
applyDrawable() applyDrawable(force: true)
// PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken).
pipPossible = AVPictureInPictureController.isPictureInPictureSupported() pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
guard let media = VLCMedia(url: url) else { guard let media = VLCMedia(url: url) else {
hasError = true hasError = true
@ -160,35 +203,38 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return return
} }
media.delegate = self media.delegate = self
media.addOption(":network-caching=2500") media.addOption(":network-caching=2000")
media.addOption(":http-reconnect") media.addOption(":http-reconnect")
media.addOption(":avcodec-hw=any") media.addOption(":avcodec-hw=any")
media.addOption(":sout-mux-caching=1500") media.addOption(":sout-mux-caching=1200")
media.addOption(":sub-track=0") media.addOption(":sub-track=0")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1") media.addOption(":freetype-bold=1")
media.addOption(":sub-margin=0")
mediaPlayer.media = media mediaPlayer.media = media
// VLCKit 4: parse is on VLCMediaParser, not VLCMedia
_ = VLCMediaParser.shared().queue( _ = VLCMediaParser.shared().queue(
media, media,
options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) // network + fetchLocal options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08)
) )
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle() applySubtitleStyle()
if startMs > 2000 { if startMs > 2000 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.seek(toMs: startMs) guard let self, !self.stopping else { return }
self.seek(toMs: startMs)
} }
} }
if let subtitleFileURL { if let subtitleFileURL {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
self?.addExternalSubtitle(url: subtitleFileURL) guard let self, !self.stopping else { return }
self.addExternalSubtitle(url: subtitleFileURL)
} }
} }
startTicker() startTicker()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.refreshTracks() guard let self, !self.stopping else { return }
self?.applySubtitleStyle() self.refreshTracks()
self.applySubtitleStyle()
} }
} }
@ -196,8 +242,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
url: URL, url: URL,
fallback: URL? = nil, fallback: URL? = nil,
format: String? = nil, format: String? = nil,
clearKeys: [ClearKeyEntry] = [] clearKeys: [ClearKeyEntry] = [],
preferHls: Bool = false
) async { ) async {
// Annuleer pending finishStop / delayed proxy-stop van vorige sessie.
stopGeneration &+= 1
let playGen = stopGeneration
stopping = false
hasError = false hasError = false
errorMessage = nil errorMessage = nil
stateText = "Live laden…" stateText = "Live laden…"
@ -206,19 +257,29 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveStartedAt = Date() liveStartedAt = Date()
liveMode = true liveMode = true
usePipDrawable = false usePipDrawable = false
pipPossible = false
var playURL = url var playURL = url
var playFormat = format var playFormat = format
clearKeyProxyActive = false clearKeyProxyActive = false
var proxySession = 0
if !clearKeys.isEmpty { if !clearKeys.isEmpty {
do { do {
stateText = "ClearKey laden…" stateText = "ClearKey laden…"
// Async: blokkeer de main thread niet (watchdog-crash). let started = try await ClearKeyDashProxy.shared.start(
// HLS (ESPN) én DASH (Prime) via dezelfde proxy. manifestURL: url,
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) keys: clearKeys,
preferHlsForAVPlayer: preferHls
)
playURL = started.url playURL = started.url
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash" proxySession = started.session
// Lokale clear-HLS of remote HLS.
if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) {
playFormat = "hls"
} else {
playFormat = "dash"
}
clearKeyProxyActive = true clearKeyProxyActive = true
} catch { } catch {
hasError = true hasError = true
@ -228,21 +289,48 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
} }
applyDrawable() guard stopGeneration == playGen, !stopping else { return }
await waitForSurface(timeout: 2.0)
await MainActor.run {
plainVideoView?.layoutIfNeeded()
applyDrawable(force: true)
}
guard stopGeneration == playGen, !stopping else { return }
livePlayURL = playURL livePlayURL = playURL
livePlayFormat = playFormat livePlayFormat = playFormat
clearKeyProxySession = proxySession
startLiveMedia(url: playURL, format: playFormat) startLiveMedia(url: playURL, format: playFormat)
if let fallback, clearKeys.isEmpty { if let fallback, clearKeys.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in let gen = stopGeneration
guard let self, !self.isPlaying || self.hasError else { return } // Snellere fallback: IPTV-HLS faalt vaak stil; probeer binnen 3.5s de fallback.
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
guard let self, self.stopGeneration == gen, !self.stopping else { return }
guard !self.everPlayed else { return }
guard !self.isPlaying || self.hasError else { return }
self.stateText = "Fallback…"
self.startLiveMedia(url: fallback, format: nil) self.startLiveMedia(url: fallback, format: nil)
} }
} }
startTicker() startTicker()
} }
private func waitForSurface(timeout: TimeInterval) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let ready = await MainActor.run { () -> Bool in
guard let view = plainVideoView else { return false }
return view.bounds.width > 1 && view.bounds.height > 1
}
if ready { return }
try? await Task.sleep(nanoseconds: 40_000_000)
}
}
private func startLiveMedia(url: URL, format: String?) { private func startLiveMedia(url: URL, format: String?) {
guard !stopping else { return }
guard let media = VLCMedia(url: url) else { guard let media = VLCMedia(url: url) else {
hasError = true hasError = true
errorMessage = "Kon live-stream niet openen." errorMessage = "Kon live-stream niet openen."
@ -250,10 +338,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
return return
} }
media.delegate = self media.delegate = self
Self.applyLiveOptions(to: media, url: url, format: format) Self.applyLiveOptions(to: media, url: url, format: format, viaLocalProxy: clearKeyProxyActive)
// Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash).
if mediaPlayer.drawable == nil {
applyDrawable(force: true)
}
mediaPlayer.media = media mediaPlayer.media = media
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle()
} }
private func dutchClearKeyError(_ error: Error) -> String { private func dutchClearKeyError(_ error: Error) -> String {
@ -274,16 +365,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
private func retryLiveIfNeeded() { private func retryLiveIfNeeded() {
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return } guard liveMode, !everPlayed, liveRetryCount < 2 else { return }
guard let url = livePlayURL else { return } guard let url = livePlayURL else { return }
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date()) let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
guard elapsed < 12 else { return } guard elapsed < 14 else { return }
liveRetryCount += 1 liveRetryCount += 1
stateText = "Opnieuw verbinden…" stateText = "Opnieuw verbinden…"
hasError = false hasError = false
errorMessage = nil errorMessage = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self, self.clearKeyProxyActive else { return } guard let self, self.liveMode, !self.stopping else { return }
self.startLiveMedia(url: url, format: self.livePlayFormat) self.startLiveMedia(url: url, format: self.livePlayFormat)
} }
} }
@ -291,16 +382,27 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private static func applyLiveOptions( private static func applyLiveOptions(
to media: VLCMedia, to media: VLCMedia,
url: URL, url: URL,
format: String? format: String?,
viaLocalProxy: Bool
) { ) {
media.addOption(":network-caching=1800") // Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen.
if viaLocalProxy {
media.addOption(":network-caching=1200")
media.addOption(":live-caching=1000")
} else {
media.addOption(":network-caching=1000")
media.addOption(":live-caching=800")
}
media.addOption(":http-reconnect") media.addOption(":http-reconnect")
media.addOption(":live-caching=1500")
media.addOption(":avcodec-hw=any") media.addOption(":avcodec-hw=any")
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
// IPTV/CDN's weigeren soms exotic UA's.
media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21")
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module). // VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
media.addOption(":adaptive-logic=highest") media.addOption(":adaptive-logic=highest")
if !viaLocalProxy {
media.addOption(":adaptive-use-access") media.addOption(":adaptive-use-access")
}
let fmt = (format ?? "").lowercased() let fmt = (format ?? "").lowercased()
let path = url.absoluteString.lowercased() let path = url.absoluteString.lowercased()
@ -356,13 +458,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if pipActive { if pipActive {
stopPictureInPicture() stopPictureInPicture()
} }
if usePipDrawable {
usePipDrawable = false
applyDrawable()
}
_ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self else { return } guard let self, !self.stopping else { return }
self.refreshTracks() self.refreshTracks()
self.applySubtitleStyle() self.applySubtitleStyle()
if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) { if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) {
@ -383,7 +481,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
func refreshTracks() { func refreshTracks() {
// VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief. // VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief.
guard mediaPlayer.media != nil else { guard !stopping, mediaPlayer.media != nil else {
subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
audioTracks = [] audioTracks = []
return return
@ -540,49 +638,81 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
func stop() { func stop() {
guard !stopping else { return } guard !stopping else { return }
stopping = true stopping = true
stopGeneration &+= 1
let gen = stopGeneration
let proxySession = clearKeyProxySession
tick?.cancel() tick?.cancel()
tick = nil tick = nil
let proxyWasActive = clearKeyProxyActive let proxyWasActive = clearKeyProxyActive
let wasPip = pipActive
clearKeyProxyActive = false clearKeyProxyActive = false
clearKeyProxySession = 0
// Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft.
mediaPlayer.drawable = nil
drawableHost.detachVideo()
attachedView = nil
plainVideoView = nil
if pipActive {
pipWindow?.stopPictureInPicture()
}
pipWindow = nil
pipPossible = false pipPossible = false
// PiP eerst netjes stoppen en wachten — sync nil + media stop = EXC_BAD_ACCESS (SwiftVLC #20).
if wasPip {
pipWindow?.stopPictureInPicture()
pipActive = false pipActive = false
usePipDrawable = false finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.85)
return
}
finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.05)
}
mediaPlayer.stop() private func finishStop(gen: Int, proxyWasActive: Bool, proxySession: Int, delay: TimeInterval) {
mediaPlayer.media = nil let work = { [weak self] in
livePlayURL = nil guard let self else { return }
livePlayFormat = nil // Nieuwe play() heeft generation gebumpt of stopping gereset → niet meer tear-down'en.
liveStartedAt = nil guard self.stopGeneration == gen, self.stopping else { return }
liveRetryCount = 0
everPlayed = false
liveMode = false
if proxyWasActive { self.pipWindow = nil
// Laat in-flight HTTP van VLC even afronden vóór listener-cancel. self.pipActive = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.usePipDrawable = false
ClearKeyDashProxy.shared.stop()
self.mediaPlayer.stop()
self.mediaPlayer.media = nil
self.mediaPlayer.drawable = nil
self.drawableHost.detachVideo()
self.attachedView = nil
self.plainVideoView = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self, self.stopGeneration == gen else { return }
self.retainedSurface = nil
}
self.livePlayURL = nil
self.livePlayFormat = nil
self.liveStartedAt = nil
self.liveRetryCount = 0
self.everPlayed = false
self.liveMode = false
self.isPlaying = false
self.hasError = false
self.errorMessage = nil
self.stateText = "Gestopt"
self.subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")]
self.audioTracks = []
if proxyWasActive, proxySession > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
ClearKeyDashProxy.shared.stop(session: proxySession)
} }
} }
stopping = false }
if delay > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
} else {
work()
}
} }
// MARK: - VLCMediaPlayerDelegate (VLCKit 4) // MARK: - VLCMediaPlayerDelegate (VLCKit 4)
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self, !self.stopping else { return }
self.isPlaying = self.mediaPlayer.isPlaying self.isPlaying = self.mediaPlayer.isPlaying
if self.pipPossible { if self.pipPossible {
self.invalidatePipPlaybackState() self.invalidatePipPlaybackState()
@ -594,49 +724,65 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
self.hasError = false self.hasError = false
self.errorMessage = nil self.errorMessage = nil
self.everPlayed = true self.everPlayed = true
// Geen drawable-rebind hier — dat maakte ClearKey audio-only + crash.
if !self.liveMode {
self.refreshTracks() self.refreshTracks()
self.applySubtitleStyle() self.applySubtitleStyle()
}
case .paused: self.stateText = "Pauze" case .paused: self.stateText = "Pauze"
case .stopped, .stopping: case .stopped, .stopping:
if self.clearKeyProxyActive && !self.everPlayed { if self.liveMode && !self.everPlayed {
self.stateText = "Verbinden…" self.stateText = "Verbinden…"
self.retryLiveIfNeeded() self.retryLiveIfNeeded()
} else if !self.hasError { } else if !self.hasError {
self.stateText = "Gestopt" self.stateText = "Gestopt"
} }
case .error: case .error:
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 { if self.liveMode && !self.everPlayed && self.liveRetryCount < 2 {
self.stateText = "Opnieuw…" self.stateText = "Opnieuw…"
self.retryLiveIfNeeded() self.retryLiveIfNeeded()
} else { } else {
self.hasError = true self.hasError = true
self.errorMessage = self.clearKeyProxyActive self.errorMessage = self.clearKeyProxyActive
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw." ? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
: "Kon dit bestand niet afspelen" : (self.liveMode
? "Kon deze livestream niet openen."
: "Kon dit bestand niet afspelen")
self.stateText = "Fout" self.stateText = "Fout"
} }
@unknown default: break case .nothingSpecial:
break
@unknown default:
break
} }
} }
} }
func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) { func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) {
DispatchQueue.main.async { [weak self] in self?.refreshTracks() } DispatchQueue.main.async { [weak self] in
guard let self, !self.stopping, !self.liveMode else { return }
self.refreshTracks()
}
} }
func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) { func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) {
DispatchQueue.main.async { [weak self] in self?.refreshTracks() } DispatchQueue.main.async { [weak self] in
guard let self, !self.stopping, !self.liveMode else { return }
self.refreshTracks()
}
} }
func mediaPlayerLengthChanged(_ length: Int64) { func mediaPlayerLengthChanged(_ length: Int64) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.invalidatePipPlaybackState() guard let self, !self.stopping else { return }
self.invalidatePipPlaybackState()
} }
} }
func mediaDidFinishParsing(_ aMedia: VLCMedia) { func mediaDidFinishParsing(_ aMedia: VLCMedia) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.refreshTracks() guard let self, !self.stopping, !self.liveMode else { return }
self.refreshTracks()
} }
} }
@ -650,6 +796,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
private func refreshTime() { private func refreshTime() {
guard !stopping else { return }
let t = mediaPlayer.time.intValue let t = mediaPlayer.time.intValue
let l = mediaPlayer.media?.length.intValue ?? 0 let l = mediaPlayer.media?.length.intValue ?? 0
if l > 0 { lengthMs = l } if l > 0 { lengthMs = l }
@ -671,14 +818,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
// MARK: - PiP / audio // MARK: - PiP / audio
fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) { fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) {
guard !stopping, !liveMode, !clearKeyProxyActive else { return }
pipWindow = controller pipWindow = controller
pipPossible = true pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
controller.stateChangeEventHandler = { [weak self] isStarted in controller.stateChangeEventHandler = { [weak self] isStarted in
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self else { return } guard let self else { return }
if self.stopping {
self.pipActive = false
return
}
let wasActive = self.pipActive
self.pipActive = isStarted self.pipActive = isStarted
if !isStarted { if wasActive && !isStarted {
self.restorePlainDrawableAfterPip() // PiP-X gesloten: herkoppel video-subview aan inline surface (geen drawable-reset).
if let view = self.plainVideoView ?? self.retainedSurface {
self.drawableHost.attach(to: view)
}
// Opnieuw FreeType/subs activeren na terugkeer uit PiP.
if self.currentSubtitleId != PlayerTrack.offId {
self.selectSubtitle(id: self.currentSubtitleId)
}
self.applySubtitleStyle()
self.invalidatePipPlaybackState()
}
if !wasActive && isStarted {
// Net PiP gestart: forceer spu opnieuw (sample-buffer kan spu laten vallen).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
guard let self, self.pipActive, !self.stopping else { return }
if self.currentSubtitleId != PlayerTrack.offId {
self.selectSubtitle(id: self.currentSubtitleId)
}
self.applySubtitleStyle()
}
} }
} }
} }
@ -733,6 +905,11 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
private weak var container: UIView? private weak var container: UIView?
private var videoSubview: UIView? private var videoSubview: UIView?
var hasVideoSubview: Bool {
guard let videoSubview else { return false }
return videoSubview.superview != nil && videoSubview.bounds.width > 1
}
func attach(to view: UIView) { func attach(to view: UIView) {
container = view container = view
view.backgroundColor = .black view.backgroundColor = .black
@ -810,17 +987,35 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab
} }
} }
/// UIKit surface met layout-callback — voorkomt zero-bounds drawable (audio-only).
final class VLCSurfaceView: UIView {
var onLayout: ((UIView) -> Void)?
override func layoutSubviews() {
super.layoutSubviews()
onLayout?(self)
}
}
struct VLCVideoView: UIViewRepresentable { struct VLCVideoView: UIViewRepresentable {
let controller: VLCPlayerController let controller: VLCPlayerController
func makeUIView(context: Context) -> UIView { func makeUIView(context: Context) -> VLCSurfaceView {
let view = UIView() let view = VLCSurfaceView()
view.backgroundColor = .black view.backgroundColor = .black
view.clipsToBounds = true
view.onLayout = { [weak controller] surface in
controller?.surfaceDidLayout(surface)
}
controller.attach(to: view) controller.attach(to: view)
return view return view
} }
func updateUIView(_ uiView: UIView, context: Context) { func updateUIView(_ uiView: VLCSurfaceView, context: Context) {
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only). uiView.onLayout = { [weak controller] surface in
controller?.surfaceDidLayout(surface)
}
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
controller.attach(to: uiView)
} }
} }

View file

@ -93,7 +93,8 @@ struct MovieDetailScreen: View {
movieId: movie.id, movieId: movie.id,
episodeId: nil, episodeId: nil,
seriesId: nil, seriesId: nil,
startMs: 0 startMs: 0,
logoUrl: movie.logoUrl
) )
.environmentObject(app) .environmentObject(app)
} }
@ -227,7 +228,8 @@ struct SeriesDetailScreen: View {
movieId: nil, movieId: nil,
episodeId: target.episodeId, episodeId: target.episodeId,
seriesId: seriesId, seriesId: seriesId,
startMs: target.startMs startMs: target.startMs,
logoUrl: series.logoUrl
) )
.environmentObject(app) .environmentObject(app)
} }

View file

@ -612,7 +612,8 @@ struct EventDetailScreen: View {
drm: item.info.drm, drm: item.info.drm,
watchSessionId: item.info.watchSessionId, watchSessionId: item.info.watchSessionId,
watchKind: "event", watchKind: "event",
watchId: eventId watchId: eventId,
logoUrl: item.info.logoUrl ?? event?.logoUrl
)) ))
.environmentObject(app) .environmentObject(app)
} }

View file

@ -134,7 +134,8 @@ struct LiveTvScreen: View {
drm: item.info.drm, drm: item.info.drm,
watchSessionId: item.info.watchSessionId, watchSessionId: item.info.watchSessionId,
watchKind: kind, watchKind: kind,
watchId: item.info.streamId watchId: item.info.streamId,
logoUrl: item.info.logoUrl
)) ))
.environmentObject(app) .environmentObject(app)
} }

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.6") LabeledContent("Versie", value: "0.5.41")
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")
} }