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,493 @@
//
// Model.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
import AVFoundation
import CoreMedia
import Libavcodec
#if canImport(UIKit)
import UIKit
#endif
// MARK: enum
enum MESourceState {
case idle
case opening
case opened
case reading
case seeking
case paused
case finished
case closed
case failed
}
// MARK: delegate
public protocol OutputRenderSourceDelegate: AnyObject {
func getVideoOutputRender(force: Bool) -> VideoVTBFrame?
func getAudioOutputRender() -> AudioFrame?
func setAudio(time: CMTime, position: Int64)
func setVideo(time: CMTime, position: Int64)
}
protocol CodecCapacityDelegate: AnyObject {
func codecDidFinished(track: some CapacityProtocol)
}
protocol MEPlayerDelegate: AnyObject {
func sourceDidChange(loadingState: LoadingState)
func sourceDidOpened()
func sourceDidFailed(error: NSError?)
func sourceDidFinished()
func sourceDidChange(oldBitRate: Int64, newBitrate: Int64)
}
// MARK: protocol
public protocol ObjectQueueItem {
var timebase: Timebase { get }
var timestamp: Int64 { get set }
var duration: Int64 { get set }
// byte position
var position: Int64 { get set }
var size: Int32 { get set }
}
extension ObjectQueueItem {
var seconds: TimeInterval { cmtime.seconds }
var cmtime: CMTime { timebase.cmtime(for: timestamp) }
}
public protocol FrameOutput: AnyObject {
var renderSource: OutputRenderSourceDelegate? { get set }
func pause()
func flush()
func play()
}
protocol MEFrame: ObjectQueueItem {
var timebase: Timebase { get set }
}
// MARK: model
// for MEPlayer
public extension KSOptions {
/// VR
static var enableSensor = true
static var stackSize = 65536
static var isClearVideoWhereReplace = true
static var audioPlayerType: AudioOutput.Type = AudioEnginePlayer.self
static var videoPlayerType: (VideoOutput & UIView).Type = MetalPlayView.self
static var yadifMode = 1
static var deInterlaceAddIdet = false
static func colorSpace(ycbcrMatrix: CFString?, transferFunction: CFString?) -> CGColorSpace? {
switch ycbcrMatrix {
case kCVImageBufferYCbCrMatrix_ITU_R_709_2:
return CGColorSpace(name: CGColorSpace.itur_709)
case kCVImageBufferYCbCrMatrix_ITU_R_601_4:
return CGColorSpace(name: CGColorSpace.sRGB)
case kCVImageBufferYCbCrMatrix_ITU_R_2020:
if transferFunction == kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ {
if #available(macOS 11.0, iOS 14.0, tvOS 14.0, *) {
return CGColorSpace(name: CGColorSpace.itur_2100_PQ)
} else if #available(macOS 10.15.4, iOS 13.4, tvOS 13.4, *) {
return CGColorSpace(name: CGColorSpace.itur_2020_PQ)
} else {
return CGColorSpace(name: CGColorSpace.itur_2020_PQ_EOTF)
}
} else if transferFunction == kCVImageBufferTransferFunction_ITU_R_2100_HLG {
if #available(macOS 11.0, iOS 14.0, tvOS 14.0, *) {
return CGColorSpace(name: CGColorSpace.itur_2100_HLG)
} else {
return CGColorSpace(name: CGColorSpace.itur_2020)
}
} else {
return CGColorSpace(name: CGColorSpace.itur_2020)
}
default:
return CGColorSpace(name: CGColorSpace.sRGB)
}
}
static func colorSpace(colorPrimaries: CFString?) -> CGColorSpace? {
switch colorPrimaries {
case kCVImageBufferColorPrimaries_ITU_R_709_2:
return CGColorSpace(name: CGColorSpace.sRGB)
case kCVImageBufferColorPrimaries_DCI_P3:
if #available(macOS 10.15.4, iOS 13.4, tvOS 13.4, *) {
return CGColorSpace(name: CGColorSpace.displayP3_PQ)
} else {
return CGColorSpace(name: CGColorSpace.displayP3_PQ_EOTF)
}
case kCVImageBufferColorPrimaries_ITU_R_2020:
if #available(macOS 11.0, iOS 14.0, tvOS 14.0, *) {
return CGColorSpace(name: CGColorSpace.itur_2100_PQ)
} else if #available(macOS 10.15.4, iOS 13.4, tvOS 13.4, *) {
return CGColorSpace(name: CGColorSpace.itur_2020_PQ)
} else {
return CGColorSpace(name: CGColorSpace.itur_2020_PQ_EOTF)
}
default:
return CGColorSpace(name: CGColorSpace.sRGB)
}
}
static func pixelFormat(planeCount: Int, bitDepth: Int32) -> [MTLPixelFormat] {
if planeCount == 3 {
if bitDepth > 8 {
return [.r16Unorm, .r16Unorm, .r16Unorm]
} else {
return [.r8Unorm, .r8Unorm, .r8Unorm]
}
} else if planeCount == 2 {
if bitDepth > 8 {
return [.r16Unorm, .rg16Unorm]
} else {
return [.r8Unorm, .rg8Unorm]
}
} else {
return [colorPixelFormat(bitDepth: bitDepth)]
}
}
static func colorPixelFormat(bitDepth: Int32) -> MTLPixelFormat {
if bitDepth == 10 {
return .bgr10a2Unorm
} else {
return .bgra8Unorm
}
}
}
enum MECodecState {
case idle
case decoding
case flush
case closed
case failed
case finished
}
public struct Timebase {
static let defaultValue = Timebase(num: 1, den: 1)
public let num: Int32
public let den: Int32
func getPosition(from seconds: TimeInterval) -> Int64 { Int64(seconds * TimeInterval(den) / TimeInterval(num)) }
func cmtime(for timestamp: Int64) -> CMTime { CMTime(value: timestamp * Int64(num), timescale: den) }
}
extension Timebase {
public var rational: AVRational { AVRational(num: num, den: den) }
init(_ rational: AVRational) {
num = rational.num
den = rational.den
}
}
final class Packet: ObjectQueueItem {
var duration: Int64 = 0
var timestamp: Int64 = 0
var position: Int64 = 0
var size: Int32 = 0
private(set) var corePacket = av_packet_alloc()
var timebase: Timebase {
assetTrack.timebase
}
var isKeyFrame: Bool {
if let corePacket {
return corePacket.pointee.flags & AV_PKT_FLAG_KEY == AV_PKT_FLAG_KEY
} else {
return false
}
}
var assetTrack: FFmpegAssetTrack! {
didSet {
guard let packet = corePacket?.pointee else {
return
}
timestamp = packet.pts == Int64.min ? packet.dts : packet.pts
position = packet.pos
duration = packet.duration
size = packet.size
}
}
deinit {
av_packet_unref(corePacket)
av_packet_free(&corePacket)
}
}
final class SubtitleFrame: MEFrame {
var timestamp: Int64 = 0
var timebase: Timebase
var duration: Int64 = 0
var position: Int64 = 0
var size: Int32 = 0
let part: SubtitlePart
init(part: SubtitlePart, timebase: Timebase) {
self.part = part
self.timebase = timebase
}
}
public final class AudioFrame: MEFrame {
public let dataSize: Int
public let audioFormat: AVAudioFormat
public internal(set) var timebase = Timebase.defaultValue
public var timestamp: Int64 = 0
public var duration: Int64 = 0
public var position: Int64 = 0
public var size: Int32 = 0
public var data: [UnsafeMutablePointer<UInt8>?]
public var numberOfSamples: UInt32 = 0
public init(dataSize: Int, audioFormat: AVAudioFormat) {
self.dataSize = dataSize
self.audioFormat = audioFormat
let count = audioFormat.isInterleaved ? 1 : audioFormat.channelCount
data = (0 ..< count).map { _ in
UnsafeMutablePointer<UInt8>.allocate(capacity: dataSize)
}
}
init(array: [AudioFrame]) {
audioFormat = array[0].audioFormat
timebase = array[0].timebase
timestamp = array[0].timestamp
position = array[0].position
var dataSize = 0
for frame in array {
duration += frame.duration
dataSize += frame.dataSize
size += frame.size
numberOfSamples += frame.numberOfSamples
}
self.dataSize = dataSize
let count = audioFormat.isInterleaved ? 1 : audioFormat.channelCount
data = (0 ..< count).map { _ in
UnsafeMutablePointer<UInt8>.allocate(capacity: dataSize)
}
var offset = 0
for frame in array {
for i in 0 ..< data.count {
data[i]?.advanced(by: offset).initialize(from: frame.data[i]!, count: frame.dataSize)
}
offset += frame.dataSize
}
}
deinit {
for i in 0 ..< data.count {
data[i]?.deinitialize(count: dataSize)
data[i]?.deallocate()
}
data.removeAll()
}
public func toFloat() -> [ContiguousArray<Float>] {
var array = [ContiguousArray<Float>]()
for i in 0 ..< data.count {
switch audioFormat.commonFormat {
case .pcmFormatInt16:
let capacity = dataSize / MemoryLayout<Int16>.size
data[i]?.withMemoryRebound(to: Int16.self, capacity: capacity) { src in
var des = ContiguousArray<Float>(repeating: 0, count: Int(capacity))
for j in 0 ..< capacity {
des[j] = max(-1.0, min(Float(src[j]) / 32767.0, 1.0))
}
array.append(des)
}
case .pcmFormatInt32:
let capacity = dataSize / MemoryLayout<Int32>.size
data[i]?.withMemoryRebound(to: Int32.self, capacity: capacity) { src in
var des = ContiguousArray<Float>(repeating: 0, count: Int(capacity))
for j in 0 ..< capacity {
des[j] = max(-1.0, min(Float(src[j]) / 2_147_483_647.0, 1.0))
}
array.append(des)
}
default:
let capacity = dataSize / MemoryLayout<Float>.size
data[i]?.withMemoryRebound(to: Float.self, capacity: capacity) { src in
var des = ContiguousArray<Float>(repeating: 0, count: Int(capacity))
for j in 0 ..< capacity {
des[j] = src[j]
}
array.append(ContiguousArray<Float>(des))
}
}
}
return array
}
public func toPCMBuffer() -> AVAudioPCMBuffer? {
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: numberOfSamples) else {
return nil
}
pcmBuffer.frameLength = pcmBuffer.frameCapacity
for i in 0 ..< min(Int(pcmBuffer.format.channelCount), data.count) {
switch audioFormat.commonFormat {
case .pcmFormatInt16:
let capacity = dataSize / MemoryLayout<Int16>.size
data[i]?.withMemoryRebound(to: Int16.self, capacity: capacity) { src in
pcmBuffer.int16ChannelData?[i].update(from: src, count: capacity)
}
case .pcmFormatInt32:
let capacity = dataSize / MemoryLayout<Int32>.size
data[i]?.withMemoryRebound(to: Int32.self, capacity: capacity) { src in
pcmBuffer.int32ChannelData?[i].update(from: src, count: capacity)
}
default:
let capacity = dataSize / MemoryLayout<Float>.size
data[i]?.withMemoryRebound(to: Float.self, capacity: capacity) { src in
pcmBuffer.floatChannelData?[i].update(from: src, count: capacity)
}
}
}
return pcmBuffer
}
public func toCMSampleBuffer() -> CMSampleBuffer? {
var outBlockListBuffer: CMBlockBuffer?
CMBlockBufferCreateEmpty(allocator: kCFAllocatorDefault, capacity: UInt32(data.count), flags: 0, blockBufferOut: &outBlockListBuffer)
guard let outBlockListBuffer else {
return nil
}
let sampleSize = Int(audioFormat.sampleSize)
let sampleCount = CMItemCount(numberOfSamples)
let dataByteSize = sampleCount * sampleSize
if dataByteSize > dataSize {
assertionFailure("dataByteSize: \(dataByteSize),render.dataSize: \(dataSize)")
}
for i in 0 ..< data.count {
var outBlockBuffer: CMBlockBuffer?
CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault,
memoryBlock: nil,
blockLength: dataByteSize,
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil,
offsetToData: 0,
dataLength: dataByteSize,
flags: kCMBlockBufferAssureMemoryNowFlag,
blockBufferOut: &outBlockBuffer
)
if let outBlockBuffer {
CMBlockBufferReplaceDataBytes(
with: data[i]!,
blockBuffer: outBlockBuffer,
offsetIntoDestination: 0,
dataLength: dataByteSize
)
CMBlockBufferAppendBufferReference(
outBlockListBuffer,
targetBBuf: outBlockBuffer,
offsetToData: 0,
dataLength: CMBlockBufferGetDataLength(outBlockBuffer),
flags: 0
)
}
}
var sampleBuffer: CMSampleBuffer?
// sampleRatetimescaledurationinvalid
// let duration = CMTime(value: CMTimeValue(sampleCount), timescale: CMTimeScale(audioFormat.sampleRate))
let duration = CMTime.invalid
let timing = CMSampleTimingInfo(duration: duration, presentationTimeStamp: cmtime, decodeTimeStamp: .invalid)
let sampleSizeEntryCount: CMItemCount
let sampleSizeArray: [Int]?
if audioFormat.isInterleaved {
sampleSizeEntryCount = 1
sampleSizeArray = [sampleSize]
} else {
sampleSizeEntryCount = 0
sampleSizeArray = nil
}
CMSampleBufferCreateReady(allocator: kCFAllocatorDefault, dataBuffer: outBlockListBuffer, formatDescription: audioFormat.formatDescription, sampleCount: sampleCount, sampleTimingEntryCount: 1, sampleTimingArray: [timing], sampleSizeEntryCount: sampleSizeEntryCount, sampleSizeArray: sampleSizeArray, sampleBufferOut: &sampleBuffer)
return sampleBuffer
}
}
public final class VideoVTBFrame: MEFrame {
public var timebase = Timebase.defaultValue
// duration
public var duration: Int64 = 0
public var position: Int64 = 0
public var timestamp: Int64 = 0
public var size: Int32 = 0
public let fps: Float
public let isDovi: Bool
public var edrMetaData: EDRMetaData? = nil
var corePixelBuffer: PixelBufferProtocol?
init(fps: Float, isDovi: Bool) {
self.fps = fps
self.isDovi = isDovi
}
}
extension VideoVTBFrame {
#if !os(tvOS)
@available(iOS 16, *)
var edrMetadata: CAEDRMetadata? {
if var contentData = edrMetaData?.contentData, var displayData = edrMetaData?.displayData {
let data = Data(bytes: &displayData, count: MemoryLayout<MasteringDisplayMetadata>.stride)
let data2 = Data(bytes: &contentData, count: MemoryLayout<ContentLightMetadata>.stride)
return CAEDRMetadata.hdr10(displayInfo: data, contentInfo: data2, opticalOutputScale: 10000)
}
if var ambientViewingEnvironment = edrMetaData?.ambientViewingEnvironment {
let data = Data(bytes: &ambientViewingEnvironment, count: MemoryLayout<AmbientViewingEnvironment>.stride)
if #available(macOS 14.0, iOS 17.0, *) {
return CAEDRMetadata.hlg(ambientViewingEnvironment: data)
} else {
return CAEDRMetadata.hlg
}
}
if corePixelBuffer?.transferFunction == kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ {
return CAEDRMetadata.hdr10(minLuminance: 0.1, maxLuminance: 1000, opticalOutputScale: 10000)
} else if corePixelBuffer?.transferFunction == kCVImageBufferTransferFunction_ITU_R_2100_HLG {
return CAEDRMetadata.hlg
}
return nil
}
#endif
}
public struct EDRMetaData {
var displayData: MasteringDisplayMetadata?
var contentData: ContentLightMetadata?
var ambientViewingEnvironment: AmbientViewingEnvironment?
}
public struct MasteringDisplayMetadata {
let display_primaries_r_x: UInt16
let display_primaries_r_y: UInt16
let display_primaries_g_x: UInt16
let display_primaries_g_y: UInt16
let display_primaries_b_x: UInt16
let display_primaries_b_y: UInt16
let white_point_x: UInt16
let white_point_y: UInt16
let minLuminance: UInt32
let maxLuminance: UInt32
}
public struct ContentLightMetadata {
let MaxCLL: UInt16
let MaxFALL: UInt16
}
// https://developer.apple.com/documentation/technotes/tn3145-hdr-video-metadata
public struct AmbientViewingEnvironment {
let ambient_illuminance: UInt32
let ambient_light_x: UInt16
let ambient_light_y: UInt16
}