Files
simvision/KSPlayer-main/Sources/KSPlayer/AVPlayer/MediaPlayerProtocol.swift
Michael Simard 872354b834 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>
2026-01-21 22:12:08 -06:00

336 lines
10 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}