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>
336 lines
10 KiB
Swift
336 lines
10 KiB
Swift
//
|
||
// MediaPlayerProtocol.swift
|
||
// KSPlayer-tvOS
|
||
//
|
||
// Created by kintan on 2018/3/9.
|
||
//
|
||
|
||
import AVFoundation
|
||
import Foundation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#else
|
||
import AppKit
|
||
#endif
|
||
|
||
public protocol MediaPlayback: AnyObject {
|
||
var duration: TimeInterval { get }
|
||
var fileSize: Double { get }
|
||
var naturalSize: CGSize { get }
|
||
var chapters: [Chapter] { get }
|
||
var currentPlaybackTime: TimeInterval { get }
|
||
func prepareToPlay()
|
||
func shutdown()
|
||
func seek(time: TimeInterval, completion: @escaping ((Bool) -> Void))
|
||
}
|
||
|
||
public class DynamicInfo: ObservableObject {
|
||
private let metadataBlock: () -> [String: String]
|
||
private let bytesReadBlock: () -> Int64
|
||
private let audioBitrateBlock: () -> Int
|
||
private let videoBitrateBlock: () -> Int
|
||
public var metadata: [String: String] {
|
||
metadataBlock()
|
||
}
|
||
|
||
public var bytesRead: Int64 {
|
||
bytesReadBlock()
|
||
}
|
||
|
||
public var audioBitrate: Int {
|
||
audioBitrateBlock()
|
||
}
|
||
|
||
public var videoBitrate: Int {
|
||
videoBitrateBlock()
|
||
}
|
||
|
||
@Published
|
||
public var displayFPS = 0.0
|
||
public var audioVideoSyncDiff = 0.0
|
||
public var droppedVideoFrameCount = UInt32(0)
|
||
public var droppedVideoPacketCount = UInt32(0)
|
||
init(metadata: @escaping () -> [String: String], bytesRead: @escaping () -> Int64, audioBitrate: @escaping () -> Int, videoBitrate: @escaping () -> Int) {
|
||
metadataBlock = metadata
|
||
bytesReadBlock = bytesRead
|
||
audioBitrateBlock = audioBitrate
|
||
videoBitrateBlock = videoBitrate
|
||
}
|
||
}
|
||
|
||
public struct Chapter {
|
||
public let start: TimeInterval
|
||
public let end: TimeInterval
|
||
public let title: String
|
||
}
|
||
|
||
public protocol MediaPlayerProtocol: MediaPlayback {
|
||
var delegate: MediaPlayerDelegate? { get set }
|
||
var view: UIView? { get }
|
||
var playableTime: TimeInterval { get }
|
||
var isReadyToPlay: Bool { get }
|
||
var playbackState: MediaPlaybackState { get }
|
||
var loadState: MediaLoadState { get }
|
||
var isPlaying: Bool { get }
|
||
var seekable: Bool { get }
|
||
// var numberOfBytesTransferred: Int64 { get }
|
||
var isMuted: Bool { get set }
|
||
var allowsExternalPlayback: Bool { get set }
|
||
var usesExternalPlaybackWhileExternalScreenIsActive: Bool { get set }
|
||
var isExternalPlaybackActive: Bool { get }
|
||
var playbackRate: Float { get set }
|
||
var playbackVolume: Float { get set }
|
||
var contentMode: UIViewContentMode { get set }
|
||
var subtitleDataSouce: SubtitleDataSouce? { get }
|
||
@available(macOS 12.0, iOS 15.0, tvOS 15.0, *)
|
||
var playbackCoordinator: AVPlaybackCoordinator { get }
|
||
@available(tvOS 14.0, *)
|
||
var pipController: KSPictureInPictureController? { get }
|
||
var dynamicInfo: DynamicInfo? { get }
|
||
init(url: URL, options: KSOptions)
|
||
func replace(url: URL, options: KSOptions)
|
||
func play()
|
||
func pause()
|
||
func enterBackground()
|
||
func enterForeground()
|
||
func thumbnailImageAtCurrentTime() async -> CGImage?
|
||
func tracks(mediaType: AVFoundation.AVMediaType) -> [MediaPlayerTrack]
|
||
func select(track: some MediaPlayerTrack)
|
||
}
|
||
|
||
public extension MediaPlayerProtocol {
|
||
var nominalFrameRate: Float {
|
||
tracks(mediaType: .video).first { $0.isEnabled }?.nominalFrameRate ?? 0
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
public protocol MediaPlayerDelegate: AnyObject {
|
||
func readyToPlay(player: some MediaPlayerProtocol)
|
||
func changeLoadState(player: some MediaPlayerProtocol)
|
||
// 缓冲加载进度,0-100
|
||
func changeBuffering(player: some MediaPlayerProtocol, progress: Int)
|
||
func playBack(player: some MediaPlayerProtocol, loopCount: Int)
|
||
func finish(player: some MediaPlayerProtocol, error: Error?)
|
||
}
|
||
|
||
public protocol MediaPlayerTrack: AnyObject, CustomStringConvertible {
|
||
var trackID: Int32 { get }
|
||
var name: String { get }
|
||
var languageCode: String? { get }
|
||
var mediaType: AVFoundation.AVMediaType { get }
|
||
var nominalFrameRate: Float { get set }
|
||
var bitRate: Int64 { get }
|
||
var bitDepth: Int32 { get }
|
||
var isEnabled: Bool { get set }
|
||
var isImageSubtitle: Bool { get }
|
||
var rotation: Int16 { get }
|
||
var dovi: DOVIDecoderConfigurationRecord? { get }
|
||
var fieldOrder: FFmpegFieldOrder { get }
|
||
var formatDescription: CMFormatDescription? { get }
|
||
}
|
||
|
||
// public extension MediaPlayerTrack: Identifiable {
|
||
// var id: Int32 { trackID }
|
||
// }
|
||
|
||
public enum MediaPlaybackState: Int {
|
||
case idle
|
||
case playing
|
||
case paused
|
||
case seeking
|
||
case finished
|
||
case stopped
|
||
}
|
||
|
||
public enum MediaLoadState: Int {
|
||
case idle
|
||
case loading
|
||
case playable
|
||
}
|
||
|
||
// swiftlint:disable identifier_name
|
||
public struct DOVIDecoderConfigurationRecord {
|
||
public let dv_version_major: UInt8
|
||
public let dv_version_minor: UInt8
|
||
public let dv_profile: UInt8
|
||
public let dv_level: UInt8
|
||
public let rpu_present_flag: UInt8
|
||
public let el_present_flag: UInt8
|
||
public let bl_present_flag: UInt8
|
||
public let dv_bl_signal_compatibility_id: UInt8
|
||
}
|
||
|
||
public enum FFmpegFieldOrder: UInt8 {
|
||
case unknown = 0
|
||
case progressive
|
||
case tt // < Top coded_first, top displayed first
|
||
case bb // < Bottom coded first, bottom displayed first
|
||
case tb // < Top coded first, bottom displayed first
|
||
case bt // < Bottom coded first, top displayed first
|
||
}
|
||
|
||
extension FFmpegFieldOrder: CustomStringConvertible {
|
||
public var description: String {
|
||
switch self {
|
||
case .unknown, .progressive:
|
||
return "progressive"
|
||
case .tt:
|
||
return "top first"
|
||
case .bb:
|
||
return "bottom first"
|
||
case .tb:
|
||
return "top coded first (swapped)"
|
||
case .bt:
|
||
return "bottom coded first (swapped)"
|
||
}
|
||
}
|
||
}
|
||
|
||
// swiftlint:enable identifier_name
|
||
public extension MediaPlayerTrack {
|
||
var language: String? {
|
||
languageCode.flatMap {
|
||
Locale.current.localizedString(forLanguageCode: $0)
|
||
}
|
||
}
|
||
|
||
var codecType: FourCharCode {
|
||
mediaSubType.rawValue
|
||
}
|
||
|
||
var dynamicRange: DynamicRange? {
|
||
if dovi != nil {
|
||
return .dolbyVision
|
||
} else {
|
||
return formatDescription?.dynamicRange
|
||
}
|
||
}
|
||
|
||
var colorSpace: CGColorSpace? {
|
||
KSOptions.colorSpace(ycbcrMatrix: yCbCrMatrix as CFString?, transferFunction: transferFunction as CFString?)
|
||
}
|
||
|
||
var mediaSubType: CMFormatDescription.MediaSubType {
|
||
formatDescription?.mediaSubType ?? .boxed
|
||
}
|
||
|
||
var audioStreamBasicDescription: AudioStreamBasicDescription? {
|
||
formatDescription?.audioStreamBasicDescription
|
||
}
|
||
|
||
var naturalSize: CGSize {
|
||
formatDescription?.naturalSize ?? .zero
|
||
}
|
||
|
||
var colorPrimaries: String? {
|
||
formatDescription?.colorPrimaries
|
||
}
|
||
|
||
var transferFunction: String? {
|
||
formatDescription?.transferFunction
|
||
}
|
||
|
||
var yCbCrMatrix: String? {
|
||
formatDescription?.yCbCrMatrix
|
||
}
|
||
}
|
||
|
||
public extension CMFormatDescription {
|
||
var dynamicRange: DynamicRange {
|
||
let contentRange: DynamicRange
|
||
if codecType.string == "dvhe" || codecType == kCMVideoCodecType_DolbyVisionHEVC {
|
||
contentRange = .dolbyVision
|
||
} else if bitDepth == 10 || transferFunction == kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ as String { /// HDR
|
||
contentRange = .hdr10
|
||
} else if transferFunction == kCVImageBufferTransferFunction_ITU_R_2100_HLG as String { /// HLG
|
||
contentRange = .hlg
|
||
} else {
|
||
contentRange = .sdr
|
||
}
|
||
return contentRange
|
||
}
|
||
|
||
var bitDepth: Int32 {
|
||
codecType.bitDepth
|
||
}
|
||
|
||
var codecType: FourCharCode {
|
||
mediaSubType.rawValue
|
||
}
|
||
|
||
var colorPrimaries: String? {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
return dictionary[kCVImageBufferColorPrimariesKey] as? String
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var transferFunction: String? {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
return dictionary[kCVImageBufferTransferFunctionKey] as? String
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var yCbCrMatrix: String? {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
return dictionary[kCVImageBufferYCbCrMatrixKey] as? String
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var naturalSize: CGSize {
|
||
let aspectRatio = aspectRatio
|
||
return CGSize(width: Int(dimensions.width), height: Int(CGFloat(dimensions.height) * aspectRatio.height / aspectRatio.width))
|
||
}
|
||
|
||
var aspectRatio: CGSize {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
if let ratio = dictionary[kCVImageBufferPixelAspectRatioKey] as? NSDictionary,
|
||
let horizontal = (ratio[kCVImageBufferPixelAspectRatioHorizontalSpacingKey] as? NSNumber)?.intValue,
|
||
let vertical = (ratio[kCVImageBufferPixelAspectRatioVerticalSpacingKey] as? NSNumber)?.intValue,
|
||
horizontal > 0, vertical > 0
|
||
{
|
||
return CGSize(width: horizontal, height: vertical)
|
||
}
|
||
}
|
||
return CGSize(width: 1, height: 1)
|
||
}
|
||
|
||
var depth: Int32 {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
return dictionary[kCMFormatDescriptionExtension_Depth] as? Int32 ?? 24
|
||
} else {
|
||
return 24
|
||
}
|
||
}
|
||
|
||
var fullRangeVideo: Bool {
|
||
if let dictionary = CMFormatDescriptionGetExtensions(self) as NSDictionary? {
|
||
return dictionary[kCMFormatDescriptionExtension_FullRangeVideo] as? Bool ?? false
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
func setHttpProxy() {
|
||
guard KSOptions.useSystemHTTPProxy else {
|
||
return
|
||
}
|
||
guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeUnretainedValue() as? NSDictionary else {
|
||
unsetenv("http_proxy")
|
||
return
|
||
}
|
||
guard let proxyHost = proxySettings[kCFNetworkProxiesHTTPProxy] as? String, let proxyPort = proxySettings[kCFNetworkProxiesHTTPPort] as? Int else {
|
||
unsetenv("http_proxy")
|
||
return
|
||
}
|
||
let httpProxy = "http://\(proxyHost):\(proxyPort)"
|
||
setenv("http_proxy", httpProxy, 0)
|
||
}
|