iOS only sent that UA when the hostname looked like viaplay; stored MPDs often do not. Event plays now pin the browser session for the whole fetch chain, still direct to the CDN.
508 lines
18 KiB
Swift
508 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
|
|
) 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
|
|
)
|
|
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
|
|
}
|
|
}
|