stremio/apps/ios/VMC/Playback/AVLivePlayerController.swift
Jos Vooges | STH 85da6f59ad Ship F1 hub on Android TV and harden iOS live/ClearKey playback.
Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
2026-09-22 21:52:23 +02:00

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
}
}
}