diff --git a/apps/ios/ExportOptions.plist b/apps/ios/ExportOptions.plist
new file mode 100644
index 0000000..92c70c7
--- /dev/null
+++ b/apps/ios/ExportOptions.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ method
+ app-store
+ teamID
+ X25NYX3K49
+ signingStyle
+ automatic
+ uploadSymbols
+
+ destination
+ export
+ manageAppVersionAndBuildNumber
+
+
+
diff --git a/apps/ios/README.md b/apps/ios/README.md
index 88792aa..783a661 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -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.1+)
+## Live TV / Events / ClearKey (0.5.2+)
- `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`.
+- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId` voor live-lijsten én events.
- Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events.
-- **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.
+- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op (dynamic live: BaseURL/Location/query/Range), decrypt CENC/CBCS (`sinf`→`free`), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy.
- Events-tab verschijnt als `/me` → `events.enabled` true is.
diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj
index 2a316e5..29cef11 100644
--- a/apps/ios/VMC.xcodeproj/project.pbxproj
+++ b/apps/ios/VMC.xcodeproj/project.pbxproj
@@ -337,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 13;
+ CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -352,7 +352,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.5.1;
+ MARKETING_VERSION = 0.5.2;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -370,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 13;
+ CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -385,7 +385,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.5.1;
+ MARKETING_VERSION = 0.5.2;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/apps/ios/VMC/Playback/CencMp4Decryptor.swift b/apps/ios/VMC/Playback/CencMp4Decryptor.swift
index 106087f..06e7d1f 100644
--- a/apps/ios/VMC/Playback/CencMp4Decryptor.swift
+++ b/apps/ios/VMC/Playback/CencMp4Decryptor.swift
@@ -196,17 +196,11 @@ enum CencMp4Decryptor {
}
// Replace encv/enca with original format (same 4 bytes → no size rewrite).
+ // Mark sinf as 'free' zodat VLC geen DRM-sample-entry meer ziet (anders: meteen Gestopt).
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
+ var o = stsd.payloadStart + 8 // FullBox (4) + entry_count (4)
while o + 8 <= stsd.payloadEnd {
guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break }
if entry.type == "encv" || entry.type == "enca" {
@@ -216,6 +210,12 @@ enum CencMp4Decryptor {
}
}
}
+ walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
+ if box.type == "sinf" {
+ writeFourCC(&data, box.start + 4, "free")
+ }
+ return true
+ }
if let crypto {
if let trackId {
diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift
index 0d00b17..820c06c 100644
--- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift
+++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift
@@ -1,5 +1,8 @@
import Foundation
import Network
+import os.log
+
+private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
/// 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.
@@ -13,12 +16,21 @@ final class ClearKeyDashProxy {
private var keyMap: ClearKeyKeyMap?
private var cryptoState = CencMp4Decryptor.SessionState()
private var generation = 0
+ private let urlSession: URLSession = {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.timeoutIntervalForRequest = 25
+ cfg.timeoutIntervalForResource = 60
+ cfg.httpMaximumConnectionsPerHost = 8
+ cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
+ return URLSession(configuration: cfg)
+ }()
private init() {}
var isRunning: Bool { listener != nil && port > 0 }
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
+ /// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else {
@@ -29,6 +41,7 @@ final class ClearKeyDashProxy {
keyMap = map
cryptoState = CencMp4Decryptor.SessionState()
generation += 1
+ let gen = generation
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
@@ -45,8 +58,10 @@ final class ClearKeyDashProxy {
if let p = listener.port?.rawValue {
self.port = p
}
+ proxyLog.info("listener ready on port \(self.port, privacy: .public)")
ready.signal()
case .failed(let err):
+ proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
readyError = err
ready.signal()
default:
@@ -63,16 +78,38 @@ final class ClearKeyDashProxy {
if let readyError { throw readyError }
guard port > 0 else { throw ProxyError.bindFailed }
+ // Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen).
+ let warm = DispatchSemaphore(value: 0)
+ var warmError: Error?
+ Task {
+ do {
+ let (body, _) = try await self.payload(for: "/manifest.mpd")
+ guard !body.isEmpty, gen == self.generation else {
+ throw ProxyError.emptyManifest
+ }
+ let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
+ proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
+ } catch {
+ warmError = error
+ proxyLog.error("warmup failed: \(error.localizedDescription, privacy: .public)")
+ }
+ warm.signal()
+ }
+ _ = warm.wait(timeout: .now() + 20)
+ if let warmError { throw warmError }
+
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
}
func stop() {
+ generation += 1
listener?.cancel()
listener = nil
port = 0
remoteManifest = nil
keyMap = nil
cryptoState = CencMp4Decryptor.SessionState()
+ proxyLog.info("proxy stopped")
}
// MARK: - HTTP
@@ -124,22 +161,66 @@ final class ClearKeyDashProxy {
return
}
let method = String(parts[0])
- let path = String(parts[1])
+ let pathAndQuery = String(parts[1])
guard method == "GET" || method == "HEAD" else {
respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8))
return
}
+ let rangeHeader = Self.parseHeader(header, name: "Range")
+
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)
+ let (body, contentType) = try await self.payload(for: pathAndQuery)
+ if let rangeHeader, let byteRange = Self.parseByteRange(rangeHeader, total: body.count) {
+ let slice = body.subdata(in: byteRange.start..<(byteRange.end + 1))
+ if method == "HEAD" {
+ self.respond(
+ connection,
+ status: 206,
+ contentType: contentType,
+ body: Data(),
+ headOnly: true,
+ contentLength: slice.count,
+ extraHeaders: [
+ "Accept-Ranges: bytes",
+ "Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
+ ]
+ )
+ } else {
+ self.respond(
+ connection,
+ status: 206,
+ contentType: contentType,
+ body: slice,
+ extraHeaders: [
+ "Accept-Ranges: bytes",
+ "Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
+ ]
+ )
+ }
+ } else if method == "HEAD" {
+ self.respond(
+ connection,
+ status: 200,
+ contentType: contentType,
+ body: Data(),
+ headOnly: true,
+ contentLength: body.count,
+ extraHeaders: ["Accept-Ranges: bytes"]
+ )
} else {
- self.respond(connection, status: 200, contentType: contentType, body: body)
+ self.respond(
+ connection,
+ status: 200,
+ contentType: contentType,
+ body: body,
+ extraHeaders: ["Accept-Ranges: bytes"]
+ )
}
} catch {
let msg = error.localizedDescription
+ proxyLog.error("serve \(pathAndQuery, privacy: .public) → \(msg, privacy: .public)")
self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8))
}
}
@@ -149,17 +230,36 @@ final class ClearKeyDashProxy {
guard let keyMap, let remoteManifest else {
throw ProxyError.notStarted
}
- let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery
+ let split = pathAndQuery.split(separator: "?", maxSplits: 1).map(String.init)
+ let pathOnly = split.first ?? pathAndQuery
+ let query = split.count > 1 ? split[1] : nil
if pathOnly == "/" || pathOnly == "/manifest.mpd" {
let raw = try await fetch(remoteManifest)
- let rewritten = rewriteMpd(String(data: raw, encoding: .utf8) ?? "", mpdURL: remoteManifest)
+ guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else {
+ // Sommige CDN's sturen UTF-16 BOM; probeer lossy.
+ let lossy = String(decoding: raw, as: UTF8.self)
+ guard !lossy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw ProxyError.emptyManifest
+ }
+ let rewritten = rewriteMpd(lossy, mpdURL: remoteManifest)
+ return (Data(rewritten.utf8), "application/dash+xml")
+ }
+ let rewritten = rewriteMpd(xml, mpdURL: remoteManifest)
+ if !rewritten.localizedCaseInsensitiveContains(" (Data, String) {
var data = try await fetch(url)
+ let lower = url.absoluteString.lowercased()
+ let isManifest = lower.contains(".mpd")
+ if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) {
+ let rewritten = rewriteMpd(xml, mpdURL: url)
+ return (Data(rewritten.utf8), "application/dash+xml")
+ }
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") {
+ if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") || lower.contains(".cmfv") || lower.contains(".cmfa") {
ct = "video/mp4"
} else {
ct = "application/octet-stream"
@@ -195,9 +301,11 @@ final class ClearKeyDashProxy {
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)
+ req.timeoutInterval = 25
+ req.cachePolicy = .reloadIgnoringLocalCacheData
+ req.setValue("VMC-iOS-ClearKey/0.5.2", forHTTPHeaderField: "User-Agent")
+ req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
+ let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw ProxyError.upstream(http.statusCode)
}
@@ -218,17 +326,32 @@ final class ClearKeyDashProxy {
options: .regularExpression
)
- let originDir = mpdURL.deletingLastPathComponent()
- let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(originDir))/"
-
- // Herschrijf bestaande BaseURL-tekst naar proxy.
+ // Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy).
s = s.replacingOccurrences(
- of: #"([\s\S]*?)"#,
- with: "\(proxyBase)",
+ of: #"]*>[\s\S]*?"#,
+ with: "http://127.0.0.1:\(port)/manifest.mpd",
options: .regularExpression
)
+ // Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p//.
+ if let baseRegex = try? NSRegularExpression(pattern: #"([\s\S]*?)"#, options: []) {
+ let ns = s as NSString
+ let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
+ for match in matches.reversed() {
+ guard match.numberOfRanges >= 2 else { continue }
+ let content = ns.substring(with: match.range(at: 1))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if content.contains("127.0.0.1:\(port)") {
+ continue
+ }
+ let resolved = resolveBaseURL(content, mpdURL: mpdURL)
+ let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(resolved)))/"
+ s = (s as NSString).replacingCharacters(in: match.range, with: "\(proxyBase)")
+ }
+ }
+
if !s.localizedCaseInsensitiveContains("]*>"#, options: []) {
let range = NSRange(s.startIndex.. URL {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ return asDirectory(mpdURL.deletingLastPathComponent())
+ }
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://"),
+ let abs = URL(string: trimmed) {
+ return asDirectory(abs)
+ }
+ if let joined = URL(string: trimmed, relativeTo: mpdURL)?.absoluteURL {
+ return asDirectory(joined)
+ }
+ return asDirectory(mpdURL.deletingLastPathComponent())
+ }
+
+ private func asDirectory(_ url: URL) -> URL {
+ var s = url.absoluteString
+ if s.isEmpty { return url }
+ if !s.hasSuffix("/") {
+ // Als laatste path-component op een bestand lijkt (.mpd/.mp4), strip; anders slash toevoegen.
+ let last = url.lastPathComponent.lowercased()
+ if last.contains("."), !last.hasSuffix("/") {
+ return asDirectory(url.deletingLastPathComponent())
+ }
+ s += "/"
+ }
+ return URL(string: s) ?? url
+ }
+
private func join(base: URL, relative: String) -> URL {
if relative.hasPrefix("http://") || relative.hasPrefix("https://") {
return URL(string: relative) ?? base
@@ -284,17 +436,47 @@ final class ClearKeyDashProxy {
return URL(string: s)
}
+ private static func parseHeader(_ header: String, name: String) -> String? {
+ let lines = header.split(separator: "\r\n")
+ let prefix = name.lowercased() + ":"
+ for line in lines.dropFirst() {
+ let l = line.trimmingCharacters(in: .whitespaces)
+ if l.lowercased().hasPrefix(prefix) {
+ return String(l.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
+ }
+ }
+ return nil
+ }
+
+ private static func parseByteRange(_ value: String, total: Int) -> (start: Int, end: Int)? {
+ // bytes=START-END | bytes=START-
+ guard value.lowercased().hasPrefix("bytes=") else { return nil }
+ let spec = value.dropFirst(6)
+ let parts = spec.split(separator: "-", maxSplits: 1).map(String.init)
+ guard let start = Int(parts.first ?? "") else { return nil }
+ let end: Int
+ if parts.count > 1, let e = Int(parts[1]) {
+ end = min(e, total - 1)
+ } else {
+ end = total - 1
+ }
+ guard total > 0, start >= 0, start <= end, end < total else { return nil }
+ return (start, end)
+ }
+
private func respond(
_ connection: NWConnection,
status: Int,
contentType: String,
body: Data,
headOnly: Bool = false,
- contentLength: Int? = nil
+ contentLength: Int? = nil,
+ extraHeaders: [String] = []
) {
let reason: String
switch status {
case 200: reason = "OK"
+ case 206: reason = "Partial Content"
case 400: reason = "Bad Request"
case 405: reason = "Method Not Allowed"
case 502: reason = "Bad Gateway"
@@ -307,6 +489,9 @@ final class ClearKeyDashProxy {
header += "Connection: close\r\n"
header += "Access-Control-Allow-Origin: *\r\n"
header += "Cache-Control: no-store\r\n"
+ for h in extraHeaders {
+ header += h + "\r\n"
+ }
header += "\r\n"
var payload = Data(header.utf8)
if !headOnly { payload.append(body) }
@@ -320,6 +505,7 @@ final class ClearKeyDashProxy {
case bindFailed
case notStarted
case badPath
+ case emptyManifest
case upstream(Int)
var errorDescription: String? {
@@ -328,6 +514,7 @@ final class ClearKeyDashProxy {
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
case .notStarted: return "ClearKey-proxy is niet actief"
case .badPath: return "Ongeldig proxy-pad"
+ case .emptyManifest: return "Leeg of ongeldig DASH-manifest ontvangen"
case .upstream(let code): return "Upstream HTTP \(code)"
}
}
diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift
index 076ec30..d11f553 100644
--- a/apps/ios/VMC/Playback/PlayerScreen.swift
+++ b/apps/ios/VMC/Playback/PlayerScreen.swift
@@ -393,14 +393,14 @@ struct PlayerScreen: View {
format: request.format,
clearKeys: keys
)
- // ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments).
- let waitNs: UInt64 = request.needsClearKey ? 8_000_000_000 : 2_500_000_000
+ // ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
+ let waitNs: UInt64 = request.needsClearKey ? 10_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: 4_000_000_000)
+ try? await Task.sleep(nanoseconds: 6_000_000_000)
if player.hasError || !player.isPlaying {
error = player.errorMessage
- ?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw."
+ ?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
}
}
} else {
diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift
index 4e212eb..d06265c 100644
--- a/apps/ios/VMC/Playback/VLCPlayerController.swift
+++ b/apps/ios/VMC/Playback/VLCPlayerController.swift
@@ -20,6 +20,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var lengthMs: Int32 = 0
private var tick: AnyCancellable?
private var clearKeyProxyActive = false
+ private var livePlayURL: URL?
+ private var livePlayFormat: String?
+ private var liveStartedAt: Date?
+ private var liveRetryCount = 0
+ private var everPlayed = false
override init() {
super.init()
@@ -78,6 +83,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
hasError = false
errorMessage = nil
stateText = "Live laden…"
+ liveRetryCount = 0
+ everPlayed = false
+ liveStartedAt = Date()
var playURL = url
var playFormat = format
@@ -86,40 +94,75 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if !clearKeys.isEmpty {
do {
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
+ // start() wacht tot listener + MPD-warmup klaar zijn.
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash"
clearKeyProxyActive = true
stateText = "ClearKey laden…"
} catch {
hasError = true
- errorMessage = error.localizedDescription
+ errorMessage = dutchClearKeyError(error)
stateText = "Fout"
return
}
}
- let media = VLCMedia(url: playURL)
- media.delegate = self
- Self.applyLiveOptions(to: media, url: playURL, format: playFormat)
- mediaPlayer.media = media
- media.parse()
- mediaPlayer.play()
- applySubtitleStyle()
+ livePlayURL = playURL
+ livePlayFormat = playFormat
+ startLiveMedia(url: playURL, format: playFormat)
+
if let fallback, clearKeys.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, !self.isPlaying || self.hasError else { return }
- let fb = VLCMedia(url: fallback)
- fb.delegate = self
- Self.applyLiveOptions(to: fb, url: fallback, format: nil)
- self.mediaPlayer.media = fb
- fb.parse()
- self.mediaPlayer.play()
- self.applySubtitleStyle()
+ self.startLiveMedia(url: fallback, format: nil)
}
}
startTicker()
}
+ private func startLiveMedia(url: URL, format: String?) {
+ let media = VLCMedia(url: url)
+ media.delegate = self
+ Self.applyLiveOptions(to: media, url: url, format: format)
+ mediaPlayer.media = media
+ // Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD.
+ mediaPlayer.play()
+ applySubtitleStyle()
+ }
+
+ private func dutchClearKeyError(_ error: Error) -> String {
+ let msg = error.localizedDescription
+ if msg.localizedCaseInsensitiveContains("upstream") {
+ return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
+ }
+ if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") {
+ return "DASH-manifest kon niet worden geladen. Controleer de zender-URL."
+ }
+ if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
+ return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw."
+ }
+ if msg.localizedCaseInsensitiveContains("sleutel") {
+ return "Geen geldige ClearKey-sleutels voor deze zender."
+ }
+ return "ClearKey-stream mislukt: \(msg)"
+ }
+
+ /// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief).
+ private func retryLiveIfNeeded() {
+ guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
+ guard let url = livePlayURL else { return }
+ let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
+ guard elapsed < 12 else { return }
+ liveRetryCount += 1
+ stateText = "Opnieuw verbinden…"
+ hasError = false
+ errorMessage = nil
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
+ guard let self, self.clearKeyProxyActive else { return }
+ self.startLiveMedia(url: url, format: self.livePlayFormat)
+ }
+ }
+
private static func applyLiveOptions(
to media: VLCMedia,
url: URL,
@@ -406,6 +449,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
tick = nil
mediaPlayer.stop()
mediaPlayer.media = nil
+ livePlayURL = nil
+ livePlayFormat = nil
+ liveStartedAt = nil
+ liveRetryCount = 0
+ everPlayed = false
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
@@ -422,15 +470,31 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
case .playing:
self.stateText = "Afspelen"
self.hasError = false
+ self.errorMessage = nil
+ self.everPlayed = true
self.refreshTracks()
self.applySubtitleStyle()
case .paused: self.stateText = "Pauze"
- case .stopped: self.stateText = "Gestopt"
+ case .stopped:
+ // Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven.
+ if self.clearKeyProxyActive && !self.everPlayed {
+ self.stateText = "Verbinden…"
+ self.retryLiveIfNeeded()
+ } else {
+ self.stateText = "Gestopt"
+ }
case .ended: self.stateText = "Einde"
case .error:
- self.hasError = true
- self.errorMessage = "Kon dit bestand niet afspelen"
- self.stateText = "Fout"
+ if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
+ self.stateText = "Opnieuw…"
+ self.retryLiveIfNeeded()
+ } else {
+ self.hasError = true
+ self.errorMessage = self.clearKeyProxyActive
+ ? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
+ : "Kon dit bestand niet afspelen"
+ self.stateText = "Fout"
+ }
case .esAdded:
self.refreshTracks()
@unknown default: break
diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift
index 2c6edf7..b76d72b 100644
--- a/apps/ios/VMC/UI/SettingsScreen.swift
+++ b/apps/ios/VMC/UI/SettingsScreen.swift
@@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
- LabeledContent("Versie", value: "0.5.1")
+ LabeledContent("Versie", value: "0.5.2")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
}
diff --git a/apps/ios/scripts/VMC-upload-TestFlight.command b/apps/ios/scripts/VMC-upload-TestFlight.command
new file mode 100644
index 0000000..0a790d6
--- /dev/null
+++ b/apps/ios/scripts/VMC-upload-TestFlight.command
@@ -0,0 +1,16 @@
+#!/bin/bash
+exec > /tmp/vmc-tf-upload.log 2>&1
+echo "TF_UPLOAD_START $(date)"
+security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
+security show-keychain-info "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
+# Allow codesign non-interactively in this GUI login session (worked for prior export)
+security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
+export KEY_ID=C4LTZP6328
+export ISSUER_ID=ab4414a1-90fc-4fbf-b051-59d95c48097e
+export AUTH_KEY_PATH="$HOME/AuthKey.p8"
+bash "$HOME/upload-testflight.sh" --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" --auth-key "$AUTH_KEY_PATH"
+EC=$?
+echo "TF_UPLOAD_EXIT:$EC"
+date > /tmp/vmc-tf-upload.done
+echo "$EC" > /tmp/vmc-tf-upload.ec
+exit "$EC"
diff --git a/apps/ios/scripts/upload-testflight.sh b/apps/ios/scripts/upload-testflight.sh
new file mode 100644
index 0000000..30f73ac
--- /dev/null
+++ b/apps/ios/scripts/upload-testflight.sh
@@ -0,0 +1,209 @@
+#!/bin/bash
+# TestFlight upload via App Store Connect API key (headless, no GUI).
+# Expects: ~/AuthKey.p8 (or AUTH_KEY_PATH)
+# Requires: KEY_ID, ISSUER_ID (env or --key-id / --issuer-id)
+# Optional: KEYCHAIN_PASS to unlock login keychain
+set -euo pipefail
+
+IOS_DIR="${IOS_DIR:-$HOME/Code/mediacluster/apps/ios}"
+AUTH_KEY_PATH="${AUTH_KEY_PATH:-$HOME/AuthKey.p8}"
+EXPORT_OPTIONS="${EXPORT_OPTIONS:-$IOS_DIR/ExportOptions.plist}"
+ARCHIVE_PATH="${ARCHIVE_PATH:-$IOS_DIR/build/VMC.xcarchive}"
+EXPORT_PATH="${EXPORT_PATH:-$IOS_DIR/build/export}"
+SCHEME="${SCHEME:-VMC}"
+CONFIGURATION="${CONFIGURATION:-Release}"
+TEAM_ID="${TEAM_ID:-X25NYX3K49}"
+BUNDLE_ID="${BUNDLE_ID:-nl.vonas.mediacluster.ios}"
+SKIP_ARCHIVE=0
+SKIP_EXPORT=0
+IPA_PATH=""
+
+usage() {
+ cat <<'EOF'
+Usage: upload-testflight.sh [options]
+
+ --key-id ID App Store Connect API Key ID (or env KEY_ID)
+ --issuer-id UUID App Store Connect Issuer ID (or env ISSUER_ID)
+ --auth-key PATH Path to .p8 (default: ~/AuthKey.p8)
+ --ipa PATH Skip archive/export; upload existing IPA
+ --skip-archive Reuse existing xcarchive
+ --skip-export Reuse existing IPA in export path
+ -h, --help Show help
+
+Env:
+ KEY_ID, ISSUER_ID, AUTH_KEY_PATH, KEYCHAIN_PASS, IOS_DIR
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --key-id) KEY_ID="${2:-}"; shift 2 ;;
+ --issuer-id) ISSUER_ID="${2:-}"; shift 2 ;;
+ --auth-key) AUTH_KEY_PATH="${2:-}"; shift 2 ;;
+ --ipa) IPA_PATH="${2:-}"; SKIP_ARCHIVE=1; SKIP_EXPORT=1; shift 2 ;;
+ --skip-archive) SKIP_ARCHIVE=1; shift ;;
+ --skip-export) SKIP_EXPORT=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
+ esac
+done
+
+KEY_ID="${KEY_ID:-}"
+ISSUER_ID="${ISSUER_ID:-}"
+
+# Derive Key ID from AuthKey_XXXXX.p8 filename if missing
+if [[ -z "$KEY_ID" && -f "$AUTH_KEY_PATH" ]]; then
+ base="$(basename "$AUTH_KEY_PATH")"
+ if [[ "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
+ KEY_ID="${BASH_REMATCH[1]}"
+ echo "KEY_ID derived from filename: $KEY_ID"
+ fi
+fi
+
+# Also accept ~/AuthKey_XXXXX.p8 if ~/AuthKey.p8 missing
+if [[ ! -f "$AUTH_KEY_PATH" ]]; then
+ for cand in "$HOME"/AuthKey_*.p8; do
+ if [[ -f "$cand" ]]; then
+ AUTH_KEY_PATH="$cand"
+ base="$(basename "$cand")"
+ if [[ -z "$KEY_ID" && "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
+ KEY_ID="${BASH_REMATCH[1]}"
+ fi
+ echo "Using API key file: $AUTH_KEY_PATH"
+ break
+ fi
+ done
+fi
+
+die() { echo "ERROR: $*" >&2; exit 1; }
+
+[[ -n "$KEY_ID" ]] || die "KEY_ID ontbreekt (env KEY_ID of --key-id, of AuthKey_XXXXX.p8)"
+[[ -n "$ISSUER_ID" ]] || die "ISSUER_ID ontbreekt (env ISSUER_ID of --issuer-id)"
+[[ -f "$AUTH_KEY_PATH" ]] || die "API key niet gevonden: $AUTH_KEY_PATH (scp AuthKey*.p8 naar ~ op de Mac)"
+
+# Private keys for altool/transporter must live in a predictable API Keys folder
+API_KEYS_DIR="$HOME/.appstoreconnect/private_keys"
+mkdir -p "$API_KEYS_DIR"
+API_KEY_DEST="$API_KEYS_DIR/AuthKey_${KEY_ID}.p8"
+if [[ "$(cd "$(dirname "$AUTH_KEY_PATH")" && pwd)/$(basename "$AUTH_KEY_PATH")" != "$API_KEY_DEST" ]]; then
+ cp -f "$AUTH_KEY_PATH" "$API_KEY_DEST"
+ chmod 600 "$API_KEY_DEST"
+fi
+# Also keep canonical ~/AuthKey.p8 symlink/copy for convenience
+if [[ ! -f "$HOME/AuthKey.p8" ]]; then
+ cp -f "$AUTH_KEY_PATH" "$HOME/AuthKey.p8"
+ chmod 600 "$HOME/AuthKey.p8"
+fi
+
+echo "=== VMC TestFlight upload ==="
+echo "KEY_ID=$KEY_ID"
+echo "ISSUER_ID=$ISSUER_ID"
+echo "AUTH_KEY=$AUTH_KEY_PATH"
+echo "BUNDLE_ID=$BUNDLE_ID"
+echo "TEAM_ID=$TEAM_ID"
+
+# Unlock keychain if password provided (headless codesign)
+if [[ -n "${KEYCHAIN_PASS:-}" ]]; then
+ echo "Unlocking login keychain..."
+ security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain-db" || \
+ security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain" || true
+ security set-keychain-settings -t 3600 -u "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
+fi
+security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
+
+cd "$IOS_DIR"
+mkdir -p build "$EXPORT_PATH" store
+
+# Ensure ExportOptions.plist for App Store
+if [[ ! -f "$EXPORT_OPTIONS" ]]; then
+ cat > "$EXPORT_OPTIONS" <
+
+
+
+ method
+ app-store
+ teamID
+ ${TEAM_ID}
+ signingStyle
+ automatic
+ uploadSymbols
+
+ destination
+ export
+ manageAppVersionAndBuildNumber
+
+
+
+PLIST
+ echo "Created $EXPORT_OPTIONS"
+fi
+
+if [[ "$SKIP_ARCHIVE" -eq 0 ]]; then
+ echo "=== Archive (Release / generic iOS) ==="
+ rm -rf "$ARCHIVE_PATH"
+ xcodebuild \
+ -project VMC.xcodeproj \
+ -scheme "$SCHEME" \
+ -configuration "$CONFIGURATION" \
+ -destination 'generic/platform=iOS' \
+ -archivePath "$ARCHIVE_PATH" \
+ -allowProvisioningUpdates \
+ DEVELOPMENT_TEAM="$TEAM_ID" \
+ CODE_SIGN_STYLE=Automatic \
+ archive
+ echo "ARCHIVE_OK: $ARCHIVE_PATH"
+fi
+
+if [[ "$SKIP_EXPORT" -eq 0 ]]; then
+ [[ -d "$ARCHIVE_PATH" ]] || die "Archive ontbreekt: $ARCHIVE_PATH"
+ echo "=== Export IPA ==="
+ rm -rf "$EXPORT_PATH"
+ mkdir -p "$EXPORT_PATH"
+ xcodebuild -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_PATH" \
+ -exportOptionsPlist "$EXPORT_OPTIONS" \
+ -allowProvisioningUpdates
+ IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f | head -1)"
+ [[ -n "$IPA_PATH" && -f "$IPA_PATH" ]] || die "Geen IPA na export in $EXPORT_PATH"
+ VER="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleShortVersionString' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo '0.5.0')"
+ BUILD="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleVersion' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo 'unknown')"
+ cp -f "$IPA_PATH" "$IOS_DIR/store/VMC-${VER}-${BUILD}.ipa"
+ echo "IPA_OK: $IPA_PATH (copied to store/VMC-${VER}-${BUILD}.ipa)"
+fi
+
+if [[ -z "${IPA_PATH:-}" ]]; then
+ IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f 2>/dev/null | head -1 || true)"
+fi
+[[ -n "${IPA_PATH:-}" && -f "$IPA_PATH" ]] || die "Geen IPA om te uploaden"
+
+echo "=== Upload via altool (API key) ==="
+# Prefer modern notary/upload path; altool still works for ASC API key upload
+set +e
+xcrun altool --upload-app \
+ --type ios \
+ --file "$IPA_PATH" \
+ --apiKey "$KEY_ID" \
+ --apiIssuer "$ISSUER_ID" \
+ 2>&1 | tee "$IOS_DIR/build/testflight-upload.log"
+UPLOAD_EC=${PIPESTATUS[0]}
+set -e
+
+if [[ "$UPLOAD_EC" -ne 0 ]]; then
+ echo "altool failed (exit $UPLOAD_EC); trying iTMSTransporter..."
+ TRANSPORTER="/Applications/Xcode.app/Contents/SharedFrameworks/ContentDeliveryServices.framework/Versions/A/itms/bin/iTMSTransporter"
+ if [[ -x "$TRANSPORTER" ]]; then
+ "$TRANSPORTER" -m upload \
+ -assetFile "$IPA_PATH" \
+ -apiKey "$KEY_ID" \
+ -apiIssuer "$ISSUER_ID" \
+ -v informational 2>&1 | tee -a "$IOS_DIR/build/testflight-upload.log"
+ else
+ die "Upload mislukt (altool exit $UPLOAD_EC) en iTMSTransporter niet gevonden"
+ fi
+fi
+
+echo "UPLOAD_OK $(date)"
+echo "IPA=$IPA_PATH"
+echo "Log: $IOS_DIR/build/testflight-upload.log"