Enable iOS ClearKey DASH playback via local CENC decrypt proxy.

MobileVLCKit cannot decrypt ClearKey; the app now decrypts segments in-process and feeds plain DASH to VLC, removing the coming-soon DRM dead-end (0.5.1/13).
This commit is contained in:
Jos Vooges | STH 2026-09-19 06:37:11 +02:00
parent 3e2549c33a
commit fac38d905e
9 changed files with 1221 additions and 45 deletions

View file

@ -42,10 +42,10 @@ open VMCTV.xcodeproj
`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren.
## Live TV / Events / ClearKey (0.5.x)
## Live TV / Events / ClearKey (0.5.1+)
- `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`).
- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId`.
- Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events.
- **ClearKey/DASH:** best-effort via VLC media options (`:demux=dash`, `:cenc-key=…`). MobileVLCKit heeft geen officiële ClearKey-API zoals ExoPlayer; Xtream HLS/TS speelt normaal. Als decryptie faalt toont de app: *“Deze stream vereist DRM-ondersteuning die binnenkort komt”*.
- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op, decrypt CENC/CBCS met de API-keys (zelfde hex kid/key + GUID-endianness als Android), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy.
- Events-tab verschijnt als `/me` → `events.enabled` true is.

View file

@ -16,6 +16,10 @@
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */; };
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED163B837C404A1183E20226 /* VLCPlayerController.swift */; };
15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */; };
A11C1EAF0000000000000001 /* ClearKeyMaterial.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */; };
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; };
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
@ -47,6 +51,10 @@
8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = "<group>"; };
ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = "<group>"; };
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyMaterial.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
@ -151,6 +159,10 @@
children = (
ED163B837C404A1183E20226 /* VLCPlayerController.swift */,
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */,
B11C1EAF0000000000000001 /* ClearKeyMaterial.swift */,
B11C1EAF0000000000000002 /* AesCtr.swift */,
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
);
path = Playback;
sourceTree = "<group>";
@ -258,6 +270,10 @@
A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */,
424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */,
15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */,
A11C1EAF0000000000000001 /* ClearKeyMaterial.swift in Sources */,
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */,
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */,
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */,
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */,
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */,
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */,
@ -321,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -336,7 +352,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.0;
MARKETING_VERSION = 0.5.1;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -354,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -369,7 +385,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.0;
MARKETING_VERSION = 0.5.1;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -0,0 +1,120 @@
import CommonCrypto
import Foundation
enum AesCtr {
/// AES-128-CTR (big-endian counter), zoals CENC `cenc` scheme.
static func crypt(key: Data, iv: Data, data: Data) -> Data? {
guard key.count == 16, data.count > 0 else {
return data.isEmpty ? data : nil
}
var counter = Data(count: 16)
let copyLen = min(iv.count, 16)
counter.replaceSubrange(0..<copyLen, with: iv.prefix(copyLen))
var cryptor: CCCryptorRef?
let status = counter.withUnsafeBytes { ivBytes in
key.withUnsafeBytes { keyBytes in
CCCryptorCreateWithMode(
CCOperation(kCCEncrypt),
CCMode(kCCModeCTR),
CCAlgorithm(kCCAlgorithmAES),
CCPadding(ccNoPadding),
ivBytes.baseAddress,
keyBytes.baseAddress,
key.count,
nil,
0,
0,
CCModeOptions(kCCModeOptionCTR_BE),
&cryptor
)
}
}
guard status == kCCSuccess, let cryptor else { return nil }
defer { CCCryptorRelease(cryptor) }
var out = Data(count: data.count)
var moved = 0
let cryptStatus = out.withUnsafeMutableBytes { outBytes in
data.withUnsafeBytes { inBytes in
CCCryptorUpdate(
cryptor,
inBytes.baseAddress,
data.count,
outBytes.baseAddress,
data.count,
&moved
)
}
}
guard cryptStatus == kCCSuccess, moved == data.count else { return nil }
return out
}
/// CBCS pattern: decrypt `crypt` 16-byte blocks, skip `skip` blocks; leftover &lt;16 clear.
static func cryptCbcs(
key: Data,
iv: Data,
data: Data,
cryptByteBlock: Int,
skipByteBlock: Int
) -> Data? {
guard key.count == 16 else { return nil }
if cryptByteBlock <= 0 {
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 remaining = result.count - offset
if remaining < cryptLen { break }
let slice = result.subdata(in: offset..<(offset + cryptLen))
guard let plain = aesCbcDecryptNoPad(key: key, iv: blockIv, data: slice) else { return nil }
result.replaceSubrange(offset..<(offset + cryptLen), with: plain)
// Next IV = last ciphertext block (CBC chain within crypt run).
blockIv = slice.suffix(16)
offset += cryptLen
let skip = min(skipLen, result.count - offset)
offset += skip
// Pattern: IV resets per subsample in caller; within subsample after skip, continue with updated IV.
}
return result
}
private static func aesCbcDecryptNoPad(key: Data, iv: Data, data: Data) -> Data? {
guard data.count % 16 == 0, data.count > 0 else { return data.isEmpty ? data : nil }
var out = Data(count: data.count)
var moved = 0
var ivMut = iv
if ivMut.count < 16 { ivMut.append(Data(count: 16 - ivMut.count)) }
let status = out.withUnsafeMutableBytes { outBytes in
data.withUnsafeBytes { inBytes in
key.withUnsafeBytes { keyBytes in
ivMut.withUnsafeMutableBytes { ivBytes in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(0),
keyBytes.baseAddress,
key.count,
ivBytes.baseAddress,
inBytes.baseAddress,
data.count,
outBytes.baseAddress,
data.count,
&moved
)
}
}
}
}
guard status == kCCSuccess, moved == data.count else { return nil }
return out
}
}

View file

@ -0,0 +1,635 @@
import Foundation
/// In-place CENC/CBCS decryptie van gefragmenteerde MP4 (init + media segments).
enum CencMp4Decryptor {
struct TrackCrypto {
var kid: Data
var perSampleIvSize: Int
var constantIv: Data?
var scheme: String // cenc | cbcs | cbc1 | cens
var cryptByteBlock: Int
var skipByteBlock: Int
}
struct SessionState {
var byTrackId: [UInt32: TrackCrypto] = [:]
var fallback: TrackCrypto?
}
@discardableResult
static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool {
guard !data.isEmpty, !keys.isEmpty else { return false }
var touched = false
if containsBox(data, type: "moov") {
harvestAndClearInit(&data, keys: keys, state: &state)
touched = true
}
if containsBox(data, type: "moof") {
decryptFragments(&data, keys: keys, state: state)
touched = true
}
return touched
}
// MARK: - Box helpers
private struct Box {
let type: String
let start: Int
let headerSize: Int
let size: Int
var payloadStart: Int { start + headerSize }
var payloadEnd: Int { start + size }
var payloadLength: Int { size - headerSize }
}
private static func containsBox(_ data: Data, type: String) -> Bool {
findBoxes(data, type: type, recursive: true).isEmpty == false
}
private static func findBoxes(_ data: Data, type: String, recursive: Bool) -> [Box] {
var out: [Box] = []
walk(data, from: 0, to: data.count, recursive: recursive) { box in
if box.type == type { out.append(box) }
return true
}
return out
}
private static func walk(
_ data: Data,
from: Int,
to: Int,
recursive: Bool,
visit: (Box) -> Bool
) {
var offset = from
let containers: Set<String> = [
"moov", "trak", "mdia", "minf", "stbl", "moof", "traf",
"mvex", "edts", "sinf", "schi", "dinf", "udta", "meta",
]
while offset + 8 <= to {
guard let box = readBox(data, at: offset, limit: to) else { break }
let cont = visit(box)
if !cont { return }
if recursive, containers.contains(box.type), box.payloadLength > 0 {
walk(data, from: box.payloadStart, to: box.payloadEnd, recursive: true, visit: visit)
}
offset = box.payloadEnd
if box.size <= 0 { break }
}
}
private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? {
guard offset + 8 <= limit else { return nil }
var size = Int(readU32(data, offset))
let type = readFourCC(data, offset + 4)
var header = 8
if size == 1 {
guard offset + 16 <= limit else { return nil }
let large = readU64(data, offset + 8)
size = Int(large)
header = 16
} else if size == 0 {
size = limit - offset
}
guard size >= header, offset + size <= limit else { return nil }
return Box(type: type, start: offset, headerSize: header, size: size)
}
private static func readU16(_ data: Data, _ o: Int) -> UInt16 {
UInt16(data[o]) << 8 | UInt16(data[o + 1])
}
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])
}
private static func readU64(_ data: Data, _ o: Int) -> UInt64 {
(UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4))
}
private static func readFourCC(_ data: Data, _ o: Int) -> String {
String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????"
}
private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) {
let chars = Array(s.utf8)
guard chars.count == 4, o + 4 <= data.count else { return }
for i in 0..<4 { data[o + i] = chars[i] }
}
// MARK: - Init segment
private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) {
// Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak.
for trak in findBoxes(data, type: "trak", recursive: true) {
var trackId: UInt32?
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
if box.type == "tkhd", box.payloadLength >= 8 {
let version = Int(data[box.payloadStart])
let idOff = box.payloadStart + (version == 1 ? 20 : 12)
if idOff + 4 <= box.payloadEnd {
trackId = readU32(data, idOff)
}
}
return true
}
var crypto: TrackCrypto?
var originalFormat: String?
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
if box.type == "frma", box.payloadLength >= 4 {
originalFormat = readFourCC(data, box.payloadStart)
}
if box.type == "schm", box.payloadLength >= 8 {
// version/flags (4) + scheme_type (4)
let scheme = readFourCC(data, box.payloadStart + 4)
if crypto == nil {
crypto = TrackCrypto(
kid: Data(count: 16),
perSampleIvSize: 8,
constantIv: nil,
scheme: scheme,
cryptByteBlock: 0,
skipByteBlock: 0
)
} else {
crypto?.scheme = scheme
}
}
if box.type == "tenc", box.payloadLength >= 20 {
let version = Int(data[box.payloadStart])
let base = box.payloadStart + 4 // na version+flags
var o = base
o += 1 // reserved
var cryptBlocks = 0
var skipBlocks = 0
if version >= 1 {
let pattern = data[o]
cryptBlocks = Int((pattern >> 4) & 0x0F)
skipBlocks = Int(pattern & 0x0F)
}
o += 1 // reserved (v0) of pattern (v1)
let isProtectedIndex = o
let isProtected = Int(data[o]); o += 1
let ivSize = Int(data[o]); o += 1
let kid = data.subdata(in: o..<(o + 16)); o += 16
_ = isProtected
var constantIv: Data?
if ivSize == 0, o < box.payloadEnd {
let constSize = Int(data[o]); o += 1
if constSize > 0, o + constSize <= box.payloadEnd {
constantIv = data.subdata(in: o..<(o + constSize))
}
}
crypto = TrackCrypto(
kid: kid,
perSampleIvSize: ivSize,
constantIv: constantIv,
scheme: crypto?.scheme ?? "cenc",
cryptByteBlock: cryptBlocks,
skipByteBlock: skipBlocks
)
data[isProtectedIndex] = 0
}
return true
}
// Replace encv/enca with original format (same 4 bytes → no size rewrite).
if let originalFormat {
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
// Sample entries live in stsd; type is at box.type for encv/enca boxes themselves.
return true
}
// Find encv/enca boxes by scanning stsd children manually.
for stsd in findBoxes(data, type: "stsd", recursive: true) {
guard stsd.start >= trak.start, stsd.payloadEnd <= trak.payloadEnd else { continue }
var o = stsd.payloadStart + 4 // version/flags + entry_count follows version/flags (4) then count (4)
// FullBox (4) + entry_count (4)
o = stsd.payloadStart + 8
while o + 8 <= stsd.payloadEnd {
guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break }
if entry.type == "encv" || entry.type == "enca" {
writeFourCC(&data, entry.start + 4, originalFormat)
}
o = entry.payloadEnd
}
}
}
if let crypto {
if let trackId {
state.byTrackId[trackId] = crypto
}
state.fallback = crypto
_ = keys.key(forKid: crypto.kid)
}
}
}
// MARK: - Media fragments
private static func decryptFragments(_ data: inout Data, keys: ClearKeyKeyMap, state: SessionState) {
// Pair each moof with the following mdat.
var offset = 0
while offset + 8 <= data.count {
guard let box = readBox(data, at: offset, limit: data.count) else { break }
if box.type == "moof" {
let moof = box
var mdat: Box?
var scan = moof.payloadEnd
while scan + 8 <= data.count {
guard let next = readBox(data, at: scan, limit: data.count) else { break }
if next.type == "mdat" {
mdat = next
break
}
if next.type == "moof" { break }
scan = next.payloadEnd
}
if let mdat {
decryptMoofMdat(moof: moof, mdat: mdat, data: &data, keys: keys, state: state)
}
}
offset = box.payloadEnd
}
}
private static func decryptMoofMdat(
moof: Box,
mdat: Box,
data: inout Data,
keys: ClearKeyKeyMap,
state: SessionState
) {
let trafs = findBoxesInRange(data, type: "traf", from: moof.payloadStart, to: moof.payloadEnd)
var mdatCursor = mdat.payloadStart
for traf in trafs {
var trackId: UInt32 = 0
var defaultSampleSize: UInt32 = 0
var baseDataOffset: Int64 = Int64(mdat.payloadStart)
var dataOffsetPresent = false
var sampleSizes: [Int] = []
var sencSamples: [SencSample] = []
var perSampleIvSize = state.fallback?.perSampleIvSize ?? 8
// tfhd
for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) {
let versionFlags = readU32(data, tfhd.payloadStart)
let flags = versionFlags & 0x00FF_FFFF
var o = tfhd.payloadStart + 4
trackId = readU32(data, o); o += 4
if let crypto = state.byTrackId[trackId] {
perSampleIvSize = crypto.perSampleIvSize
}
if flags & 0x000001 != 0 { // base-data-offset
baseDataOffset = Int64(readU64(data, o)); o += 8
dataOffsetPresent = true
}
if flags & 0x000002 != 0 { o += 4 } // sample-description-index
if flags & 0x000008 != 0 {
defaultSampleSize = readU32(data, o); o += 4
}
_ = versionFlags
}
// trun
for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) {
let versionFlags = readU32(data, trun.payloadStart)
let version = Int(versionFlags >> 24)
let flags = versionFlags & 0x00FF_FFFF
var o = trun.payloadStart + 4
let sampleCount = Int(readU32(data, o)); o += 4
var dataOffset: Int32 = 0
if flags & 0x000001 != 0 {
dataOffset = Int32(bitPattern: readU32(data, o)); o += 4
dataOffsetPresent = true
}
if flags & 0x000004 != 0 { o += 4 } // first-sample-flags
sampleSizes.removeAll(keepingCapacity: true)
for _ in 0..<sampleCount {
if flags & 0x000100 != 0 { // sample-duration
o += 4
}
var size = Int(defaultSampleSize)
if flags & 0x000200 != 0 {
size = Int(readU32(data, o)); o += 4
}
sampleSizes.append(size)
if flags & 0x000400 != 0 { o += 4 } // sample-flags
if flags & 0x000800 != 0 {
o += version == 0 ? 4 : 4 // sample-composition-time-offset
}
}
if dataOffsetPresent {
// data_offset is relative to moof start when base-data-offset-present is NOT set;
// when base-data-offset is set, absolute = base + dataOffset.
// Common CMAF: no base-data-offset, data_offset relative to moof.
let tfhd = findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd).first
let tfhdFlags: UInt32 = {
guard let tfhd else { return 0 }
return readU32(data, tfhd.payloadStart) & 0x00FF_FFFF
}()
if tfhdFlags & 0x000001 != 0 {
mdatCursor = Int(baseDataOffset + Int64(dataOffset))
} else {
mdatCursor = moof.start + Int(dataOffset)
}
}
_ = version
}
// senc
let crypto = state.byTrackId[trackId] ?? state.fallback
if let crypto {
perSampleIvSize = crypto.perSampleIvSize
}
for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) {
sencSamples = parseSenc(data, box: senc, perSampleIvSize: perSampleIvSize, constantIv: crypto?.constantIv)
}
// saiz/saio fallback when no senc
if sencSamples.isEmpty {
sencSamples = parseSaizSaio(
data,
traf: traf,
moof: moof,
perSampleIvSize: perSampleIvSize,
constantIv: crypto?.constantIv,
sampleCount: sampleSizes.count
)
}
guard !sampleSizes.isEmpty, sencSamples.count == sampleSizes.count || sencSamples.isEmpty == false else {
// Advance cursor even if we cannot decrypt.
for sz in sampleSizes { mdatCursor += sz }
continue
}
let key: Data? = {
if let crypto, let k = keys.key(forKid: crypto.kid) { return k }
return keys.primaryKey
}()
guard let key else {
for sz in sampleSizes { mdatCursor += sz }
continue
}
let scheme = (crypto?.scheme ?? "cenc").lowercased()
let cryptBlocks = crypto?.cryptByteBlock ?? 0
let skipBlocks = crypto?.skipByteBlock ?? 0
for i in 0..<sampleSizes.count {
let size = sampleSizes[i]
guard size > 0, mdatCursor + size <= data.count else { break }
let sampleRange = mdatCursor..<(mdatCursor + size)
var sample = data.subdata(in: sampleRange)
let senc = i < sencSamples.count ? sencSamples[i] : nil
if let senc {
if let decrypted = decryptSample(
sample,
key: key,
iv: senc.iv,
subsamples: senc.subsamples,
scheme: scheme,
cryptByteBlock: cryptBlocks,
skipByteBlock: skipBlocks
) {
sample = decrypted
data.replaceSubrange(sampleRange, with: sample)
}
}
mdatCursor += size
}
}
}
private struct SencSample {
var iv: Data
var subsamples: [(clear: Int, protected: Int)]?
}
private static func parseSenc(
_ data: Data,
box: Box,
perSampleIvSize: Int,
constantIv: Data?
) -> [SencSample] {
let versionFlags = readU32(data, box.payloadStart)
let flags = versionFlags & 0x00FF_FFFF
var o = box.payloadStart + 4
let sampleCount = Int(readU32(data, o)); o += 4
var out: [SencSample] = []
out.reserveCapacity(sampleCount)
let useSubsamples = (flags & 0x2) != 0
for _ in 0..<sampleCount {
var iv = Data(count: 16)
if perSampleIvSize > 0 {
let n = min(perSampleIvSize, 16)
guard o + n <= box.payloadEnd else { break }
iv.replaceSubrange(0..<n, with: data.subdata(in: o..<(o + n)))
o += n
} else if let constantIv {
let n = min(constantIv.count, 16)
iv.replaceSubrange(0..<n, with: constantIv.prefix(n))
}
var subs: [(clear: Int, protected: Int)]?
if useSubsamples {
guard o + 2 <= box.payloadEnd else { break }
let count = Int(readU16(data, o)); o += 2
var list: [(clear: Int, protected: Int)] = []
for _ in 0..<count {
guard o + 6 <= box.payloadEnd else { break }
let clear = Int(readU16(data, o)); o += 2
let protected = Int(readU32(data, o)); o += 4
list.append((clear, protected))
}
subs = list
}
out.append(SencSample(iv: iv, subsamples: subs))
}
return out
}
private static func parseSaizSaio(
_ data: Data,
traf: Box,
moof: Box,
perSampleIvSize: Int,
constantIv: Data?,
sampleCount: Int
) -> [SencSample] {
let saizBoxes = findBoxesInRange(data, type: "saiz", from: traf.payloadStart, to: traf.payloadEnd)
let saioBoxes = findBoxesInRange(data, type: "saio", from: traf.payloadStart, to: traf.payloadEnd)
guard let saiz = saizBoxes.first, let saio = saioBoxes.first else { return [] }
let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF
var o = saiz.payloadStart + 4
if saizFlags & 0x1 != 0 { o += 8 } // aux_info_type + parameter
let defaultSize = Int(data[o]); o += 1
let count = Int(readU32(data, o)); o += 4
var sizes: [Int] = []
if defaultSize == 0 {
for _ in 0..<count {
sizes.append(Int(data[o])); o += 1
}
} else {
sizes = Array(repeating: defaultSize, count: count)
}
let saioVersionFlags = readU32(data, saio.payloadStart)
let saioVersion = Int(saioVersionFlags >> 24)
let saioFlags = saioVersionFlags & 0x00FF_FFFF
var so = saio.payloadStart + 4
if saioFlags & 0x1 != 0 { so += 8 }
let entryCount = Int(readU32(data, so)); so += 4
guard entryCount >= 1 else { return [] }
let auxOffset: Int
if saioVersion == 0 {
auxOffset = Int(readU32(data, so))
} else {
auxOffset = Int(readU64(data, so))
}
// Offsets are usually relative to moof start (ISO) or absolute file offset.
var auxPos = auxOffset
if auxPos < moof.start || auxPos > data.count {
auxPos = moof.start + auxOffset
}
guard auxPos >= 0, auxPos < data.count else { return [] }
var out: [SencSample] = []
let n = min(sizes.count, sampleCount > 0 ? sampleCount : sizes.count)
for i in 0..<n {
let entrySize = sizes[i]
guard auxPos + entrySize <= data.count else { break }
var p = auxPos
var iv = Data(count: 16)
if perSampleIvSize > 0 {
let ivn = min(perSampleIvSize, 16)
iv.replaceSubrange(0..<ivn, with: data.subdata(in: p..<(p + ivn)))
p += ivn
} else if let constantIv {
let ivn = min(constantIv.count, 16)
iv.replaceSubrange(0..<ivn, with: constantIv.prefix(ivn))
}
var subs: [(clear: Int, protected: Int)]?
if entrySize > perSampleIvSize {
let sc = Int(readU16(data, p)); p += 2
var list: [(clear: Int, protected: Int)] = []
for _ in 0..<sc {
let clear = Int(readU16(data, p)); p += 2
let protected = Int(readU32(data, p)); p += 4
list.append((clear, protected))
}
subs = list
}
out.append(SencSample(iv: iv, subsamples: subs))
auxPos += entrySize
}
return out
}
private static func decryptSample(
_ sample: Data,
key: Data,
iv: Data,
subsamples: [(clear: Int, protected: Int)]?,
scheme: String,
cryptByteBlock: Int,
skipByteBlock: Int
) -> Data? {
if scheme == "cbcs" || scheme == "cbc1" {
return decryptCbcsSample(
sample,
key: key,
iv: iv,
subsamples: subsamples,
cryptByteBlock: max(cryptByteBlock, 1),
skipByteBlock: skipByteBlock
)
}
// cenc / cens: AES-CTR. Concatenate protected ranges → one CTR stream → merge.
guard let ranges = protectedRanges(sampleCount: sample.count, subsamples: subsamples), !ranges.isEmpty else {
return AesCtr.crypt(key: key, iv: iv, data: sample)
}
var encrypted = Data()
for r in ranges {
encrypted.append(sample.subdata(in: r))
}
guard let plain = AesCtr.crypt(key: key, iv: iv, data: encrypted) else { return nil }
var out = sample
var read = 0
for r in ranges {
let len = r.count
out.replaceSubrange(r, with: plain.subdata(in: read..<(read + len)))
read += len
}
return out
}
private static func decryptCbcsSample(
_ sample: Data,
key: Data,
iv: Data,
subsamples: [(clear: Int, protected: Int)]?,
cryptByteBlock: Int,
skipByteBlock: Int
) -> Data? {
var out = sample
if let subsamples {
var cursor = 0
for sub in subsamples {
cursor += sub.clear
let end = cursor + sub.protected
guard end <= out.count else { break }
let slice = out.subdata(in: cursor..<end)
guard let dec = AesCtr.cryptCbcs(
key: key,
iv: iv,
data: slice,
cryptByteBlock: cryptByteBlock,
skipByteBlock: skipByteBlock
) else { return nil }
out.replaceSubrange(cursor..<end, with: dec)
cursor = end
}
return out
}
return AesCtr.cryptCbcs(
key: key,
iv: iv,
data: sample,
cryptByteBlock: cryptByteBlock,
skipByteBlock: skipByteBlock
)
}
private static func protectedRanges(
sampleCount: Int,
subsamples: [(clear: Int, protected: Int)]?
) -> [Range<Int>]? {
guard let subsamples else {
return [0..<sampleCount]
}
var ranges: [Range<Int>] = []
var cursor = 0
for sub in subsamples {
cursor += sub.clear
if sub.protected > 0 {
ranges.append(cursor..<(cursor + sub.protected))
}
cursor += sub.protected
}
return ranges
}
private static func findBoxesInRange(_ data: Data, type: String, from: Int, to: Int) -> [Box] {
var out: [Box] = []
walk(data, from: from, to: to, recursive: true) { box in
if box.type == type { out.append(box) }
return true
}
return out
}
}

View file

@ -0,0 +1,335 @@
import Foundation
import Network
/// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey.
/// VLC speelt daarna plain DASH vanaf 127.0.0.1.
final class ClearKeyDashProxy {
static let shared = ClearKeyDashProxy()
private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy")
private var listener: NWListener?
private var port: UInt16 = 0
private var remoteManifest: URL?
private var keyMap: ClearKeyKeyMap?
private var cryptoState = CencMp4Decryptor.SessionState()
private var generation = 0
private init() {}
var isRunning: Bool { listener != nil && port > 0 }
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else {
throw ProxyError.noKeys
}
stop()
remoteManifest = manifestURL
keyMap = map
cryptoState = CencMp4Decryptor.SessionState()
generation += 1
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: .any)
self.listener = listener
let ready = DispatchSemaphore(value: 0)
var readyError: Error?
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .ready:
if let p = listener.port?.rawValue {
self.port = p
}
ready.signal()
case .failed(let err):
readyError = err
ready.signal()
default:
break
}
}
listener.newConnectionHandler = { [weak self] conn in
self?.handle(connection: conn)
}
listener.start(queue: queue)
_ = ready.wait(timeout: .now() + 5)
if let readyError { throw readyError }
guard port > 0 else { throw ProxyError.bindFailed }
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
}
func stop() {
listener?.cancel()
listener = nil
port = 0
remoteManifest = nil
keyMap = nil
cryptoState = CencMp4Decryptor.SessionState()
}
// MARK: - HTTP
private func handle(connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(on: connection, buffer: Data())
}
private func receiveRequest(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in
guard let self else {
connection.cancel()
return
}
if error != nil {
connection.cancel()
return
}
var buf = buffer
if let data { buf.append(data) }
if let range = buf.range(of: Data("\r\n\r\n".utf8)) {
let headerData = buf.subdata(in: 0..<range.lowerBound)
self.serve(connection: connection, headerData: headerData)
return
}
if isComplete {
connection.cancel()
return
}
if buf.count > 256 * 1024 {
connection.cancel()
return
}
self.receiveRequest(on: connection, buffer: buf)
}
}
private func serve(connection: NWConnection, headerData: Data) {
guard let header = String(data: headerData, encoding: .utf8),
let firstLine = header.split(separator: "\r\n", omittingEmptySubsequences: false).first
else {
respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8))
return
}
let parts = firstLine.split(separator: " ")
guard parts.count >= 2 else {
respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8))
return
}
let method = String(parts[0])
let path = String(parts[1])
guard method == "GET" || method == "HEAD" else {
respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8))
return
}
Task {
do {
let (body, contentType) = try await self.payload(for: path)
if method == "HEAD" {
self.respond(connection, status: 200, contentType: contentType, body: Data(), headOnly: true, contentLength: body.count)
} else {
self.respond(connection, status: 200, contentType: contentType, body: body)
}
} catch {
let msg = error.localizedDescription
self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8))
}
}
}
private func payload(for pathAndQuery: String) async throws -> (Data, String) {
guard let keyMap, let remoteManifest else {
throw ProxyError.notStarted
}
let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery
if pathOnly == "/" || pathOnly == "/manifest.mpd" {
let raw = try await fetch(remoteManifest)
let rewritten = rewriteMpd(String(data: raw, encoding: .utf8) ?? "", mpdURL: remoteManifest)
return (Data(rewritten.utf8), "application/dash+xml")
}
if pathOnly.hasPrefix("/a/") {
let b64 = String(pathOnly.dropFirst(3))
guard let url = decodeURL(b64) else { throw ProxyError.badPath }
return try await fetchAndDecrypt(url, keys: keyMap)
}
if pathOnly.hasPrefix("/p/") {
let rest = String(pathOnly.dropFirst(3))
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
let b64 = String(rest[..<slash])
let rel = String(rest[rest.index(after: slash)...])
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
let joined = join(base: base, relative: rel.removingPercentEncoding ?? rel)
return try await fetchAndDecrypt(joined, keys: keyMap)
}
throw ProxyError.badPath
}
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
var data = try await fetch(url)
queue.sync {
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
}
let ct: String
let lower = url.absoluteString.lowercased()
if lower.contains(".mpd") {
ct = "application/dash+xml"
} else if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") {
ct = "video/mp4"
} else {
ct = "application/octet-stream"
}
return (data, ct)
}
private func fetch(_ url: URL) async throws -> Data {
var req = URLRequest(url: url)
req.timeoutInterval = 20
req.setValue("VMC-iOS-ClearKey/0.5.1", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw ProxyError.upstream(http.statusCode)
}
return data
}
private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
var s = xml
// Strip ContentProtection (ClearKey/Widevine/PlayReady/mp4protection).
s = s.replacingOccurrences(
of: #"<ContentProtection\b[^>]*/>"#,
with: "",
options: .regularExpression
)
s = s.replacingOccurrences(
of: #"<ContentProtection\b[^>]*>[\s\S]*?</ContentProtection>"#,
with: "",
options: .regularExpression
)
let originDir = mpdURL.deletingLastPathComponent()
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(originDir))/"
// Herschrijf bestaande BaseURL-tekst naar proxy.
s = s.replacingOccurrences(
of: #"<BaseURL>([\s\S]*?)</BaseURL>"#,
with: "<BaseURL>\(proxyBase)</BaseURL>",
options: .regularExpression
)
if !s.localizedCaseInsensitiveContains("<BaseURL") {
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
let range = NSRange(s.startIndex..<s.endIndex, in: s)
if let match = regex.firstMatch(in: s, options: [], range: range),
let r = Range(match.range, in: s) {
s.insert(contentsOf: "\n <BaseURL>\(proxyBase)</BaseURL>", at: r.upperBound)
}
}
}
// Absolute http(s) URL's in attributen → /a/<b64>
if let urlRegex = try? NSRegularExpression(pattern: #"https?://[^\s"'<>]+"#, options: []) {
let ns = s as NSString
let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
for match in matches.reversed() {
let abs = ns.substring(with: match.range)
// Laat lokale proxy-URL's met rust.
if abs.contains("127.0.0.1:\(port)") { continue }
let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))"
s = (s as NSString).replacingCharacters(in: match.range, with: replacement)
}
}
return s
}
private func join(base: URL, relative: String) -> URL {
if relative.hasPrefix("http://") || relative.hasPrefix("https://") {
return URL(string: relative) ?? base
}
return URL(string: relative, relativeTo: base)?.absoluteURL ?? base.appendingPathComponent(relative)
}
private func encodeURL(_ url: URL) -> String {
encodeURLString(url.absoluteString)
}
private func encodeURLString(_ s: String) -> String {
Data(s.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func decodeURL(_ b64url: String) -> URL? {
var b64 = b64url
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let pad = (4 - b64.count % 4) % 4
if pad > 0 { b64 += String(repeating: "=", count: pad) }
guard let data = Data(base64Encoded: b64),
let s = String(data: data, encoding: .utf8)
else { return nil }
return URL(string: s)
}
private func respond(
_ connection: NWConnection,
status: Int,
contentType: String,
body: Data,
headOnly: Bool = false,
contentLength: Int? = nil
) {
let reason: String
switch status {
case 200: reason = "OK"
case 400: reason = "Bad Request"
case 405: reason = "Method Not Allowed"
case 502: reason = "Bad Gateway"
default: reason = "Error"
}
let len = contentLength ?? body.count
var header = "HTTP/1.1 \(status) \(reason)\r\n"
header += "Content-Type: \(contentType)\r\n"
header += "Content-Length: \(len)\r\n"
header += "Connection: close\r\n"
header += "Access-Control-Allow-Origin: *\r\n"
header += "Cache-Control: no-store\r\n"
header += "\r\n"
var payload = Data(header.utf8)
if !headOnly { payload.append(body) }
connection.send(content: payload, completion: .contentProcessed { _ in
connection.cancel()
})
}
enum ProxyError: LocalizedError {
case noKeys
case bindFailed
case notStarted
case badPath
case upstream(Int)
var errorDescription: String? {
switch self {
case .noKeys: return "Geen geldige ClearKey-sleutels"
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
case .notStarted: return "ClearKey-proxy is niet actief"
case .badPath: return "Ongeldig proxy-pad"
case .upstream(let code): return "Upstream HTTP \(code)"
}
}
}
}

View file

@ -0,0 +1,70 @@
import Foundation
/// ClearKey kids/keys zoals Android `buildClearKeyLicenseJson` (hex 32 + GUID-endianness).
struct ClearKeyKeyMap {
/// kid-hex (lowercase, 32 chars) → key bytes (16)
private let keysByKid: [String: Data]
init(entries: [ClearKeyEntry]) {
var map: [String: Data] = [:]
for entry in entries {
guard let kid = Self.hex16(entry.kid), let key = Self.hex16(entry.key) else { continue }
let kidHex = kid.map { String(format: "%02x", $0) }.joined()
map[kidHex] = key
let swapped = Self.swapUuidEndianness(kid)
let swappedHex = swapped.map { String(format: "%02x", $0) }.joined()
if swappedHex != kidHex {
map[swappedHex] = key
}
}
keysByKid = map
}
var isEmpty: Bool { keysByKid.isEmpty }
/// Eerste key (streams hebben meestal één kid/key-paar).
var primaryKey: Data? { keysByKid.values.first }
func key(forKid kid: Data) -> Data? {
guard kid.count == 16 else { return primaryKey }
let hex = kid.map { String(format: "%02x", $0) }.joined()
if let k = keysByKid[hex] { return k }
let swapped = Self.swapUuidEndianness(kid)
let swappedHex = swapped.map { String(format: "%02x", $0) }.joined()
return keysByKid[swappedHex] ?? primaryKey
}
static func sanitizeHex(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "0x", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: " ", with: "")
.lowercased()
}
static func hex16(_ raw: String) -> Data? {
let clean = sanitizeHex(raw)
guard clean.count == 32, clean.allSatisfy({ $0.isHexDigit }) else { return nil }
var out = Data(capacity: 16)
var i = clean.startIndex
while i < clean.endIndex {
let j = clean.index(i, offsetBy: 2)
guard let b = UInt8(clean[i..<j], radix: 16) else { return nil }
out.append(b)
i = j
}
return out
}
/// Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes.
static func swapUuidEndianness(_ b: Data) -> Data {
guard b.count == 16 else { return b }
return Data([
b[3], b[2], b[1], b[0],
b[5], b[4],
b[7], b[6],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15],
])
}
}

View file

@ -386,19 +386,21 @@ struct PlayerScreen: View {
loading = true
error = nil
if request.isLive {
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
player.playLive(
url: request.url,
fallback: request.fallbackUrl,
format: request.format,
clearKeys: request.drm?.keys ?? []
clearKeys: keys
)
// Geef VLC tijd; bij ClearKey + blijvende fout → duidelijke NL-melding.
try? await Task.sleep(nanoseconds: 2_500_000_000)
// ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments).
let waitNs: UInt64 = request.needsClearKey ? 8_000_000_000 : 2_500_000_000
try? await Task.sleep(nanoseconds: waitNs)
if request.needsClearKey && (player.hasError || !player.isPlaying) {
try? await Task.sleep(nanoseconds: 3_500_000_000)
try? await Task.sleep(nanoseconds: 4_000_000_000)
if player.hasError || !player.isPlaying {
error = "Deze stream vereist DRM-ondersteuning die binnenkort komt"
player.stop()
error = player.errorMessage
?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw."
}
}
} else {

View file

@ -19,6 +19,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
let mediaPlayer = VLCMediaPlayer()
private var lengthMs: Int32 = 0
private var tick: AnyCancellable?
private var clearKeyProxyActive = false
override init() {
super.init()
@ -77,20 +78,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
hasError = false
errorMessage = nil
stateText = "Live laden…"
let media = VLCMedia(url: url)
var playURL = url
var playFormat = format
clearKeyProxyActive = false
if !clearKeys.isEmpty {
do {
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash"
clearKeyProxyActive = true
stateText = "ClearKey laden…"
} catch {
hasError = true
errorMessage = error.localizedDescription
stateText = "Fout"
return
}
}
let media = VLCMedia(url: playURL)
media.delegate = self
Self.applyLiveOptions(to: media, url: url, format: format, clearKeys: clearKeys)
Self.applyLiveOptions(to: media, url: playURL, format: playFormat)
mediaPlayer.media = media
media.parse()
mediaPlayer.play()
applySubtitleStyle()
if let fallback {
if let fallback, clearKeys.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, !self.isPlaying || self.hasError else { return }
// Geen ClearKey op fallback tenzij zelfde DRM nodig is — probeer plain HLS/TS.
let fb = VLCMedia(url: fallback)
fb.delegate = self
Self.applyLiveOptions(to: fb, url: fallback, format: nil, clearKeys: [])
Self.applyLiveOptions(to: fb, url: fallback, format: nil)
self.mediaPlayer.media = fb
fb.parse()
self.mediaPlayer.play()
@ -100,13 +120,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
startTicker()
}
/// Best-effort DASH + ClearKey via libvlc media options.
/// MobileVLCKit heeft geen officiële ClearKey-API; we zetten gangbare demux/cenc-opties.
private static func applyLiveOptions(
to media: VLCMedia,
url: URL,
format: String?,
clearKeys: [ClearKeyEntry]
format: String?
) {
media.addOption(":network-caching=1200")
media.addOption(":http-reconnect")
@ -125,29 +142,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} else if isHls {
media.addOption(":demux=hls")
}
guard !clearKeys.isEmpty else { return }
// Probeer meerdere option-namen die in libvlc/cenc-builds voorkomen.
for entry in clearKeys {
let kid = sanitizeHex(entry.kid)
let key = sanitizeHex(entry.key)
guard kid.count == 32, key.count == 32 else { continue }
media.addOption(":cenc-key=\(kid):\(key)")
media.addOption(":encryption-key=\(key)")
// Sommige builds: kid zonder scheiding + key als hex.
media.addOption(":demuxdump-key=\(key)")
}
// Extra hint voor ISO-BMFF CENC in DASH.
media.addOption(":no-spu")
}
private static func sanitizeHex(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "0x", with: "", options: .caseInsensitive)
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: " ", with: "")
.lowercased()
}
func togglePlay() {
@ -412,6 +406,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
tick = nil
mediaPlayer.stop()
mediaPlayer.media = nil
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {

View file

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