Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
417 lines
15 KiB
Swift
417 lines
15 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?
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|