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:
2026-01-21 22:12:08 -06:00
commit 872354b834
283 changed files with 338296 additions and 0 deletions

View File

@@ -0,0 +1,335 @@
//
// 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)
}