import AVFoundation import AVKit import Combine import SwiftUI struct AVMediaOptionItem: Identifiable, Hashable { let id: String let name: String } /// Native AVPlayer voor ClearKey-live via lokale clear-HLS proxy (on-device decrypt). @MainActor final class AVLivePlayerController: NSObject, ObservableObject { @Published var isPlaying = false @Published var stateText = "Laden…" @Published var hasError = false @Published var errorMessage: String? /// True als AVPlayer minstens één video-track / presentatiegrootte heeft. @Published var hasVideo = false @Published var audioOptions: [AVMediaOptionItem] = [] @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 private var proxyActive = false private var timeControlObs: NSKeyValueObservation? private var itemStatusObs: NSKeyValueObservation? private var endObs: NSObjectProtocol? private var errorLogObs: NSObjectProtocol? private var stopping = false private var generation = 0 private var audioGroup: AVMediaSelectionGroup? private var subtitleGroup: AVMediaSelectionGroup? private var audioOptionById: [String: AVMediaSelectionOption] = [:] private var subtitleOptionById: [String: AVMediaSelectionOption] = [:] override init() { super.init() configureAudioSession() } func play( url: URL, fallback: URL? = nil, format: String? = nil, clearKeys: [ClearKeyEntry] = [] ) async { generation &+= 1 let gen = generation stopping = false hasError = false errorMessage = nil hasVideo = false everPlayed = false audioOptions = [] subtitleOptions = [] currentAudioId = nil currentSubtitleId = nil audioGroup = nil subtitleGroup = nil audioOptionById = [:] subtitleOptionById = [:] stateText = "Live laden…" stopProxyIfNeeded() player.pause() player.replaceCurrentItem(with: nil) var playURL = url if !clearKeys.isEmpty { do { stateText = "ClearKey laden…" let started = try await ClearKeyDashProxy.shared.start( manifestURL: url, keys: clearKeys, preferHlsForAVPlayer: true ) guard gen == generation, !stopping else { return } playURL = started.url proxySession = started.session proxyActive = true proxyLogState("proxy ok \(playURL.absoluteString)") } catch { hasError = true errorMessage = "ClearKey-proxy: \(error.localizedDescription)" stateText = "Fout" return } } else { let fmt = (format ?? "").lowercased() let isHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(url) if !isHls, let fallback, ClearKeyDashProxy.isHlsURL(fallback) { playURL = fallback } else if !isHls { hasError = true errorMessage = "AVPlayer vereist HLS voor deze stream." stateText = "Fout" return } } guard gen == generation, !stopping else { return } await startItem(url: playURL, gen: gen) } func stop() { guard !stopping else { return } stopping = true generation &+= 1 clearObservers() player.pause() // Eerst item loskoppelen, daarna proxy — voorkomt crash op half-open sockets. player.replaceCurrentItem(with: nil) stopProxyIfNeeded() isPlaying = false hasVideo = false audioOptions = [] subtitleOptions = [] stateText = "Gestopt" } func togglePlay() { if player.timeControlStatus == .playing { player.pause() } else { player.play() } } 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) currentAudioId = id } func selectSubtitle(id: String?) { guard let item = player.currentItem, let group = subtitleGroup else { return } if let id, let option = subtitleOptionById[id] { item.select(option, in: group) currentSubtitleId = id } else { item.select(nil, in: group) currentSubtitleId = nil } } /// 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 { stateText = "Openen…" let asset = AVURLAsset(url: url, options: [ AVURLAssetAllowsCellularAccessKey: true, AVURLAssetAllowsExpensiveNetworkAccessKey: true, AVURLAssetAllowsConstrainedNetworkAccessKey: true, ]) let item = AVPlayerItem(asset: asset) item.preferredForwardBufferDuration = 6 clearObservers() itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in Task { @MainActor in guard let self, self.generation == gen, !self.stopping else { return } switch item.status { case .readyToPlay: self.stateText = "Afspelen" self.hasError = false self.refreshHasVideo(from: 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: break } } } timeControlObs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in Task { @MainActor in guard let self, self.generation == gen, !self.stopping else { return } switch player.timeControlStatus { case .playing: self.isPlaying = true self.everPlayed = true self.stateText = "Afspelen" self.hasError = false if let item = player.currentItem { self.refreshHasVideo(from: item) } case .waitingToPlayAtSpecifiedRate: self.isPlaying = false // 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" } @unknown default: break } } } endObs = NotificationCenter.default.addObserver( forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main ) { [weak self] note in Task { @MainActor in guard let self, self.generation == gen, !self.stopping else { return } let err = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error self.fail(from: err, gen: gen) } } errorLogObs = NotificationCenter.default.addObserver( forName: .AVPlayerItemNewErrorLogEntry, object: item, queue: .main ) { [weak self] _ in Task { @MainActor in guard let self, self.generation == gen else { return } 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 } } } } player.replaceCurrentItem(with: item) player.automaticallyWaitsToMinimizeStalling = true player.play() // Timeout alleen als er NOOIT iets speelde — nooit een werkende/bufferende stream afbreken. Task { @MainActor in 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.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 20s (ClearKey/HLS)." self.stateText = "Fout" } } } } private func refreshHasVideo(from item: AVPlayerItem) { if item.presentationSize.height > 1 { hasVideo = true return } let video = item.tracks.contains { track in track.assetTrack?.mediaType == .video } hasVideo = video } private func loadMediaOptions(for item: AVPlayerItem) async { let asset = item.asset do { let proxyTracks = ClearKeyDashProxy.shared.availableAudioTracks() if !proxyTracks.isEmpty { audioOptions = proxyTracks.map { AVMediaOptionItem(id: $0.id, name: $0.name) } audioOptionById = [:] audioGroup = nil currentAudioId = ClearKeyDashProxy.shared.preferredAudioId ?? proxyTracks.first?.id } else { let audible = try await asset.loadMediaSelectionGroup(for: .audible) 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) subtitleGroup = legible if let legible { var opts: [AVMediaOptionItem] = [AVMediaOptionItem(id: "off", name: "Uit")] var map: [String: AVMediaSelectionOption] = [:] for (i, opt) in legible.options.enumerated() where !opt.hasMediaCharacteristic(.containsOnlyForcedSubtitles) { let id = "s-\(i)-\(opt.displayName)" let name = opt.displayName.isEmpty ? "Ondertitel \(i + 1)" : opt.displayName opts.append(AVMediaOptionItem(id: id, name: name)) map[id] = opt } subtitleOptions = opts subtitleOptionById = map if let selected = item.selectedMediaOption(in: legible), let match = map.first(where: { $0.value == selected })?.key { currentSubtitleId = match } else { currentSubtitleId = "off" } } else { subtitleOptions = [AVMediaOptionItem(id: "off", name: "Uit")] subtitleOptionById = [:] currentSubtitleId = "off" } } catch { proxyLogState("media options: \(error.localizedDescription)") } } 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) } else if let item = player.currentItem { errorMessage = bestErrorMessage(for: item) ?? "Kon stream niet openen" } 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 { 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)" } return nil } private func dutchAVError(_ error: Error) -> String { let ns = error as NSError if ns.domain == "CoreMediaErrorDomain" { return "Mediafout (\(ns.code)). Vaak ongeldige HLS of decrypt. Probeer opnieuw." } return error.localizedDescription } private func stopProxyIfNeeded() { if proxyActive { let session = proxySession proxyActive = false proxySession = 0 ClearKeyDashProxy.shared.stop(session: session) } } private func clearObservers() { timeControlObs?.invalidate() timeControlObs = nil itemStatusObs?.invalidate() itemStatusObs = nil if let endObs { NotificationCenter.default.removeObserver(endObs) self.endObs = nil } if let errorLogObs { NotificationCenter.default.removeObserver(errorLogObs) self.errorLogObs = nil } } private func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .moviePlayback, options: []) try session.setActive(true) } catch {} } private func proxyLogState(_ msg: String) { #if DEBUG print("[AVLive] \(msg)") #endif } } /// 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 makeUIView(context: Context) -> AVLivePlayerLayerView { let view = AVLivePlayerLayerView() view.player = controller.player return view } 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 } }