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? 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 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() { guard let item = player.currentItem else { return } Task { await loadMediaOptions(for: item) } } func selectAudio(id: String) { 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 } } // 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 = 8 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) await self.loadMediaOptions(for: item) self.player.play() 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.stateText = "Afspelen" self.hasError = false if let item = player.currentItem { self.refreshHasVideo(from: item) } case .waitingToPlayAtSpecifiedRate: self.isPlaying = false self.stateText = "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 ?? "")") } } } player.replaceCurrentItem(with: item) player.automaticallyWaitsToMinimizeStalling = true player.play() // Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken. Task { @MainActor in try? await Task.sleep(nanoseconds: 15_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 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)." 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 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 } 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" } stateText = "Fout" isPlaying = false } private func bestErrorMessage(for item: AVPlayerItem) -> String? { if let err = item.error { return dutchAVError(err) } if let events = item.errorLog()?.events, let last = events.last { 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 } } struct AVLivePlayerView: UIViewControllerRepresentable { 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 updateUIViewController(_ vc: AVPlayerViewController, context: Context) { if vc.player !== controller.player { vc.player = controller.player } } }