510 lines
18 KiB
Swift
510 lines
18 KiB
Swift
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] = [],
|
|
browserCdnHeaders: Bool = false,
|
|
forceVod: Bool = false
|
|
) 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,
|
|
browserCdnHeaders: browserCdnHeaders,
|
|
forceVod: forceVod
|
|
)
|
|
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
|
|
}
|
|
}
|