stremio/apps/ios/VMC/Playback/AVLivePlayerController.swift
Jos Vooges | STH 89ceccb557 Restore working iOS 0.5.35 player and fix Odido redirected DASH on iOS (0.5.41).
Recovered the ClearKey AVPlayer/proxy stack from the Mac stash, keep later UI fixes, resolve DASH segments against the redirected manifest URL and XML-unescape MPD values.
2026-09-27 23:34:12 +02:00

506 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] = []
) 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
}
}