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>
593 lines
20 KiB
Swift
593 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|