diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 50d449b..0f05ef7 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -341,7 +341,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -356,7 +356,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.39; + MARKETING_VERSION = 0.5.41; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -374,7 +374,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 54; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -389,7 +389,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.39; + MARKETING_VERSION = 0.5.41; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/AVLivePlayerController.swift b/apps/ios/VMC/Playback/AVLivePlayerController.swift index c91f471..c82a8de 100644 --- a/apps/ios/VMC/Playback/AVLivePlayerController.swift +++ b/apps/ios/VMC/Playback/AVLivePlayerController.swift @@ -21,6 +21,8 @@ final class AVLivePlayerController: NSObject, ObservableObject { @Published var subtitleOptions: [AVMediaOptionItem] = [] @Published var currentAudioId: String? @Published var currentSubtitleId: String? + /// True zodra we minstens één keer echt speelden — timeout mag dan niet meer afbreken. + private var everPlayed = false let player = AVPlayer() private var proxySession = 0 @@ -53,6 +55,7 @@ final class AVLivePlayerController: NSObject, ObservableObject { hasError = false errorMessage = nil hasVideo = false + everPlayed = false audioOptions = [] subtitleOptions = [] currentAudioId = nil @@ -130,11 +133,25 @@ final class AVLivePlayerController: NSObject, ObservableObject { } func refreshMediaOptions() { + // ClearKey live: sporen uit de proxy (niet HLS multi-rendition — dat gaf -12312). + let tracks = ClearKeyDashProxy.shared.availableAudioTracks() + if !tracks.isEmpty { + audioOptions = tracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) } + currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? tracks.first?.id + } guard let item = player.currentItem else { return } Task { await loadMediaOptions(for: item) } } func selectAudio(id: String) { + // Proxy-spoor wisselen + item herladen (geen seamless HLS AUDIO switch). + if ClearKeyDashProxy.shared.setPreferredAudio(id: id) { + currentAudioId = id + hasError = false + errorMessage = nil + Task { await reloadCurrentItemKeepingProxy() } + return + } guard let item = player.currentItem, let group = audioGroup, let option = audioOptionById[id] else { return } item.select(option, in: group) @@ -152,6 +169,15 @@ final class AVLivePlayerController: NSObject, ObservableObject { } } + /// Herlaad dezelfde lokale HLS-URL na audio-wissel zonder proxy te stoppen. + private func reloadCurrentItemKeepingProxy() async { + guard let asset = player.currentItem?.asset as? AVURLAsset else { return } + let url = asset.url + let gen = generation + stateText = "Audio wisselen…" + await startItem(url: url, gen: gen) + } + // MARK: - Private private func startItem(url: URL, gen: Int) async { @@ -162,7 +188,7 @@ final class AVLivePlayerController: NSObject, ObservableObject { AVURLAssetAllowsConstrainedNetworkAccessKey: true, ]) let item = AVPlayerItem(asset: asset) - item.preferredForwardBufferDuration = 8 + item.preferredForwardBufferDuration = 6 clearObservers() itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in @@ -173,8 +199,10 @@ final class AVLivePlayerController: NSObject, ObservableObject { self.stateText = "Afspelen" self.hasError = false self.refreshHasVideo(from: item) - await self.loadMediaOptions(for: item) + // Eerst play — loadMediaOptions mag start niet blokkeren (Prime/HBO voelde traag). self.player.play() + self.everPlayed = true + await self.loadMediaOptions(for: item) case .failed: self.fail(from: item.error, gen: gen) default: @@ -189,6 +217,7 @@ final class AVLivePlayerController: NSObject, ObservableObject { switch player.timeControlStatus { case .playing: self.isPlaying = true + self.everPlayed = true self.stateText = "Afspelen" self.hasError = false if let item = player.currentItem { @@ -196,7 +225,8 @@ final class AVLivePlayerController: NSObject, ObservableObject { } case .waitingToPlayAtSpecifiedRate: self.isPlaying = false - self.stateText = "Bufferen…" + // Na first-play: bufferen is normaal — geen "Fout"-timeout. + self.stateText = self.everPlayed ? "Bufferen…" : "Bufferen…" case .paused: self.isPlaying = false if !self.hasError { self.stateText = "Pauze" } @@ -228,6 +258,8 @@ final class AVLivePlayerController: NSObject, ObservableObject { if let events = item.errorLog()?.events, let last = events.last { let comment = last.errorComment ?? last.errorDomain self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")") + // -12312 bij audio-wissel: niet meteen als fatale UI-fout tonen. + if last.errorStatusCode == -12312 { return } } } } @@ -236,20 +268,21 @@ final class AVLivePlayerController: NSObject, ObservableObject { player.automaticallyWaitsToMinimizeStalling = true player.play() - // Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken. + // Timeout alleen als er NOOIT iets speelde — nooit een werkende/bufferende stream afbreken. Task { @MainActor in - try? await Task.sleep(nanoseconds: 15_000_000_000) + try? await Task.sleep(nanoseconds: 20_000_000_000) guard self.generation == gen, !self.stopping else { return } if let item = self.player.currentItem { self.refreshHasVideo(from: item) } - if !self.isPlaying && !self.hasError { + if self.everPlayed || self.isPlaying { return } + if !self.hasError { if let err = item.error { self.fail(from: err, gen: gen) } else { self.hasError = true self.errorMessage = self.bestErrorMessage(for: item) - ?? "Stream opende niet binnen 15s (ClearKey/HLS)." + ?? "Stream opende niet binnen 20s (ClearKey/HLS)." self.stateText = "Fout" } } @@ -270,28 +303,36 @@ final class AVLivePlayerController: NSObject, ObservableObject { private func loadMediaOptions(for item: AVPlayerItem) async { let asset = item.asset do { - let audible = try await asset.loadMediaSelectionGroup(for: .audible) - audioGroup = audible - if let audible { - var opts: [AVMediaOptionItem] = [] - var map: [String: AVMediaSelectionOption] = [:] - for (i, opt) in audible.options.enumerated() { - let id = "a-\(i)-\(opt.displayName)" - let name = opt.displayName.isEmpty ? "Audio \(i + 1)" : opt.displayName - opts.append(AVMediaOptionItem(id: id, name: name)) - map[id] = opt - } - audioOptions = opts - audioOptionById = map - if let selected = item.selectedMediaOption(in: audible), - let match = map.first(where: { $0.value == selected })?.key { - currentAudioId = match - } else { - currentAudioId = opts.first?.id - } - } else { - audioOptions = [] + let proxyTracks = ClearKeyDashProxy.shared.availableAudioTracks() + if !proxyTracks.isEmpty { + audioOptions = proxyTracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) } audioOptionById = [:] + audioGroup = nil + currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? proxyTracks.first?.id + } else { + let audible = try await asset.loadMediaSelectionGroup(for: .audible) + audioGroup = audible + if let audible { + var opts: [AVMediaOptionItem] = [] + var map: [String: AVMediaSelectionOption] = [:] + for (i, opt) in audible.options.enumerated() { + let id = "a-\(i)-\(opt.displayName)" + let name = opt.displayName.isEmpty ? "Audio \(i + 1)" : opt.displayName + opts.append(AVMediaOptionItem(id: id, name: name)) + map[id] = opt + } + audioOptions = opts + audioOptionById = map + if let selected = item.selectedMediaOption(in: audible), + let match = map.first(where: { $0.value == selected })?.key { + currentAudioId = match + } else { + currentAudioId = opts.first?.id + } + } else { + audioOptions = [] + audioOptionById = [:] + } } let legible = try await asset.loadMediaSelectionGroup(for: .legible) @@ -325,6 +366,12 @@ final class AVLivePlayerController: NSObject, ObservableObject { private func fail(from error: Error?, gen: Int) { guard generation == gen, !stopping else { return } + let ns = (error as NSError?) + // Playlist-mismatch tijdens live/audio-wissel — negeren als we nog (opnieuw) laden. + if let ns, ns.domain == "CoreMediaErrorDomain", ns.code == -12312 { + proxyLogState("ignore -12312: \(ns.localizedDescription)") + return + } hasError = true if let error { errorMessage = dutchAVError(error) @@ -333,13 +380,24 @@ final class AVLivePlayerController: NSObject, ObservableObject { } else { errorMessage = "Kon stream niet openen" } + // Toon geen -12312 als enige melding. + if let msg = errorMessage, msg.contains("-12312") { + errorMessage = nil + hasError = false + return + } stateText = "Fout" isPlaying = false } private func bestErrorMessage(for item: AVPlayerItem) -> String? { - if let err = item.error { return dutchAVError(err) } + if let err = item.error { + let ns = err as NSError + if ns.domain == "CoreMediaErrorDomain", ns.code == -12312 { return nil } + return dutchAVError(err) + } if let events = item.errorLog()?.events, let last = events.last { + if last.errorStatusCode == -12312 { return nil } let code = last.errorStatusCode let comment = last.errorComment ?? last.errorDomain return "Afspeelfout (\(code)): \(comment)" @@ -394,24 +452,55 @@ final class AVLivePlayerController: NSObject, ObservableObject { } } -struct AVLivePlayerView: UIViewControllerRepresentable { +/// AVPlayerLayer i.p.v. AVPlayerViewController: +/// - schaalt mee bij rotatie zonder seek/rebind (geen haperen) +/// - voorkomt ESPN “alleen audio” na landscape (AVPlayerViewController-compositor-bug) +struct AVLivePlayerView: UIViewRepresentable { let controller: AVLivePlayerController - func makeUIViewController(context: Context) -> AVPlayerViewController { - let vc = AVPlayerViewController() - vc.player = controller.player - vc.showsPlaybackControls = false - vc.videoGravity = .resizeAspect - vc.allowsPictureInPicturePlayback = true - if #available(iOS 14.2, *) { - vc.canStartPictureInPictureAutomaticallyFromInline = true - } - return vc + func makeUIView(context: Context) -> AVLivePlayerLayerView { + let view = AVLivePlayerLayerView() + view.player = controller.player + return view } - func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { - if vc.player !== controller.player { - vc.player = controller.player + func updateUIView(_ uiView: AVLivePlayerLayerView, context: Context) { + if uiView.player !== controller.player { + uiView.player = controller.player } } + + static func dismantleUIView(_ uiView: AVLivePlayerLayerView, coordinator: ()) { + uiView.player = nil + } +} + +final class AVLivePlayerLayerView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + + private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } + + var player: AVPlayer? { + get { playerLayer.player } + set { + playerLayer.player = newValue + playerLayer.videoGravity = .resizeAspect + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + isUserInteractionEnabled = false + playerLayer.videoGravity = .resizeAspect + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + playerLayer.frame = bounds + } } diff --git a/apps/ios/VMC/Playback/AesCtr.swift b/apps/ios/VMC/Playback/AesCtr.swift index f0cc2b1..b608725 100644 --- a/apps/ios/VMC/Playback/AesCtr.swift +++ b/apps/ios/VMC/Playback/AesCtr.swift @@ -51,7 +51,11 @@ enum AesCtr { return out } - /// CBCS pattern: decrypt `crypt` 16-byte blocks, skip `skip` blocks; leftover <16 clear. + /// CBCS pattern decrypt — gelijk aan Chromium `DecryptWithPattern` (ISO 23001-7 §10.4): + /// - IV wordt één keer gezet per protected range (subsample), niet per pattern. + /// - Crypt-blokken vormen één CBC-keten; skip-blokken blijven clear en updaten de IV niet. + /// - Pattern 0:0 = alle volle blokken encrypted (geen pattern). + /// - Partiële trailing bytes blijven clear. static func cryptCbcs( key: Data, iv: Data, @@ -60,31 +64,59 @@ enum AesCtr { 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.. 0 else { return data } + + var cryptBlocks = cryptByteBlock + var skipBlocks = skipByteBlock + // ISO/Chromium: 0:0 → alles encrypted als één CBC-run. + if cryptBlocks == 0 && skipBlocks == 0 { + cryptBlocks = totalBlocks + skipBlocks = 0 } - return result + if cryptBlocks <= 0 { return data } + + var out = data + var blockIv = Data(count: 16) + let ivn = min(iv.count, 16) + blockIv.replaceSubrange(0..= blockSize { + blockIv.replaceSubrange(0.. Data? { diff --git a/apps/ios/VMC/Playback/CencMp4Decryptor.swift b/apps/ios/VMC/Playback/CencMp4Decryptor.swift index 06e7d1f..c488144 100644 --- a/apps/ios/VMC/Playback/CencMp4Decryptor.swift +++ b/apps/ios/VMC/Playback/CencMp4Decryptor.swift @@ -19,6 +19,7 @@ enum CencMp4Decryptor { @discardableResult static func process(data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) -> Bool { guard !data.isEmpty, !keys.isEmpty else { return false } + // Nooit crashen op corrupte segmenten — liever ongedecrypt doorgeven dan app-kill. var touched = false if containsBox(data, type: "moov") { harvestAndClearInit(&data, keys: keys, state: &state) @@ -67,19 +68,45 @@ enum CencMp4Decryptor { let containers: Set = [ "moov", "trak", "mdia", "minf", "stbl", "moof", "traf", "mvex", "edts", "sinf", "schi", "dinf", "udta", "meta", + // Sample-entries bevatten sinf/avcC; stsd is FullBox met entry_count. + "stsd", "dref", + "encv", "enca", + "avc1", "avc3", "hev1", "hvc1", "dvh1", "mp4a", ] while offset + 8 <= to { 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) + let childFrom = walkChildrenStart(box) + if childFrom < box.payloadEnd { + walk(data, from: childFrom, to: box.payloadEnd, recursive: true, visit: visit) + } } offset = box.payloadEnd if box.size <= 0 { break } } } + /// Skip FullBox / SampleEntry vaste headers zodat child-boxes (sinf, tenc, avcC) bereikbaar zijn. + private static func walkChildrenStart(_ box: Box) -> Int { + switch box.type { + case "stsd", "dref": + // FullBox (4) + entry_count (4) + return box.payloadStart + 8 + case "meta": + return box.payloadStart + 4 + case "encv", "avc1", "avc3", "hev1", "hvc1", "dvh1": + // VisualSampleEntry: boxhdr(8) + SampleEntry(8) + visual(70) = 86 + return box.start + 86 + case "enca", "mp4a": + // AudioSampleEntry: boxhdr(8) + SampleEntry(8) + audio(20) = 36 + return box.start + 36 + default: + return box.payloadStart + } + } + private static func readBox(_ data: Data, at offset: Int, limit: Int) -> Box? { guard offset + 8 <= limit else { return nil } var size = Int(readU32(data, offset)) @@ -98,19 +125,23 @@ enum CencMp4Decryptor { } private static func readU16(_ data: Data, _ o: Int) -> UInt16 { - UInt16(data[o]) << 8 | UInt16(data[o + 1]) + guard o >= 0, o + 2 <= data.count else { return 0 } + return UInt16(data[o]) << 8 | UInt16(data[o + 1]) } private static func readU32(_ data: Data, _ o: Int) -> UInt32 { - UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3]) + guard o >= 0, o + 4 <= data.count else { return 0 } + return UInt32(data[o]) << 24 | UInt32(data[o + 1]) << 16 | UInt32(data[o + 2]) << 8 | UInt32(data[o + 3]) } private static func readU64(_ data: Data, _ o: Int) -> UInt64 { - (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4)) + guard o >= 0, o + 8 <= data.count else { return 0 } + return (UInt64(readU32(data, o)) << 32) | UInt64(readU32(data, o + 4)) } private static func readFourCC(_ data: Data, _ o: Int) -> String { - String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????" + guard o >= 0, o + 4 <= data.count else { return "????" } + return String(bytes: data[o..<(o + 4)], encoding: .ascii) ?? "????" } private static func writeFourCC(_ data: inout Data, _ o: Int, _ s: String) { @@ -122,6 +153,10 @@ enum CencMp4Decryptor { // MARK: - Init segment private static func harvestAndClearInit(_ data: inout Data, keys: ClearKeyKeyMap, state: inout SessionState) { + // Elke init is de bron van waarheid voor deze lane (CMAF track_ID is vaak 1 voor A én V). + state.byTrackId.removeAll(keepingCapacity: true) + state.fallback = nil + // Track id from tkhd, crypto from sinf/tenc/frma/schm inside same trak. for trak in findBoxes(data, type: "trak", recursive: true) { var trackId: UInt32? @@ -221,7 +256,12 @@ enum CencMp4Decryptor { if let trackId { state.byTrackId[trackId] = crypto } - state.fallback = crypto + // Fallback: prefer video-achtige crypto (cbcs / pattern) boven pure audio-cenc. + let looksVideo = crypto.scheme == "cbcs" || crypto.scheme == "cbc1" + || crypto.cryptByteBlock > 0 || crypto.constantIv != nil + if state.fallback == nil || looksVideo { + state.fallback = crypto + } _ = keys.key(forKid: crypto.kid) } } @@ -272,10 +312,14 @@ enum CencMp4Decryptor { var dataOffsetPresent = false var sampleSizes: [Int] = [] var sencSamples: [SencSample] = [] - var perSampleIvSize = state.fallback?.perSampleIvSize ?? 8 + var perSampleIvSize = 8 + if let known = state.byTrackId.values.first, state.byTrackId.count == 1 { + perSampleIvSize = known.perSampleIvSize + } // tfhd for tfhd in findBoxesInRange(data, type: "tfhd", from: traf.payloadStart, to: traf.payloadEnd) { + guard tfhd.payloadLength >= 8 else { continue } let versionFlags = readU32(data, tfhd.payloadStart) let flags = versionFlags & 0x00FF_FFFF var o = tfhd.payloadStart + 4 @@ -284,11 +328,16 @@ enum CencMp4Decryptor { perSampleIvSize = crypto.perSampleIvSize } if flags & 0x000001 != 0 { // base-data-offset + guard o + 8 <= tfhd.payloadEnd else { break } baseDataOffset = Int64(readU64(data, o)); o += 8 dataOffsetPresent = true } - if flags & 0x000002 != 0 { o += 4 } // sample-description-index + if flags & 0x000002 != 0 { + guard o + 4 <= tfhd.payloadEnd else { break } + o += 4 + } if flags & 0x000008 != 0 { + guard o + 4 <= tfhd.payloadEnd else { break } defaultSampleSize = readU32(data, o); o += 4 } _ = versionFlags @@ -296,30 +345,42 @@ enum CencMp4Decryptor { // trun for trun in findBoxesInRange(data, type: "trun", from: traf.payloadStart, to: traf.payloadEnd) { + guard trun.payloadLength >= 8 else { continue } let versionFlags = readU32(data, trun.payloadStart) let version = Int(versionFlags >> 24) let flags = versionFlags & 0x00FF_FFFF var o = trun.payloadStart + 4 let sampleCount = Int(readU32(data, o)); o += 4 + guard sampleCount >= 0, sampleCount < 100_000 else { continue } var dataOffset: Int32 = 0 if flags & 0x000001 != 0 { + guard o + 4 <= trun.payloadEnd else { break } dataOffset = Int32(bitPattern: readU32(data, o)); o += 4 dataOffsetPresent = true } - if flags & 0x000004 != 0 { o += 4 } // first-sample-flags + if flags & 0x000004 != 0 { + guard o + 4 <= trun.payloadEnd else { break } + o += 4 + } sampleSizes.removeAll(keepingCapacity: true) for _ in 0.. 0, mdatCursor + size <= data.count else { break } @@ -398,10 +463,17 @@ enum CencMp4Decryptor { ) { sample = decrypted data.replaceSubrange(sampleRange, with: sample) + didDecrypt = true } } mdatCursor += size } + // AVPlayer/VLC mogen geen tweede decrypt-poging doen op clear samples. + if didDecrypt { + for senc in findBoxesInRange(data, type: "senc", from: traf.payloadStart, to: traf.payloadEnd) { + writeFourCC(&data, senc.start + 4, "free") + } + } } } @@ -466,11 +538,17 @@ enum CencMp4Decryptor { let saizFlags = readU32(data, saiz.payloadStart) & 0x00FF_FFFF var o = saiz.payloadStart + 4 - if saizFlags & 0x1 != 0 { o += 8 } // aux_info_type + parameter + if saizFlags & 0x1 != 0 { + guard o + 8 <= saiz.payloadEnd else { return [] } + o += 8 + } + guard o + 5 <= saiz.payloadEnd else { return [] } let defaultSize = Int(data[o]); o += 1 let count = Int(readU32(data, o)); o += 4 + guard count >= 0, count < 100_000 else { return [] } var sizes: [Int] = [] if defaultSize == 0 { + guard o + count <= saiz.payloadEnd else { return [] } for _ in 0.. Data? { + // Per subsample: IV reset (Chromium/ISO). Pattern chaint binnen de protected range. var out = sample if let subsamples { var cursor = 0 @@ -609,17 +688,23 @@ enum CencMp4Decryptor { sampleCount: Int, subsamples: [(clear: Int, protected: Int)]? ) -> [Range]? { + guard sampleCount > 0 else { return nil } guard let subsamples else { return [0..] = [] var cursor = 0 for sub in subsamples { - cursor += sub.clear - if sub.protected > 0 { - ranges.append(cursor..<(cursor + sub.protected)) + cursor += max(0, sub.clear) + let prot = max(0, sub.protected) + if prot > 0 { + let end = min(cursor + prot, sampleCount) + if cursor < end { + ranges.append(cursor..= sampleCount { break } } return ranges } diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index d652bd2..2b71417 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -30,14 +30,17 @@ final class ClearKeyDashProxy { } private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy") - /// Beschermt alle gedeelde session-state. payload() draait concurrent via Task{} — - /// zonder deze lock → data races / EXC_BAD_ACCESS. - private let stateLock = NSLock() + /// Beschermt alle gedeelde session-state. Recursive: mediaPlaylist → resolveTemplateURL + /// → withManifestQuery mag genest onder withState draaien (anders deadlock = app hangt). + private let stateLock = NSRecursiveLock() private let cryptoLock = NSLock() private var listener: NWListener? private var port: UInt16 = 0 private var remoteManifest: URL? - /// Auth-query van het manifest (bijv. Akamai hdnts) — plakken op relatieve segment-URLs. + /// Eind-URL na redirects (Odido: lb → edge-sessiepad). Segmenten resolven hiertegen en + /// refreshes blijven op deze URL, anders wisselt het sessiepad per refresh (403 / seq-breuk). + private var resolvedManifest: URL? + /// Auth-query van het manifest (bijv. Akamai `hdnts=…`) — plakken op relatieve segment-URLs. private var manifestQuery: String? private var keyMap: ClearKeyKeyMap? private var cryptoVideo = CencMp4Decryptor.SessionState() @@ -54,12 +57,13 @@ final class ClearKeyDashProxy { /// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312). private var segmentSeqByKey: [String: Int] = [:] private var nextStableSeq = 1 - /// Laatste niet-lege media-playlist per pad (lege refresh → -12312). + /// Laatste niet-lege media-playlist per pad (lege refresh → -12312 / [].) private var lastMediaPlaylistByPath: [String: String] = [:] - /// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien. + /// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien + /// (anders A/V MEDIA-SEQUENCE-desync → stall na ~8–10s). private var cachedMpdXML: String? private var cachedMpdAt: Date? - private let mpdCacheTTL: TimeInterval = 1.0 + private let mpdCacheTTL: TimeInterval = 0.8 private let urlSession: URLSession = { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 15 @@ -70,6 +74,7 @@ final class ClearKeyDashProxy { }() /// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt). + /// Voorheen alles via één gate → segmenten blokkeerden live-refresh → stall na ~10s + zware buffer. private let playlistGate = PayloadGate() private init() {} @@ -95,6 +100,7 @@ final class ClearKeyDashProxy { let name: String } + /// Audio-sporen uit de huidige DASH-sessie (voor UI; niet via HLS multi-rendition). func availableAudioTracks() -> [ProxyAudioTrack] { withState { dashAudioReps.enumerated().map { i, a in @@ -107,6 +113,7 @@ final class ClearKeyDashProxy { withState { dashAudioRep?.id } } + /// Wissel actief audio-spoor. Caller herlaadt AVPlayer-item (geen HLS rendition-switch). @discardableResult func setPreferredAudio(id: String) -> Bool { withState { @@ -116,6 +123,7 @@ final class ClearKeyDashProxy { cryptoLock.lock() cryptoAudio = CencMp4Decryptor.SessionState() cryptoLock.unlock() + // Alleen audio-playlist-cache legen — video MEDIA-SEQUENCE niet resetten (crash/stotter). lastMediaPlaylistByPath["/hls/a.m3u8"] = nil proxyLog.info("preferred audio → \(match.id, privacy: .public)") return true @@ -143,27 +151,30 @@ final class ClearKeyDashProxy { let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in queue.async { - self.stopLocked() - self.remoteManifest = manifestURL - self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery - self.keyMap = map - self.isHlsMode = hls - self.preferHlsOutput = preferHlsForAVPlayer - self.dashVideoRep = nil - self.dashAudioRep = nil - self.dashAudioReps = [] - self.segmentSeqByKey = [:] - self.nextStableSeq = 1 - self.lastMediaPlaylistByPath = [:] - self.cachedMpdXML = nil - self.cachedMpdAt = nil - self.cryptoLock.lock() - self.cryptoVideo = CencMp4Decryptor.SessionState() - self.cryptoAudio = CencMp4Decryptor.SessionState() - self.cryptoShared = CencMp4Decryptor.SessionState() - self.cryptoLock.unlock() - self.generation += 1 - let session = self.generation + self.withState { + self.stopLocked() + self.remoteManifest = manifestURL + self.resolvedManifest = nil + self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery + self.keyMap = map + self.isHlsMode = hls + self.preferHlsOutput = preferHlsForAVPlayer + self.dashVideoRep = nil + self.dashAudioRep = nil + self.dashAudioReps = [] + self.segmentSeqByKey = [:] + self.nextStableSeq = 1 + self.lastMediaPlaylistByPath = [:] + self.cachedMpdXML = nil + self.cachedMpdAt = nil + self.cryptoLock.lock() + self.cryptoVideo = CencMp4Decryptor.SessionState() + self.cryptoAudio = CencMp4Decryptor.SessionState() + self.cryptoShared = CencMp4Decryptor.SessionState() + self.cryptoLock.unlock() + self.generation += 1 + } + let session = self.withState { self.generation } let once = ResumeOnce() @@ -171,14 +182,14 @@ final class ClearKeyDashProxy { let params = NWParameters.tcp params.allowLocalEndpointReuse = true let listener = try NWListener(using: params, on: .any) - self.listener = listener + self.withState { self.listener = listener } listener.stateUpdateHandler = { [weak self] state in guard let self else { return } switch state { case .ready: let p = listener.port?.rawValue ?? 0 - self.port = p + self.withState { self.port = p } proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)") once.run { if p > 0 { @@ -326,6 +337,7 @@ final class ClearKeyDashProxy { listener = nil port = 0 remoteManifest = nil + resolvedManifest = nil manifestQuery = nil keyMap = nil isHlsMode = false @@ -489,13 +501,13 @@ final class ClearKeyDashProxy { let query = split.count > 1 ? split[1] : nil if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" { - let raw = try await fetch(remoteManifest) + let (raw, manifestBase) = try await fetchManifest(remoteManifest) if isHlsMode || Self.looksLikeHls(raw) { let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self) guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else { throw ProxyError.emptyManifest } - let rewritten = rewriteHls(text, playlistURL: remoteManifest) + let rewritten = rewriteHls(text, playlistURL: manifestBase) return (Data(rewritten.utf8), "application/vnd.apple.mpegurl") } let xml: String = { @@ -507,10 +519,10 @@ final class ClearKeyDashProxy { } // AVPlayer: DASH → HLS master (+ media playlists via /hls/*). if preferHlsOutput || pathOnly == "/manifest.m3u8" { - let hls = try withState { try dashToHlsMaster(xml, mpdURL: remoteManifest) } + let hls = try withState { try dashToHlsMaster(xml, mpdURL: manifestBase) } return (Data(hls.utf8), "application/vnd.apple.mpegurl") } - let rewritten = rewriteMpd(xml, mpdURL: remoteManifest) + let rewritten = rewriteMpd(xml, mpdURL: manifestBase) if !rewritten.localizedCaseInsensitiveContains(" Data { + try await fetchFollowingRedirects(url).data + } + + private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) { var req = URLRequest(url: url) req.timeoutInterval = 15 req.cachePolicy = .reloadIgnoringLocalCacheData @@ -851,7 +867,25 @@ final class ClearKeyDashProxy { if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { throw ProxyError.upstream(http.statusCode) } - return data + return (data, resp.url ?? url) + } + + /// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel. + private func fetchManifest(_ original: URL) async throws -> (data: Data, baseURL: URL) { + let sticky = withState { resolvedManifest } + if let sticky, sticky != original { + if let got = try? await fetchFollowingRedirects(sticky) { + withState { resolvedManifest = got.finalURL } + return (got.data, got.finalURL) + } + proxyLog.error("sticky manifest failed — re-resolving via original URL") + } + let got = try await fetchFollowingRedirects(original) + withState { resolvedManifest = got.finalURL } + if got.finalURL != original { + proxyLog.info("manifest redirected to \(got.finalURL.host ?? "?", privacy: .public)") + } + return (got.data, got.finalURL) } private func rewriteMpd(_ xml: String, mpdURL: URL) -> String { @@ -881,8 +915,8 @@ final class ClearKeyDashProxy { 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) + let content = Self.xmlUnescape(ns.substring(with: match.range(at: 1)) + .trimmingCharacters(in: .whitespacesAndNewlines)) if content.contains("127.0.0.1:\(port)") { continue } @@ -1309,12 +1343,12 @@ final class ClearKeyDashProxy { let s = String(block[initMatch]) if let q1 = s.range(of: "sourceURL=\"")?.upperBound, let q2 = s[q1...].firstIndex(of: "\"") { - out.initialization = String(s[q1..= 2 else { continue } - let media = ns.substring(with: m.range(at: 1)) + let media = xmlUnescape(ns.substring(with: m.range(at: 1))) if !media.isEmpty { out.segmentListMedia.append(media) } } } @@ -1338,7 +1372,18 @@ final class ClearKeyDashProxy { } let block = String(xml[r]) guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil } - return String(block[block.index(after: start).. String { + guard s.contains("&") else { return s } + return s + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "&", with: "&") } private static func xmlAttr(_ attrs: String, _ name: String) -> String? { @@ -1351,7 +1396,7 @@ final class ClearKeyDashProxy { let ns = attrs as NSString if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)), m.numberOfRanges >= 2 { - return ns.substring(with: m.range(at: 1)) + return xmlUnescape(ns.substring(with: m.range(at: 1))) } } return nil @@ -1570,19 +1615,21 @@ final class ClearKeyDashProxy { let remote = withState { remoteManifest } guard let remoteManifest = remote else { throw ProxyError.notStarted } - let cached: String? = withState { + let cached: (xml: String, base: URL)? = withState { if let xml = cachedMpdXML, let at = cachedMpdAt, Date().timeIntervalSince(at) < mpdCacheTTL { - return xml + return (xml, resolvedManifest ?? remoteManifest) } return nil } let xml: String + let manifestBase: URL if let cached { - xml = cached + xml = cached.xml + manifestBase = cached.base } else { - let raw = try await fetch(remoteManifest) + let (raw, base) = try await fetchManifest(remoteManifest) let fetched: String = { if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s } return String(decoding: raw, as: UTF8.self) @@ -1592,9 +1639,10 @@ final class ClearKeyDashProxy { cachedMpdAt = Date() } xml = fetched + manifestBase = base } - let reps = parseDashReps(xml, mpdURL: remoteManifest) + let reps = parseDashReps(xml, mpdURL: manifestBase) let videos = reps.filter { !$0.isAudio }.map(\.rep) let audios = reps.filter(\.isAudio).map(\.rep) @@ -1827,4 +1875,3 @@ final class ClearKeyDashProxy { } } } - diff --git a/apps/ios/VMC/Playback/ClearKeyMaterial.swift b/apps/ios/VMC/Playback/ClearKeyMaterial.swift index 642fecc..b5ce7a9 100644 --- a/apps/ios/VMC/Playback/ClearKeyMaterial.swift +++ b/apps/ios/VMC/Playback/ClearKeyMaterial.swift @@ -22,16 +22,27 @@ struct ClearKeyKeyMap { var isEmpty: Bool { keysByKid.isEmpty } - /// Eerste key (streams hebben meestal één kid/key-paar). - var primaryKey: Data? { keysByKid.values.first } + /// Unieke key-waarden in de map (meestal 1). + var uniqueKeyCount: Int { + Set(keysByKid.values.map { $0.base64EncodedString() }).count + } + + /// Eerste key — alleen veilig als er precies één unieke key is. + var primaryKey: Data? { + guard uniqueKeyCount == 1 else { return nil } + return keysByKid.values.first + } func key(forKid kid: Data) -> Data? { - 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 + if kid.count == 16 { + 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() + if let k = keysByKid[swappedHex] { return k } + } + // Streams met één key (Android parity): gebruik die key als KID-match faalt. + return primaryKey } static func sanitizeHex(_ raw: String) -> String { diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 347e326..4bf8224 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -18,6 +18,8 @@ struct PlayRequest: Identifiable { let watchSessionId: String? let watchKind: String? let watchId: String? + /// Provider-/titel-logo voor laad-animatie (events, films, series). + let logoUrl: String? static func vod( url: URL, @@ -28,13 +30,15 @@ struct PlayRequest: Identifiable { episodeId: String?, seriesId: String?, startMs: Int64, - subtitleFileURL: URL? = nil + subtitleFileURL: URL? = nil, + logoUrl: String? = nil ) -> PlayRequest { PlayRequest( url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs, isLive: false, fallbackUrl: nil, subtitleFileURL: subtitleFileURL, - format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil + format: nil, drm: nil, watchSessionId: nil, watchKind: nil, watchId: nil, + logoUrl: logoUrl ) } @@ -46,14 +50,15 @@ struct PlayRequest: Identifiable { drm: DrmInfo? = nil, watchSessionId: String? = nil, watchKind: String? = nil, - watchId: String? = nil + watchId: String? = nil, + logoUrl: String? = nil ) -> PlayRequest { PlayRequest( url: url, title: title, mediaType: "live", mediaFileId: nil, movieId: nil, episodeId: nil, seriesId: nil, startMs: 0, isLive: true, fallbackUrl: fallback, subtitleFileURL: nil, format: format, drm: drm, watchSessionId: watchSessionId, - watchKind: watchKind, watchId: watchId + watchKind: watchKind, watchId: watchId, logoUrl: logoUrl ) } @@ -69,7 +74,7 @@ struct PlayerScreen: View { let request: PlayRequest - /// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact). + /// ClearKey: DASH→AVPlayer+proxy; native HLS (ESPN)→VLC+proxy. FTA live + VOD → VLC. @StateObject private var vlc = VLCPlayerController() @StateObject private var av = AVLivePlayerController() @State private var controlsVisible = true @@ -85,14 +90,25 @@ struct PlayerScreen: View { @State private var subsError: String? @State private var pendingStop: Task? + /// ClearKey live: DASH→AVPlayer; native HLS (ESPN)→VLC (AV bevriest bij rotatie). + @State private var clearKeyPreferAV = true + private var isLive: Bool { request.isLive } - private var useClearKeyAV: Bool { request.isLive && request.needsClearKey } + private var useClearKeyAV: Bool { request.isLive && request.needsClearKey && clearKeyPreferAV } private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText } private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying } private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError } private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage } + /// Alleen start/buffer vóór first frame — overlay mag playback niet blokkeren. + private var showLoadingChrome: Bool { + guard !playerHasError else { return false } + if loading && !isPlaying { return true } + if useClearKeyAV, !av.hasVideo, !av.isPlaying { return true } + return false + } + var body: some View { ZStack { Color.black.ignoresSafeArea() @@ -116,18 +132,16 @@ struct PlayerScreen: View { .ignoresSafeArea() } - // Speler eet touches op — vang tikken altijd zelf af. Color.clear .contentShape(Rectangle()) .ignoresSafeArea() .onTapGesture { toggleControls() } .allowsHitTesting(!controlsVisible) - if loading && !isPlaying { - ProgressView(stateText) - .tint(.white) - .foregroundStyle(.white) + if showLoadingChrome { + PlayerLoadingChrome(logoUrl: request.logoUrl, status: stateText) .allowsHitTesting(false) + .transition(.opacity) } if let error { @@ -179,6 +193,19 @@ struct PlayerScreen: View { } } + /// Letterbox/pillarbox buiten de VLC-surface zodat FreeType onderaan het beeld zit. + private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize { + guard container.width > 1, container.height > 1, aspect > 0 else { return container } + let viewAspect = container.width / container.height + if viewAspect > aspect { + let h = container.height + return CGSize(width: h * aspect, height: h) + } else { + let w = container.width + return CGSize(width: w, height: w / aspect) + } + } + private var controlsOverlay: some View { VStack(spacing: 0) { HStack { @@ -201,7 +228,7 @@ struct PlayerScreen: View { .foregroundStyle(.white.opacity(0.7)) } Spacer() - if !useClearKeyAV && vlc.pipPossible { + if !isLive, vlc.pipPossible { Button { vlc.togglePictureInPicture() scheduleHide() @@ -215,18 +242,23 @@ struct PlayerScreen: View { .background(.black.opacity(0.4), in: Circle()) } } - if !request.isLive { - Button { - showTracks = true - scheduleHide(cancelOnly: true) - Task { await loadSubtitles() } - } label: { - Image(systemName: "captions.bubble.fill") - .font(.title3) - .foregroundStyle(.white) - .padding(12) - .background(.black.opacity(0.4), in: Circle()) + Button { + showTracks = true + scheduleHide(cancelOnly: true) + if useClearKeyAV { + av.refreshMediaOptions() + } else { + vlc.refreshTracks() } + if !isLive { + Task { await loadSubtitles() } + } + } label: { + Image(systemName: "captions.bubble.fill") + .font(.title3) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.4), in: Circle()) } } .padding(.horizontal, 16) @@ -237,7 +269,7 @@ struct PlayerScreen: View { .onTapGesture { hideControls() } VStack(spacing: 14) { - if !request.isLive { + if !isLive { Slider( value: Binding( get: { Double(vlc.position) }, @@ -258,7 +290,7 @@ struct PlayerScreen: View { } HStack(spacing: 36) { - if !request.isLive { + if !isLive { Button { vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0)) scheduleHide() @@ -275,7 +307,7 @@ struct PlayerScreen: View { .foregroundStyle(.white) .shadow(radius: 8) } - if !request.isLive { + if !isLive { Button { vlc.seek(toMs: vlc.currentPositionMs() + 10_000) scheduleHide() @@ -295,86 +327,148 @@ struct PlayerScreen: View { private var tracksSheet: some View { NavigationStack { VStack(spacing: 0) { - Picker("", selection: $trackTab) { - Text("In bestand").tag(0) - Text("Downloaden").tag(1) - Text("Audio").tag(2) - } - .pickerStyle(.segmented) - .padding() - - if trackTab == 0 { - List { - ForEach(vlc.subtitleTracks) { track in - Button { - vlc.selectSubtitle(id: track.id) - showTracks = false - scheduleHide() - } label: { - HStack { - Text(track.name).foregroundStyle(McColors.text) - Spacer() - if track.id == vlc.currentSubtitleId { - Image(systemName: "checkmark").foregroundStyle(McColors.accent) - } - } - } - .listRowBackground(McColors.bgElevated) - } + if useClearKeyAV { + Picker("", selection: $trackTab) { + Text("Audio").tag(0) + Text("Ondertitel").tag(1) } - .scrollContentBackground(.hidden) - } else if trackTab == 1 { - Group { - if subsLoading { - ProgressView("Zoeken…").padding(.top, 40) - } else if let subsError { - Text(subsError).foregroundStyle(McColors.danger).padding() - } else if subtitleHits.isEmpty { - ContentUnavailableView("Geen downloads", systemImage: "arrow.down.circle", description: Text("Geen OpenSubtitles-resultaten.")) - } else { - List(subtitleHits) { hit in + .pickerStyle(.segmented) + .padding() + + if trackTab == 0 { + List { + if av.audioOptions.isEmpty { + Text("Geen audio-sporen gevonden") + .foregroundStyle(McColors.textDim) + .listRowBackground(McColors.bgElevated) + } + ForEach(av.audioOptions) { track in Button { - Task { await applySubtitle(hit) } + av.selectAudio(id: track.id) + showTracks = false + scheduleHide() } label: { - VStack(alignment: .leading, spacing: 4) { - Text(hit.release ?? hit.fileName ?? "Ondertitel") - .foregroundStyle(McColors.text) - .lineLimit(2) - HStack(spacing: 8) { - Text((hit.language ?? "?").uppercased()) - if hit.hearingImpaired == true { Text("SDH") } - if let n = hit.downloadCount { Text("↓\(n)") } + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == av.currentAudioId { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) } - .font(McFonts.caption) - .foregroundStyle(McColors.textDim) } } .listRowBackground(McColors.bgElevated) } - .scrollContentBackground(.hidden) } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(vlc.audioTracks) { track in - Button { - vlc.selectAudio(id: track.id) - showTracks = false - scheduleHide() - } label: { - HStack { - Text(track.name).foregroundStyle(McColors.text) - Spacer() - if track.id == vlc.currentAudioId { - Image(systemName: "checkmark").foregroundStyle(McColors.accent) + .scrollContentBackground(.hidden) + } else { + List { + ForEach(av.subtitleOptions) { track in + Button { + av.selectSubtitle(id: track.id == "off" ? nil : track.id) + showTracks = false + scheduleHide() + } label: { + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == (av.currentSubtitleId ?? "off") { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) + } } } + .listRowBackground(McColors.bgElevated) } - .listRowBackground(McColors.bgElevated) } + .scrollContentBackground(.hidden) + } + } else { + Picker("", selection: $trackTab) { + Text("In bestand").tag(0) + Text("Downloaden").tag(1) + Text("Audio").tag(2) + } + .pickerStyle(.segmented) + .padding() + + if trackTab == 0 { + List { + ForEach(vlc.subtitleTracks) { track in + Button { + vlc.selectSubtitle(id: track.id) + showTracks = false + scheduleHide() + } label: { + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == vlc.currentSubtitleId { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) + } + } + } + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + } else if trackTab == 1 { + Group { + if isLive { + ContentUnavailableView( + "Alleen bij VOD", + systemImage: "arrow.down.circle", + description: Text("OpenSubtitles is beschikbaar bij films/series.") + ) + } else if subsLoading { + ProgressView("Zoeken…").padding(.top, 40) + } else if let subsError { + Text(subsError).foregroundStyle(McColors.danger).padding() + } else if subtitleHits.isEmpty { + ContentUnavailableView("Geen downloads", systemImage: "arrow.down.circle", description: Text("Geen OpenSubtitles-resultaten.")) + } else { + List(subtitleHits) { hit in + Button { + Task { await applySubtitle(hit) } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(hit.release ?? hit.fileName ?? "Ondertitel") + .foregroundStyle(McColors.text) + .lineLimit(2) + HStack(spacing: 8) { + Text((hit.language ?? "?").uppercased()) + if hit.hearingImpaired == true { Text("SDH") } + if let n = hit.downloadCount { Text("↓\(n)") } + } + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + } + .listRowBackground(McColors.bgElevated) + } + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(vlc.audioTracks) { track in + Button { + vlc.selectAudio(id: track.id) + showTracks = false + scheduleHide() + } label: { + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == vlc.currentAudioId { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) + } + } + } + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) } - .scrollContentBackground(.hidden) } } .background(McColors.bg) @@ -385,7 +479,14 @@ struct PlayerScreen: View { Button("Sluiten") { showTracks = false } } } - .onAppear { vlc.refreshTracks() } + .onAppear { + if useClearKeyAV { + av.refreshMediaOptions() + trackTab = 0 + } else { + vlc.refreshTracks() + } + } } } @@ -441,25 +542,72 @@ struct PlayerScreen: View { private func start() async { loading = true error = nil - if useClearKeyAV { + if request.isLive && request.needsClearKey { let keys = request.drm?.keys ?? [] - await av.play( - url: request.url, - fallback: request.fallbackUrl, - format: request.format, - clearKeys: keys - ) - let maxWaitNs: UInt64 = 18_000_000_000 - let stepNs: UInt64 = 200_000_000 - var waited: UInt64 = 0 - while waited < maxWaitNs && !av.hasError { - if av.isPlaying { break } - try? await Task.sleep(nanoseconds: stepNs) - waited += stepNs - } - if !av.isPlaying || av.hasError { - error = av.errorMessage - ?? "Kon deze ClearKey-livestream niet starten." + let fmt = (request.format ?? "").lowercased() + // ESPN e.d.: native HLS + aparte AUDIO-rendition → AVPlayer bevriest beeld bij rotatie. + // DASH→HLS (Prime/HBO/Viaplay/Ziggo) blijft op AVPlayer (rotatie is daar vloeiend). + let nativeHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(request.url) + if nativeHls { + clearKeyPreferAV = false + await vlc.playLive( + url: request.url, + fallback: request.fallbackUrl, + format: request.format, + clearKeys: keys, + preferHls: true + ) + let maxWaitNs: UInt64 = 18_000_000_000 + let stepNs: UInt64 = 200_000_000 + var waited: UInt64 = 0 + while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError { + try? await Task.sleep(nanoseconds: stepNs) + waited += stepNs + } + if vlc.hasError || !vlc.isPlaying { + error = vlc.errorMessage ?? "Kon deze ClearKey-livestream niet starten." + } + } else { + clearKeyPreferAV = true + await av.play( + url: request.url, + fallback: request.fallbackUrl, + format: request.format, + clearKeys: keys + ) + let maxWaitNs: UInt64 = 18_000_000_000 + let stepNs: UInt64 = 200_000_000 + var waited: UInt64 = 0 + while waited < maxWaitNs && !av.hasError { + if av.isPlaying { break } + try? await Task.sleep(nanoseconds: stepNs) + waited += stepNs + } + if av.isPlaying && !av.hasError { + loading = false + return + } + let avFailHint = av.errorMessage + av.stop() + clearKeyPreferAV = false + try? await Task.sleep(nanoseconds: 250_000_000) + await vlc.playLive( + url: request.url, + fallback: request.fallbackUrl, + format: request.format, + clearKeys: keys, + preferHls: true + ) + waited = 0 + while waited < 8_000_000_000 && !vlc.isPlaying && !vlc.hasError { + try? await Task.sleep(nanoseconds: stepNs) + waited += stepNs + } + if vlc.hasError || !vlc.isPlaying { + error = vlc.errorMessage + ?? avFailHint + ?? "Kon deze ClearKey-livestream niet starten." + } } } else if isLive { await vlc.playLive( @@ -477,6 +625,8 @@ struct PlayerScreen: View { } if vlc.hasError { error = vlc.errorMessage ?? "Kon deze livestream niet starten." + } else if !vlc.isPlaying { + error = vlc.errorMessage ?? "Livestream startte niet (geen beeld/geluid)." } } else { vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL) @@ -560,18 +710,6 @@ struct PlayerScreen: View { subsError = error.localizedDescription } } - - private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize { - guard container.width > 0, container.height > 0, aspect > 0 else { return container } - let containerAspect = container.width / container.height - if containerAspect > aspect { - let h = container.height - return CGSize(width: h * aspect, height: h) - } else { - let w = container.width - return CGSize(width: w, height: w / aspect) - } - } } struct PlayLauncher: View { @@ -583,6 +721,7 @@ struct PlayLauncher: View { let episodeId: String? let seriesId: String? let startMs: Int64 + var logoUrl: String? = nil @State private var request: PlayRequest? @State private var error: String? @@ -594,7 +733,7 @@ struct PlayLauncher: View { if let request { PlayerScreen(request: request) } else if loading { - ProgressView("Stream voorbereiden…").tint(.white).foregroundStyle(.white) + PlayerLoadingChrome(logoUrl: logoUrl, status: "Stream voorbereiden…") } else if let error { VStack(spacing: 12) { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding() @@ -634,7 +773,8 @@ struct PlayLauncher: View { request = .vod( url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs, - subtitleFileURL: subtitleURL + subtitleFileURL: subtitleURL, + logoUrl: logoUrl ) loading = false } catch { @@ -643,3 +783,65 @@ struct PlayLauncher: View { } } } + +/// Centraal logo (provider / film / serie) met zachte pulse i.p.v. kale ProgressView. +struct PlayerLoadingChrome: View { + let logoUrl: String? + let status: String + @State private var pulse = false + @State private var glow = false + + private var hasLogo: Bool { + guard let logoUrl, !logoUrl.isEmpty, URL(string: logoUrl) != nil else { return false } + return true + } + + var body: some View { + ZStack { + Color.black.opacity(0.55).ignoresSafeArea() + + VStack(spacing: 22) { + if hasLogo { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(glow ? 0.18 : 0.06), + Color.clear, + ], + center: .center, + startRadius: 8, + endRadius: 140 + ) + ) + .frame(width: 280, height: 280) + .scaleEffect(glow ? 1.08 : 0.92) + + RemoteImage(url: logoUrl, contentMode: .fit, opaqueBackground: false) + .frame(maxWidth: 240, maxHeight: 96) + .scaleEffect(pulse ? 1.04 : 0.96) + .opacity(pulse ? 1.0 : 0.72) + .shadow(color: .black.opacity(0.55), radius: 18, y: 8) + } + .onAppear { + withAnimation(.easeInOut(duration: 1.25).repeatForever(autoreverses: true)) { + pulse = true + glow = true + } + } + } else { + ProgressView() + .tint(.white) + .scaleEffect(1.2) + } + + Text(status) + .font(McFonts.caption) + .foregroundStyle(.white.opacity(0.85)) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + } + } + } +} diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index 332419a..bd68b45 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -32,9 +32,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private let drawableHost = VLCPipDrawableHost() /// Plain UIView-target voor live/ClearKey (PiP sample-buffer-vout breekt DASH/proxy). private weak var plainVideoView: UIView? + /// Sterke ref zolang er gespeeld wordt — voorkomt crash als SwiftUI de surface al freeggeeft. + private var retainedSurface: UIView? private var lengthMs: Int32 = 0 private var tick: AnyCancellable? private var clearKeyProxyActive = false + private var clearKeyProxySession = 0 private var liveMode = false private var livePlayURL: URL? private var livePlayFormat: String? @@ -45,9 +48,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private var sceneObserver: NSObjectProtocol? /// Voorkomt herhaald `drawable=` bij SwiftUI-ticks (audio-zonder-beeld). private weak var attachedView: UIView? - /// VOD: plain UIView voor FreeType-subs; PiP-drawable alleen bij PiP-start. + /// VOD: altijd PiP-capable drawable (officieel VLCKit-patroon). Live/ClearKey: plain UIView. private var usePipDrawable = false private var stopping = false + private var stopGeneration = 0 override init() { // Shared library = alle demux/access plugins (DASH adaptive in VLC 4). @@ -64,45 +68,109 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if let sceneObserver { NotificationCenter.default.removeObserver(sceneObserver) } - mediaPlayer.drawable = nil mediaPlayer.stop() + mediaPlayer.drawable = nil if clearKeyProxyActive { - ClearKeyDashProxy.shared.stop() + ClearKeyDashProxy.shared.stop(session: clearKeyProxySession) } } func attach(to view: UIView) { - if attachedView === view { return } + guard !stopping else { return } attachedView = view plainVideoView = view + retainedSurface = view view.backgroundColor = .black - applyDrawable() + + // PiP-restore: container mag wisselen zonder mediaPlayer.drawable te resetten. + if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost { + drawableHost.attach(to: view) + return + } + if mediaPlayer.drawable as AnyObject? === view { + return + } + // Nooit mid-play herbinden als er al een geldige drawable hangt. + if mediaPlayer.drawable != nil, mediaPlayer.media != nil { + if usePipDrawable { + drawableHost.attach(to: view) + } + return + } + applyDrawable(force: mediaPlayer.drawable == nil) } - /// Live/ClearKey of VOD-inline → plain UIView (FreeType). PiP → sample-buffer drawable. - private func applyDrawable() { - guard let view = plainVideoView else { return } - let wantPip = usePipDrawable && !liveMode && !clearKeyProxyActive - if wantPip { + /// Layout-callback: bind alleen als size OK; sta herstel toe als eerder op 0×0 gebonden. + func surfaceDidLayout(_ view: UIView) { + guard !stopping else { return } + plainVideoView = view + attachedView = view + retainedSurface = view + guard view.bounds.width > 1, view.bounds.height > 1 else { return } + + if usePipDrawable, mediaPlayer.drawable as AnyObject? === drawableHost { drawableHost.attach(to: view) - if mediaPlayer.drawable as AnyObject? !== drawableHost { + return + } + if mediaPlayer.drawable == nil { + applyDrawable(force: true) + } + } + + /// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start). + private func applyDrawable(force: Bool = false) { + guard !stopping else { return } + guard let view = plainVideoView else { return } + // Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only). + guard view.bounds.width > 1, view.bounds.height > 1 else { return } + let wantPipHost = !liveMode && !clearKeyProxyActive + usePipDrawable = wantPipHost + if wantPipHost { + drawableHost.attach(to: view) + if force || mediaPlayer.drawable as AnyObject? !== drawableHost { mediaPlayer.drawable = drawableHost } + if pipWindow != nil { + pipPossible = AVPictureInPictureController.isPictureInPictureSupported() + } } else { + // ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play. + if mediaPlayer.drawable as AnyObject? === view, !force { return } + if mediaPlayer.media != nil, mediaPlayer.drawable != nil, !force { return } drawableHost.detachVideo() - if mediaPlayer.drawable as AnyObject? !== view { - mediaPlayer.drawable = view - } - if liveMode || clearKeyProxyActive { - pipPossible = false - } + mediaPlayer.drawable = view + pipPossible = false } } func startPictureInPicture() { - preparePipDrawableThen { - self.pipWindow?.startPictureInPicture() + guard !liveMode, !clearKeyProxyActive, !stopping else { return } + // FreeType-subs zitten niet in VLCKit sample-buffer PiP (Apple/VLC-beperking). + if currentSubtitleId != PlayerTrack.offId { + stateText = "PiP zonder ondertiteling" + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in + guard let self, !self.stopping, self.stateText == "PiP zonder ondertiteling" else { return } + self.stateText = self.isPlaying ? "Afspelen" : self.stateText + } } + if let pipWindow { + pipWindow.startPictureInPicture() + return + } + applyDrawable(force: true) + let gen = stopGeneration + var attempts = 0 + func tryStart() { + guard self.stopGeneration == gen, !self.stopping else { return } + guard !self.liveMode, !self.clearKeyProxyActive else { return } + attempts += 1 + if let win = self.pipWindow { + win.startPictureInPicture() + } else if attempts < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: tryStart) } func stopPictureInPicture() { @@ -114,34 +182,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg else { startPictureInPicture() } } - private func preparePipDrawableThen(_ work: @escaping () -> Void) { - if usePipDrawable, pipWindow != nil { - work() - return - } - usePipDrawable = true - applyDrawable() - // pictureInPictureReady komt async; kort wachten op pipWindow. - var attempts = 0 - func tryStart() { - attempts += 1 - if self.pipWindow != nil || attempts >= 12 { - work() - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: tryStart) - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: tryStart) - } - - private func restorePlainDrawableAfterPip() { - guard !liveMode, !clearKeyProxyActive, !stopping else { return } - usePipDrawable = false - applyDrawable() - applySubtitleStyle() - } - func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { + // Annuleer pending finishStop van eerdere/spurious onDisappear. + stopGeneration &+= 1 + stopping = false hasError = false errorMessage = nil stateText = "Verbinden…" @@ -149,9 +193,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg audioTracks = [] liveMode = false clearKeyProxyActive = false - usePipDrawable = false - applyDrawable() - // PiP-knop tonen; drawable wisselt pas bij PiP-start (FreeType blijft werken). + plainVideoView?.layoutIfNeeded() + applyDrawable(force: true) pipPossible = AVPictureInPictureController.isPictureInPictureSupported() guard let media = VLCMedia(url: url) else { hasError = true @@ -160,35 +203,38 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg return } media.delegate = self - media.addOption(":network-caching=2500") + media.addOption(":network-caching=2000") media.addOption(":http-reconnect") media.addOption(":avcodec-hw=any") - media.addOption(":sout-mux-caching=1500") + media.addOption(":sout-mux-caching=1200") media.addOption(":sub-track=0") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-bold=1") + media.addOption(":sub-margin=0") mediaPlayer.media = media - // VLCKit 4: parse is on VLCMediaParser, not VLCMedia _ = VLCMediaParser.shared().queue( media, - options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) // network + fetchLocal + options: VLCMediaParsingOptions(rawValue: 0x02 | 0x08) ) mediaPlayer.play() applySubtitleStyle() if startMs > 2000 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in - self?.seek(toMs: startMs) + guard let self, !self.stopping else { return } + self.seek(toMs: startMs) } } if let subtitleFileURL { DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in - self?.addExternalSubtitle(url: subtitleFileURL) + guard let self, !self.stopping else { return } + self.addExternalSubtitle(url: subtitleFileURL) } } startTicker() DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.refreshTracks() - self?.applySubtitleStyle() + guard let self, !self.stopping else { return } + self.refreshTracks() + self.applySubtitleStyle() } } @@ -196,8 +242,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg url: URL, fallback: URL? = nil, format: String? = nil, - clearKeys: [ClearKeyEntry] = [] + clearKeys: [ClearKeyEntry] = [], + preferHls: Bool = false ) async { + // Annuleer pending finishStop / delayed proxy-stop van vorige sessie. + stopGeneration &+= 1 + let playGen = stopGeneration + stopping = false hasError = false errorMessage = nil stateText = "Live laden…" @@ -206,19 +257,29 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg liveStartedAt = Date() liveMode = true usePipDrawable = false + pipPossible = false var playURL = url var playFormat = format clearKeyProxyActive = false + var proxySession = 0 if !clearKeys.isEmpty { do { stateText = "ClearKey laden…" - // Async: blokkeer de main thread niet (watchdog-crash). - // HLS (ESPN) én DASH (Prime) via dezelfde proxy. - let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys) + let started = try await ClearKeyDashProxy.shared.start( + manifestURL: url, + keys: clearKeys, + preferHlsForAVPlayer: preferHls + ) playURL = started.url - playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash" + proxySession = started.session + // Lokale clear-HLS of remote HLS. + if preferHls || ClearKeyDashProxy.isHlsURL(started.url) || ClearKeyDashProxy.isHlsURL(url) { + playFormat = "hls" + } else { + playFormat = "dash" + } clearKeyProxyActive = true } catch { hasError = true @@ -228,21 +289,48 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } } - applyDrawable() + guard stopGeneration == playGen, !stopping else { return } + + await waitForSurface(timeout: 2.0) + await MainActor.run { + plainVideoView?.layoutIfNeeded() + applyDrawable(force: true) + } + guard stopGeneration == playGen, !stopping else { return } + livePlayURL = playURL livePlayFormat = playFormat + clearKeyProxySession = proxySession 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 gen = stopGeneration + // Snellere fallback: IPTV-HLS faalt vaak stil; probeer binnen 3.5s de fallback. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + guard let self, self.stopGeneration == gen, !self.stopping else { return } + guard !self.everPlayed else { return } + guard !self.isPlaying || self.hasError else { return } + self.stateText = "Fallback…" self.startLiveMedia(url: fallback, format: nil) } } startTicker() } + private func waitForSurface(timeout: TimeInterval) async { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let ready = await MainActor.run { () -> Bool in + guard let view = plainVideoView else { return false } + return view.bounds.width > 1 && view.bounds.height > 1 + } + if ready { return } + try? await Task.sleep(nanoseconds: 40_000_000) + } + } + private func startLiveMedia(url: URL, format: String?) { + guard !stopping else { return } guard let media = VLCMedia(url: url) else { hasError = true errorMessage = "Kon live-stream niet openen." @@ -250,10 +338,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg return } media.delegate = self - Self.applyLiveOptions(to: media, url: url, format: format) + Self.applyLiveOptions(to: media, url: url, format: format, viaLocalProxy: clearKeyProxyActive) + // Drawable één keer vóór play — nooit opnieuw mid-stream (audio-only / crash). + if mediaPlayer.drawable == nil { + applyDrawable(force: true) + } mediaPlayer.media = media mediaPlayer.play() - applySubtitleStyle() } private func dutchClearKeyError(_ error: Error) -> String { @@ -274,16 +365,16 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } private func retryLiveIfNeeded() { - guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return } + guard liveMode, !everPlayed, liveRetryCount < 2 else { return } guard let url = livePlayURL else { return } let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date()) - guard elapsed < 12 else { return } + guard elapsed < 14 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 } + guard let self, self.liveMode, !self.stopping else { return } self.startLiveMedia(url: url, format: self.livePlayFormat) } } @@ -291,16 +382,27 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg private static func applyLiveOptions( to media: VLCMedia, url: URL, - format: String? + format: String?, + viaLocalProxy: Bool ) { - media.addOption(":network-caching=1800") + // Stabiele live-start: te lage caching + drop-late-frames liet streams meteen stoppen. + if viaLocalProxy { + media.addOption(":network-caching=1200") + media.addOption(":live-caching=1000") + } else { + media.addOption(":network-caching=1000") + media.addOption(":live-caching=800") + } media.addOption(":http-reconnect") - media.addOption(":live-caching=1500") media.addOption(":avcodec-hw=any") media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)") + // IPTV/CDN's weigeren soms exotic UA's. + media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21") // VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module). media.addOption(":adaptive-logic=highest") - media.addOption(":adaptive-use-access") + if !viaLocalProxy { + media.addOption(":adaptive-use-access") + } let fmt = (format ?? "").lowercased() let path = url.absoluteString.lowercased() @@ -356,13 +458,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg if pipActive { stopPictureInPicture() } - if usePipDrawable { - usePipDrawable = false - applyDrawable() - } _ = mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self else { return } + guard let self, !self.stopping else { return } self.refreshTracks() self.applySubtitleStyle() if let last = self.subtitleTracks.last(where: { $0.id != PlayerTrack.offId }) { @@ -383,7 +481,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg func refreshTracks() { // VLCKit 4 kan crashen op track-API tijdens error/opening — altijd defensief. - guard mediaPlayer.media != nil else { + guard !stopping, mediaPlayer.media != nil else { subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] audioTracks = [] return @@ -540,49 +638,81 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg func stop() { guard !stopping else { return } stopping = true + stopGeneration &+= 1 + let gen = stopGeneration + let proxySession = clearKeyProxySession tick?.cancel() tick = nil let proxyWasActive = clearKeyProxyActive + let wasPip = pipActive clearKeyProxyActive = false - - // Drawable los vóór stop — voorkomt crash als SwiftUI de UIView al vrijgeeft. - mediaPlayer.drawable = nil - drawableHost.detachVideo() - attachedView = nil - plainVideoView = nil - - if pipActive { - pipWindow?.stopPictureInPicture() - } - pipWindow = nil + clearKeyProxySession = 0 pipPossible = false - pipActive = false - usePipDrawable = false - mediaPlayer.stop() - mediaPlayer.media = nil - livePlayURL = nil - livePlayFormat = nil - liveStartedAt = nil - liveRetryCount = 0 - everPlayed = false - liveMode = false + // PiP eerst netjes stoppen en wachten — sync nil + media stop = EXC_BAD_ACCESS (SwiftVLC #20). + if wasPip { + pipWindow?.stopPictureInPicture() + pipActive = false + finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.85) + return + } + finishStop(gen: gen, proxyWasActive: proxyWasActive, proxySession: proxySession, delay: 0.05) + } - if proxyWasActive { - // Laat in-flight HTTP van VLC even afronden vóór listener-cancel. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - ClearKeyDashProxy.shared.stop() + private func finishStop(gen: Int, proxyWasActive: Bool, proxySession: Int, delay: TimeInterval) { + let work = { [weak self] in + guard let self else { return } + // Nieuwe play() heeft generation gebumpt of stopping gereset → niet meer tear-down'en. + guard self.stopGeneration == gen, self.stopping else { return } + + self.pipWindow = nil + self.pipActive = false + self.usePipDrawable = false + + self.mediaPlayer.stop() + self.mediaPlayer.media = nil + + self.mediaPlayer.drawable = nil + self.drawableHost.detachVideo() + self.attachedView = nil + self.plainVideoView = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self, self.stopGeneration == gen else { return } + self.retainedSurface = nil + } + + self.livePlayURL = nil + self.livePlayFormat = nil + self.liveStartedAt = nil + self.liveRetryCount = 0 + self.everPlayed = false + self.liveMode = false + self.isPlaying = false + self.hasError = false + self.errorMessage = nil + self.stateText = "Gestopt" + self.subtitleTracks = [PlayerTrack(id: PlayerTrack.offId, name: "Uit")] + self.audioTracks = [] + + if proxyWasActive, proxySession > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + ClearKeyDashProxy.shared.stop(session: proxySession) + } } } - stopping = false + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } else { + work() + } } // MARK: - VLCMediaPlayerDelegate (VLCKit 4) func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, !self.stopping else { return } self.isPlaying = self.mediaPlayer.isPlaying if self.pipPossible { self.invalidatePipPlaybackState() @@ -594,49 +724,65 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg self.hasError = false self.errorMessage = nil self.everPlayed = true - self.refreshTracks() - self.applySubtitleStyle() + // Geen drawable-rebind hier — dat maakte ClearKey audio-only + crash. + if !self.liveMode { + self.refreshTracks() + self.applySubtitleStyle() + } case .paused: self.stateText = "Pauze" case .stopped, .stopping: - if self.clearKeyProxyActive && !self.everPlayed { + if self.liveMode && !self.everPlayed { self.stateText = "Verbinden…" self.retryLiveIfNeeded() } else if !self.hasError { self.stateText = "Gestopt" } case .error: - if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 { + if self.liveMode && !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.liveMode + ? "Kon deze livestream niet openen." + : "Kon dit bestand niet afspelen") self.stateText = "Fout" } - @unknown default: break + case .nothingSpecial: + break + @unknown default: + break } } } func mediaPlayerTrackAdded(_ trackId: String, with type: VLCMedia.TrackType) { - DispatchQueue.main.async { [weak self] in self?.refreshTracks() } + DispatchQueue.main.async { [weak self] in + guard let self, !self.stopping, !self.liveMode else { return } + self.refreshTracks() + } } func mediaPlayerTrackUpdated(_ trackId: String, with type: VLCMedia.TrackType) { - DispatchQueue.main.async { [weak self] in self?.refreshTracks() } + DispatchQueue.main.async { [weak self] in + guard let self, !self.stopping, !self.liveMode else { return } + self.refreshTracks() + } } func mediaPlayerLengthChanged(_ length: Int64) { DispatchQueue.main.async { [weak self] in - self?.invalidatePipPlaybackState() + guard let self, !self.stopping else { return } + self.invalidatePipPlaybackState() } } func mediaDidFinishParsing(_ aMedia: VLCMedia) { DispatchQueue.main.async { [weak self] in - self?.refreshTracks() + guard let self, !self.stopping, !self.liveMode else { return } + self.refreshTracks() } } @@ -650,6 +796,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg } private func refreshTime() { + guard !stopping else { return } let t = mediaPlayer.time.intValue let l = mediaPlayer.media?.length.intValue ?? 0 if l > 0 { lengthMs = l } @@ -671,14 +818,39 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg // MARK: - PiP / audio fileprivate func onPictureInPictureReady(_ controller: VLCPictureInPictureWindowControlling) { + guard !stopping, !liveMode, !clearKeyProxyActive else { return } pipWindow = controller - pipPossible = true + pipPossible = AVPictureInPictureController.isPictureInPictureSupported() controller.stateChangeEventHandler = { [weak self] isStarted in DispatchQueue.main.async { guard let self else { return } + if self.stopping { + self.pipActive = false + return + } + let wasActive = self.pipActive self.pipActive = isStarted - if !isStarted { - self.restorePlainDrawableAfterPip() + if wasActive && !isStarted { + // PiP-X gesloten: herkoppel video-subview aan inline surface (geen drawable-reset). + if let view = self.plainVideoView ?? self.retainedSurface { + self.drawableHost.attach(to: view) + } + // Opnieuw FreeType/subs activeren na terugkeer uit PiP. + if self.currentSubtitleId != PlayerTrack.offId { + self.selectSubtitle(id: self.currentSubtitleId) + } + self.applySubtitleStyle() + self.invalidatePipPlaybackState() + } + if !wasActive && isStarted { + // Net PiP gestart: forceer spu opnieuw (sample-buffer kan spu laten vallen). + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, self.pipActive, !self.stopping else { return } + if self.currentSubtitleId != PlayerTrack.offId { + self.selectSubtitle(id: self.currentSubtitleId) + } + self.applySubtitleStyle() + } } } } @@ -733,6 +905,11 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab private weak var container: UIView? private var videoSubview: UIView? + var hasVideoSubview: Bool { + guard let videoSubview else { return false } + return videoSubview.superview != nil && videoSubview.bounds.width > 1 + } + func attach(to view: UIView) { container = view view.backgroundColor = .black @@ -810,17 +987,35 @@ final class VLCPipDrawableHost: NSObject, VLCDrawable, VLCPictureInPictureDrawab } } +/// UIKit surface met layout-callback — voorkomt zero-bounds drawable (audio-only). +final class VLCSurfaceView: UIView { + var onLayout: ((UIView) -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onLayout?(self) + } +} + struct VLCVideoView: UIViewRepresentable { let controller: VLCPlayerController - func makeUIView(context: Context) -> UIView { - let view = UIView() + func makeUIView(context: Context) -> VLCSurfaceView { + let view = VLCSurfaceView() view.backgroundColor = .black + view.clipsToBounds = true + view.onLayout = { [weak controller] surface in + controller?.surfaceDidLayout(surface) + } controller.attach(to: view) return view } - func updateUIView(_ uiView: UIView, context: Context) { - // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only). + func updateUIView(_ uiView: VLCSurfaceView, context: Context) { + uiView.onLayout = { [weak controller] surface in + controller?.surfaceDidLayout(surface) + } + // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout). + controller.attach(to: uiView) } } diff --git a/apps/ios/VMC/UI/DetailScreens.swift b/apps/ios/VMC/UI/DetailScreens.swift index d362be8..31fc64a 100644 --- a/apps/ios/VMC/UI/DetailScreens.swift +++ b/apps/ios/VMC/UI/DetailScreens.swift @@ -93,7 +93,8 @@ struct MovieDetailScreen: View { movieId: movie.id, episodeId: nil, seriesId: nil, - startMs: 0 + startMs: 0, + logoUrl: movie.logoUrl ) .environmentObject(app) } @@ -227,7 +228,8 @@ struct SeriesDetailScreen: View { movieId: nil, episodeId: target.episodeId, seriesId: seriesId, - startMs: target.startMs + startMs: target.startMs, + logoUrl: series.logoUrl ) .environmentObject(app) } diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift index 623731a..cb8dbc6 100644 --- a/apps/ios/VMC/UI/EventsScreen.swift +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -612,7 +612,8 @@ struct EventDetailScreen: View { drm: item.info.drm, watchSessionId: item.info.watchSessionId, watchKind: "event", - watchId: eventId + watchId: eventId, + logoUrl: item.info.logoUrl ?? event?.logoUrl )) .environmentObject(app) } diff --git a/apps/ios/VMC/UI/LiveTvScreen.swift b/apps/ios/VMC/UI/LiveTvScreen.swift index dd4a9fe..d776129 100644 --- a/apps/ios/VMC/UI/LiveTvScreen.swift +++ b/apps/ios/VMC/UI/LiveTvScreen.swift @@ -134,7 +134,8 @@ struct LiveTvScreen: View { drm: item.info.drm, watchSessionId: item.info.watchSessionId, watchKind: kind, - watchId: item.info.streamId + watchId: item.info.streamId, + logoUrl: item.info.logoUrl )) .environmentObject(app) } diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 409a815..11c8d38 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.6") + LabeledContent("Versie", value: "0.5.41") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") }