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"