Initial commit: SimVision tvOS streaming app
Features: - VOD library with movie grouping and version detection - TV show library with season/episode organization - TMDB integration for trending shows and recently aired episodes - Recent releases section with TMDB release date sorting - Watch history tracking with continue watching - Playlist caching (12-hour TTL) for offline support - M3U playlist parsing with XStream API support - Authentication with credential storage Technical: - SwiftUI for tvOS - Actor-based services for thread safety - Persistent caching for playlists, TMDB data, and watch history - KSPlayer integration for video playback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
592
KSPlayer-main/Sources/KSPlayer/AVPlayer/KSAVPlayer.swift
Normal file
592
KSPlayer-main/Sources/KSPlayer/AVPlayer/KSAVPlayer.swift
Normal file
@@ -0,0 +1,592 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
|
||||
public typealias UIImage = NSImage
|
||||
#endif
|
||||
import Combine
|
||||
import CoreGraphics
|
||||
|
||||
public final class KSAVPlayerView: UIView {
|
||||
public let player = AVQueuePlayer()
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
#if !canImport(UIKit)
|
||||
layer = AVPlayerLayer()
|
||||
#endif
|
||||
playerLayer.player = player
|
||||
player.automaticallyWaitsToMinimizeStalling = false
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public var contentMode: UIViewContentMode {
|
||||
get {
|
||||
switch playerLayer.videoGravity {
|
||||
case .resize:
|
||||
return .scaleToFill
|
||||
case .resizeAspect:
|
||||
return .scaleAspectFit
|
||||
case .resizeAspectFill:
|
||||
return .scaleAspectFill
|
||||
default:
|
||||
return .scaleAspectFit
|
||||
}
|
||||
}
|
||||
set {
|
||||
switch newValue {
|
||||
case .scaleToFill:
|
||||
playerLayer.videoGravity = .resize
|
||||
case .scaleAspectFit:
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
case .scaleAspectFill:
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
case .center:
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
override public class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
#endif
|
||||
fileprivate var playerLayer: AVPlayerLayer {
|
||||
// swiftlint:disable force_cast
|
||||
layer as! AVPlayerLayer
|
||||
// swiftlint:enable force_cast
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class KSAVPlayer {
|
||||
private var cancellable: AnyCancellable?
|
||||
private var options: KSOptions {
|
||||
didSet {
|
||||
player.currentItem?.preferredForwardBufferDuration = options.preferredForwardBufferDuration
|
||||
cancellable = options.$preferredForwardBufferDuration.sink { [weak self] newValue in
|
||||
self?.player.currentItem?.preferredForwardBufferDuration = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let playerView = KSAVPlayerView()
|
||||
private var urlAsset: AVURLAsset
|
||||
private var shouldSeekTo = TimeInterval(0)
|
||||
private var playerLooper: AVPlayerLooper?
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
||||
private var bufferEmptyObservation: NSKeyValueObservation?
|
||||
private var likelyToKeepUpObservation: NSKeyValueObservation?
|
||||
private var bufferFullObservation: NSKeyValueObservation?
|
||||
private var itemObservation: NSKeyValueObservation?
|
||||
private var loopCountObservation: NSKeyValueObservation?
|
||||
private var loopStatusObservation: NSKeyValueObservation?
|
||||
private var mediaPlayerTracks = [AVMediaPlayerTrack]()
|
||||
private var error: Error? {
|
||||
didSet {
|
||||
if let error {
|
||||
delegate?.finish(player: self, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var _pipController: Any? = {
|
||||
if #available(tvOS 14.0, *) {
|
||||
let pip = KSPictureInPictureController(playerLayer: playerView.playerLayer)
|
||||
return pip
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
@available(tvOS 14.0, *)
|
||||
public var pipController: KSPictureInPictureController? {
|
||||
_pipController as? KSPictureInPictureController
|
||||
}
|
||||
|
||||
public var naturalSize: CGSize = .zero
|
||||
public let dynamicInfo: DynamicInfo? = nil
|
||||
@available(macOS 12.0, iOS 15.0, tvOS 15.0, *)
|
||||
public var playbackCoordinator: AVPlaybackCoordinator {
|
||||
playerView.player.playbackCoordinator
|
||||
}
|
||||
|
||||
public private(set) var bufferingProgress = 0 {
|
||||
didSet {
|
||||
delegate?.changeBuffering(player: self, progress: bufferingProgress)
|
||||
}
|
||||
}
|
||||
|
||||
public weak var delegate: MediaPlayerDelegate?
|
||||
public private(set) var duration: TimeInterval = 0
|
||||
public private(set) var fileSize: Double = 0
|
||||
public private(set) var playableTime: TimeInterval = 0
|
||||
public let chapters: [Chapter] = []
|
||||
public var playbackRate: Float = 1 {
|
||||
didSet {
|
||||
if playbackState == .playing {
|
||||
player.rate = playbackRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var playbackVolume: Float = 1.0 {
|
||||
didSet {
|
||||
if player.volume != playbackVolume {
|
||||
player.volume = playbackVolume
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var loadState = MediaLoadState.idle {
|
||||
didSet {
|
||||
if loadState != oldValue {
|
||||
playOrPause()
|
||||
if loadState == .loading || loadState == .idle {
|
||||
bufferingProgress = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var playbackState = MediaPlaybackState.idle {
|
||||
didSet {
|
||||
if playbackState != oldValue {
|
||||
playOrPause()
|
||||
if playbackState == .finished {
|
||||
delegate?.finish(player: self, error: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var isReadyToPlay = false {
|
||||
didSet {
|
||||
if isReadyToPlay != oldValue {
|
||||
if isReadyToPlay {
|
||||
options.readyTime = CACurrentMediaTime()
|
||||
delegate?.readyToPlay(player: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(xrOS)
|
||||
public var allowsExternalPlayback = false
|
||||
public var usesExternalPlaybackWhileExternalScreenIsActive = false
|
||||
public let isExternalPlaybackActive = false
|
||||
#else
|
||||
public var allowsExternalPlayback: Bool {
|
||||
get {
|
||||
player.allowsExternalPlayback
|
||||
}
|
||||
set {
|
||||
player.allowsExternalPlayback = newValue
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
public var usesExternalPlaybackWhileExternalScreenIsActive = false
|
||||
#else
|
||||
public var usesExternalPlaybackWhileExternalScreenIsActive: Bool {
|
||||
get {
|
||||
player.usesExternalPlaybackWhileExternalScreenIsActive
|
||||
}
|
||||
set {
|
||||
player.usesExternalPlaybackWhileExternalScreenIsActive = newValue
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public var isExternalPlaybackActive: Bool {
|
||||
player.isExternalPlaybackActive
|
||||
}
|
||||
#endif
|
||||
|
||||
public required init(url: URL, options: KSOptions) {
|
||||
KSOptions.setAudioSession()
|
||||
urlAsset = AVURLAsset(url: url, options: options.avOptions)
|
||||
self.options = options
|
||||
itemObservation = player.observe(\.currentItem) { [weak self] player, _ in
|
||||
guard let self else { return }
|
||||
self.observer(playerItem: player.currentItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KSAVPlayer {
|
||||
public var player: AVQueuePlayer { playerView.player }
|
||||
public var playerLayer: AVPlayerLayer { playerView.playerLayer }
|
||||
@objc private func moviePlayDidEnd(notification _: Notification) {
|
||||
if !options.isLoopPlay {
|
||||
playbackState = .finished
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func playerItemFailedToPlayToEndTime(notification: Notification) {
|
||||
var playError: Error?
|
||||
if let userInfo = notification.userInfo {
|
||||
if let error = userInfo["error"] as? Error {
|
||||
playError = error
|
||||
} else if let error = userInfo[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError {
|
||||
playError = error
|
||||
} else if let errorCode = (userInfo["error"] as? NSNumber)?.intValue {
|
||||
playError = NSError(domain: "AVMoviePlayer", code: errorCode, userInfo: nil)
|
||||
}
|
||||
}
|
||||
delegate?.finish(player: self, error: playError)
|
||||
}
|
||||
|
||||
private func updateStatus(item: AVPlayerItem) {
|
||||
if item.status == .readyToPlay {
|
||||
options.findTime = CACurrentMediaTime()
|
||||
mediaPlayerTracks = item.tracks.map {
|
||||
AVMediaPlayerTrack(track: $0)
|
||||
}
|
||||
let playableVideo = mediaPlayerTracks.first {
|
||||
$0.mediaType == .video && $0.isPlayable
|
||||
}
|
||||
if let playableVideo {
|
||||
naturalSize = playableVideo.naturalSize
|
||||
} else {
|
||||
error = NSError(errorCode: .videoTracksUnplayable)
|
||||
return
|
||||
}
|
||||
// 默认选择第一个声道
|
||||
item.tracks.filter { $0.assetTrack?.mediaType.rawValue == AVMediaType.audio.rawValue }.dropFirst().forEach { $0.isEnabled = false }
|
||||
duration = item.duration.seconds
|
||||
let estimatedDataRates = item.tracks.compactMap { $0.assetTrack?.estimatedDataRate }
|
||||
fileSize = Double(estimatedDataRates.reduce(0, +)) * duration / 8
|
||||
isReadyToPlay = true
|
||||
} else if item.status == .failed {
|
||||
error = item.error
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlayableDuration(item: AVPlayerItem) {
|
||||
let first = item.loadedTimeRanges.first { CMTimeRangeContainsTime($0.timeRangeValue, time: item.currentTime()) }
|
||||
if let first {
|
||||
playableTime = first.timeRangeValue.end.seconds
|
||||
guard playableTime > 0 else { return }
|
||||
let loadedTime = playableTime - currentPlaybackTime
|
||||
guard loadedTime > 0 else { return }
|
||||
bufferingProgress = Int(min(loadedTime * 100 / item.preferredForwardBufferDuration, 100))
|
||||
if bufferingProgress >= 100 {
|
||||
loadState = .playable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playOrPause() {
|
||||
if playbackState == .playing {
|
||||
if loadState == .playable {
|
||||
player.play()
|
||||
player.rate = playbackRate
|
||||
}
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
delegate?.changeLoadState(player: self)
|
||||
}
|
||||
|
||||
private func replaceCurrentItem(playerItem: AVPlayerItem?) {
|
||||
player.currentItem?.cancelPendingSeeks()
|
||||
if options.isLoopPlay {
|
||||
loopCountObservation?.invalidate()
|
||||
loopStatusObservation?.invalidate()
|
||||
playerLooper?.disableLooping()
|
||||
guard let playerItem else {
|
||||
playerLooper = nil
|
||||
return
|
||||
}
|
||||
playerLooper = AVPlayerLooper(player: player, templateItem: playerItem)
|
||||
loopCountObservation = playerLooper?.observe(\.loopCount) { [weak self] playerLooper, _ in
|
||||
guard let self else { return }
|
||||
self.delegate?.playBack(player: self, loopCount: playerLooper.loopCount)
|
||||
}
|
||||
loopStatusObservation = playerLooper?.observe(\.status) { [weak self] playerLooper, _ in
|
||||
guard let self else { return }
|
||||
if playerLooper.status == .failed {
|
||||
self.error = playerLooper.error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func observer(playerItem: AVPlayerItem?) {
|
||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: playerItem)
|
||||
statusObservation?.invalidate()
|
||||
loadedTimeRangesObservation?.invalidate()
|
||||
bufferEmptyObservation?.invalidate()
|
||||
likelyToKeepUpObservation?.invalidate()
|
||||
bufferFullObservation?.invalidate()
|
||||
guard let playerItem else { return }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(moviePlayDidEnd), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(playerItemFailedToPlayToEndTime), name: .AVPlayerItemFailedToPlayToEndTime, object: playerItem)
|
||||
statusObservation = playerItem.observe(\.status) { [weak self] item, _ in
|
||||
guard let self else { return }
|
||||
self.updateStatus(item: item)
|
||||
}
|
||||
loadedTimeRangesObservation = playerItem.observe(\.loadedTimeRanges) { [weak self] item, _ in
|
||||
guard let self else { return }
|
||||
// 计算缓冲进度
|
||||
self.updatePlayableDuration(item: item)
|
||||
}
|
||||
|
||||
let changeHandler: (AVPlayerItem, NSKeyValueObservedChange<Bool>) -> Void = { [weak self] _, _ in
|
||||
guard let self else { return }
|
||||
// 在主线程更新进度
|
||||
if playerItem.isPlaybackBufferEmpty {
|
||||
self.loadState = .loading
|
||||
} else if playerItem.isPlaybackLikelyToKeepUp || playerItem.isPlaybackBufferFull {
|
||||
self.loadState = .playable
|
||||
}
|
||||
}
|
||||
bufferEmptyObservation = playerItem.observe(\.isPlaybackBufferEmpty, changeHandler: changeHandler)
|
||||
likelyToKeepUpObservation = playerItem.observe(\.isPlaybackLikelyToKeepUp, changeHandler: changeHandler)
|
||||
bufferFullObservation = playerItem.observe(\.isPlaybackBufferFull, changeHandler: changeHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension KSAVPlayer: MediaPlayerProtocol {
|
||||
public var subtitleDataSouce: SubtitleDataSouce? { nil }
|
||||
public var isPlaying: Bool { player.rate > 0 ? true : playbackState == .playing }
|
||||
public var view: UIView? { playerView }
|
||||
public var currentPlaybackTime: TimeInterval {
|
||||
get {
|
||||
if shouldSeekTo > 0 {
|
||||
return TimeInterval(shouldSeekTo)
|
||||
} else {
|
||||
// 防止卡主
|
||||
return isReadyToPlay ? player.currentTime().seconds : 0
|
||||
}
|
||||
}
|
||||
set {
|
||||
seek(time: newValue) { _ in
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var numberOfBytesTransferred: Int64 {
|
||||
guard let playerItem = player.currentItem, let accesslog = playerItem.accessLog(), let event = accesslog.events.first else {
|
||||
return 0
|
||||
}
|
||||
return event.numberOfBytesTransferred
|
||||
}
|
||||
|
||||
public func thumbnailImageAtCurrentTime() async -> CGImage? {
|
||||
guard let playerItem = player.currentItem, isReadyToPlay else {
|
||||
return nil
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
urlAsset.thumbnailImage(currentTime: playerItem.currentTime()) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func seek(time: TimeInterval, completion: @escaping ((Bool) -> Void)) {
|
||||
let time = max(time, 0)
|
||||
shouldSeekTo = time
|
||||
playbackState = .seeking
|
||||
runOnMainThread { [weak self] in
|
||||
self?.bufferingProgress = 0
|
||||
}
|
||||
let tolerance: CMTime = options.isAccurateSeek ? .zero : .positiveInfinity
|
||||
player.seek(to: CMTime(seconds: time), toleranceBefore: tolerance, toleranceAfter: tolerance) {
|
||||
[weak self] finished in
|
||||
guard let self else { return }
|
||||
self.shouldSeekTo = 0
|
||||
completion(finished)
|
||||
}
|
||||
}
|
||||
|
||||
public func prepareToPlay() {
|
||||
KSLog("prepareToPlay \(self)")
|
||||
options.prepareTime = CACurrentMediaTime()
|
||||
runOnMainThread { [weak self] in
|
||||
guard let self else { return }
|
||||
self.bufferingProgress = 0
|
||||
let playerItem = AVPlayerItem(asset: self.urlAsset)
|
||||
self.options.openTime = CACurrentMediaTime()
|
||||
self.replaceCurrentItem(playerItem: playerItem)
|
||||
self.player.actionAtItemEnd = .pause
|
||||
self.player.volume = self.playbackVolume
|
||||
}
|
||||
}
|
||||
|
||||
public func play() {
|
||||
KSLog("play \(self)")
|
||||
playbackState = .playing
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
KSLog("pause \(self)")
|
||||
playbackState = .paused
|
||||
}
|
||||
|
||||
public func shutdown() {
|
||||
KSLog("shutdown \(self)")
|
||||
isReadyToPlay = false
|
||||
playbackState = .stopped
|
||||
loadState = .idle
|
||||
urlAsset.cancelLoading()
|
||||
replaceCurrentItem(playerItem: nil)
|
||||
}
|
||||
|
||||
public func replace(url: URL, options: KSOptions) {
|
||||
KSLog("replaceUrl \(self)")
|
||||
shutdown()
|
||||
urlAsset = AVURLAsset(url: url, options: options.avOptions)
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public var contentMode: UIViewContentMode {
|
||||
get {
|
||||
playerView.contentMode
|
||||
}
|
||||
set {
|
||||
playerView.contentMode = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public func enterBackground() {
|
||||
playerView.playerLayer.player = nil
|
||||
}
|
||||
|
||||
public func enterForeground() {
|
||||
playerView.playerLayer.player = playerView.player
|
||||
}
|
||||
|
||||
public var seekable: Bool {
|
||||
!(player.currentItem?.seekableTimeRanges.isEmpty ?? true)
|
||||
}
|
||||
|
||||
public var isMuted: Bool {
|
||||
get {
|
||||
player.isMuted
|
||||
}
|
||||
set {
|
||||
player.isMuted = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public func tracks(mediaType: AVFoundation.AVMediaType) -> [MediaPlayerTrack] {
|
||||
player.currentItem?.tracks.filter { $0.assetTrack?.mediaType == mediaType }.map { AVMediaPlayerTrack(track: $0) } ?? []
|
||||
}
|
||||
|
||||
public func select(track: some MediaPlayerTrack) {
|
||||
player.currentItem?.tracks.filter { $0.assetTrack?.mediaType == track.mediaType }.forEach { $0.isEnabled = false }
|
||||
track.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
extension AVFoundation.AVMediaType {
|
||||
var mediaCharacteristic: AVMediaCharacteristic {
|
||||
switch self {
|
||||
case .video:
|
||||
return .visual
|
||||
case .audio:
|
||||
return .audible
|
||||
case .subtitle:
|
||||
return .legible
|
||||
default:
|
||||
return .easyToRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AVAssetTrack {
|
||||
func toMediaPlayerTrack() {}
|
||||
}
|
||||
|
||||
class AVMediaPlayerTrack: MediaPlayerTrack {
|
||||
let formatDescription: CMFormatDescription?
|
||||
let description: String
|
||||
private let track: AVPlayerItemTrack
|
||||
var nominalFrameRate: Float
|
||||
let trackID: Int32
|
||||
let rotation: Int16 = 0
|
||||
let bitDepth: Int32
|
||||
let bitRate: Int64
|
||||
let name: String
|
||||
let languageCode: String?
|
||||
let mediaType: AVFoundation.AVMediaType
|
||||
let isImageSubtitle = false
|
||||
var dovi: DOVIDecoderConfigurationRecord?
|
||||
let fieldOrder: FFmpegFieldOrder = .unknown
|
||||
var isPlayable: Bool
|
||||
@MainActor
|
||||
var isEnabled: Bool {
|
||||
get {
|
||||
track.isEnabled
|
||||
}
|
||||
set {
|
||||
track.isEnabled = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(track: AVPlayerItemTrack) {
|
||||
self.track = track
|
||||
trackID = track.assetTrack?.trackID ?? 0
|
||||
mediaType = track.assetTrack?.mediaType ?? .video
|
||||
name = track.assetTrack?.languageCode ?? ""
|
||||
languageCode = track.assetTrack?.languageCode
|
||||
nominalFrameRate = track.assetTrack?.nominalFrameRate ?? 24.0
|
||||
bitRate = Int64(track.assetTrack?.estimatedDataRate ?? 0)
|
||||
#if os(xrOS)
|
||||
isPlayable = false
|
||||
#else
|
||||
isPlayable = track.assetTrack?.isPlayable ?? false
|
||||
#endif
|
||||
// swiftlint:disable force_cast
|
||||
if let first = track.assetTrack?.formatDescriptions.first {
|
||||
formatDescription = first as! CMFormatDescription
|
||||
} else {
|
||||
formatDescription = nil
|
||||
}
|
||||
bitDepth = formatDescription?.bitDepth ?? 0
|
||||
// swiftlint:enable force_cast
|
||||
description = (formatDescription?.mediaSubType ?? .boxed).rawValue.string
|
||||
#if os(xrOS)
|
||||
Task {
|
||||
isPlayable = await (try? track.assetTrack?.load(.isPlayable)) ?? false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func load() {}
|
||||
}
|
||||
|
||||
public extension AVAsset {
|
||||
func createImageGenerator() -> AVAssetImageGenerator {
|
||||
let imageGenerator = AVAssetImageGenerator(asset: self)
|
||||
imageGenerator.requestedTimeToleranceBefore = .zero
|
||||
imageGenerator.requestedTimeToleranceAfter = .zero
|
||||
return imageGenerator
|
||||
}
|
||||
|
||||
func thumbnailImage(currentTime: CMTime, handler: @escaping (CGImage?) -> Void) {
|
||||
let imageGenerator = createImageGenerator()
|
||||
imageGenerator.requestedTimeToleranceBefore = .zero
|
||||
imageGenerator.requestedTimeToleranceAfter = .zero
|
||||
imageGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: currentTime)]) { _, cgImage, _, _, _ in
|
||||
if let cgImage {
|
||||
handler(cgImage)
|
||||
} else {
|
||||
handler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user