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,216 @@
//
// VideoToolboxDecode.swift
// KSPlayer
//
// Created by kintan on 2018/3/10.
//
import FFmpegKit
import Libavformat
#if canImport(VideoToolbox)
import VideoToolbox
class VideoToolboxDecode: DecodeProtocol {
private var session: DecompressionSession {
didSet {
VTDecompressionSessionInvalidate(oldValue.decompressionSession)
}
}
private let options: KSOptions
private var startTime = Int64(0)
private var lastPosition = Int64(0)
private var needReconfig = false
init(options: KSOptions, session: DecompressionSession) {
self.options = options
self.session = session
}
func decodeFrame(from packet: Packet, completionHandler: @escaping (Result<MEFrame, Error>) -> Void) {
if needReconfig {
//
session = DecompressionSession(assetTrack: session.assetTrack, options: options)!
doFlushCodec()
needReconfig = false
}
guard let corePacket = packet.corePacket?.pointee, let data = corePacket.data else {
return
}
do {
let sampleBuffer = try session.formatDescription.getSampleBuffer(isConvertNALSize: session.assetTrack.isConvertNALSize, data: data, size: Int(corePacket.size))
let flags: VTDecodeFrameFlags = [
._EnableAsynchronousDecompression,
]
var flagOut = VTDecodeInfoFlags.frameDropped
let timestamp = packet.timestamp
let packetFlags = corePacket.flags
let duration = corePacket.duration
let size = corePacket.size
let status = VTDecompressionSessionDecodeFrame(session.decompressionSession, sampleBuffer: sampleBuffer, flags: flags, infoFlagsOut: &flagOut) { [weak self] status, infoFlags, imageBuffer, _, _ in
guard let self, !infoFlags.contains(.frameDropped) else {
return
}
guard status == noErr else {
if status == kVTInvalidSessionErr || status == kVTVideoDecoderMalfunctionErr || status == kVTVideoDecoderBadDataErr {
if packet.isKeyFrame {
completionHandler(.failure(NSError(errorCode: .codecVideoReceiveFrame, avErrorCode: status)))
} else {
//
self.needReconfig = true
}
}
return
}
let frame = VideoVTBFrame(fps: session.assetTrack.nominalFrameRate, isDovi: session.assetTrack.dovi != nil)
frame.corePixelBuffer = imageBuffer
frame.timebase = session.assetTrack.timebase
if packet.isKeyFrame, packetFlags & AV_PKT_FLAG_DISCARD != 0, self.lastPosition > 0 {
self.startTime = self.lastPosition - timestamp
}
self.lastPosition = max(self.lastPosition, timestamp)
frame.position = packet.position
frame.timestamp = self.startTime + timestamp
frame.duration = duration
frame.size = size
self.lastPosition += frame.duration
completionHandler(.success(frame))
}
if status == noErr {
if !flags.contains(._EnableAsynchronousDecompression) {
VTDecompressionSessionWaitForAsynchronousFrames(session.decompressionSession)
}
} else if status == kVTInvalidSessionErr || status == kVTVideoDecoderMalfunctionErr || status == kVTVideoDecoderBadDataErr {
if packet.isKeyFrame {
throw NSError(errorCode: .codecVideoReceiveFrame, avErrorCode: status)
} else {
//
needReconfig = true
}
}
} catch {
completionHandler(.failure(error))
}
}
func doFlushCodec() {
lastPosition = 0
startTime = 0
}
func shutdown() {
VTDecompressionSessionInvalidate(session.decompressionSession)
}
func decode() {
lastPosition = 0
startTime = 0
}
}
class DecompressionSession {
fileprivate let formatDescription: CMFormatDescription
fileprivate let decompressionSession: VTDecompressionSession
fileprivate var assetTrack: FFmpegAssetTrack
init?(assetTrack: FFmpegAssetTrack, options: KSOptions) {
self.assetTrack = assetTrack
guard let pixelFormatType = assetTrack.pixelFormatType, let formatDescription = assetTrack.formatDescription else {
return nil
}
self.formatDescription = formatDescription
#if os(macOS)
VTRegisterProfessionalVideoWorkflowVideoDecoders()
if #available(macOS 11.0, *) {
VTRegisterSupplementalVideoDecoderIfAvailable(formatDescription.mediaSubType.rawValue)
}
#endif
// VTDecompressionSessionCanAcceptFormatDescription(<#T##session: VTDecompressionSession##VTDecompressionSession#>, formatDescription: <#T##CMFormatDescription#>)
let attributes: NSMutableDictionary = [
kCVPixelBufferPixelFormatTypeKey: pixelFormatType,
kCVPixelBufferMetalCompatibilityKey: true,
kCVPixelBufferWidthKey: assetTrack.codecpar.width,
kCVPixelBufferHeightKey: assetTrack.codecpar.height,
kCVPixelBufferIOSurfacePropertiesKey: NSDictionary(),
]
var session: VTDecompressionSession?
// swiftlint:disable line_length
let status = VTDecompressionSessionCreate(allocator: kCFAllocatorDefault, formatDescription: formatDescription, decoderSpecification: CMFormatDescriptionGetExtensions(formatDescription), imageBufferAttributes: attributes, outputCallback: nil, decompressionSessionOut: &session)
// swiftlint:enable line_length
guard status == noErr, let decompressionSession = session else {
return nil
}
if #available(iOS 14.0, tvOS 14.0, macOS 11.0, *) {
VTSessionSetProperty(decompressionSession, key: kVTDecompressionPropertyKey_PropagatePerFrameHDRDisplayMetadata,
value: kCFBooleanTrue)
}
if let destinationDynamicRange = options.availableDynamicRange(nil) {
let pixelTransferProperties = [kVTPixelTransferPropertyKey_DestinationColorPrimaries: destinationDynamicRange.colorPrimaries,
kVTPixelTransferPropertyKey_DestinationTransferFunction: destinationDynamicRange.transferFunction,
kVTPixelTransferPropertyKey_DestinationYCbCrMatrix: destinationDynamicRange.yCbCrMatrix]
VTSessionSetProperty(decompressionSession,
key: kVTDecompressionPropertyKey_PixelTransferProperties,
value: pixelTransferProperties as CFDictionary)
}
self.decompressionSession = decompressionSession
}
}
#endif
extension CMFormatDescription {
fileprivate func getSampleBuffer(isConvertNALSize: Bool, data: UnsafeMutablePointer<UInt8>, size: Int) throws -> CMSampleBuffer {
if isConvertNALSize {
var ioContext: UnsafeMutablePointer<AVIOContext>?
let status = avio_open_dyn_buf(&ioContext)
if status == 0 {
var nalSize: UInt32 = 0
let end = data + size
var nalStart = data
while nalStart < end {
nalSize = UInt32(nalStart[0]) << 16 | UInt32(nalStart[1]) << 8 | UInt32(nalStart[2])
avio_wb32(ioContext, nalSize)
nalStart += 3
avio_write(ioContext, nalStart, Int32(nalSize))
nalStart += Int(nalSize)
}
var demuxBuffer: UnsafeMutablePointer<UInt8>?
let demuxSze = avio_close_dyn_buf(ioContext, &demuxBuffer)
return try createSampleBuffer(data: demuxBuffer, size: Int(demuxSze))
} else {
throw NSError(errorCode: .codecVideoReceiveFrame, avErrorCode: status)
}
} else {
return try createSampleBuffer(data: data, size: size)
}
}
private func createSampleBuffer(data: UnsafeMutablePointer<UInt8>?, size: Int) throws -> CMSampleBuffer {
var blockBuffer: CMBlockBuffer?
var sampleBuffer: CMSampleBuffer?
// swiftlint:disable line_length
var status = CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: data, blockLength: size, blockAllocator: kCFAllocatorNull, customBlockSource: nil, offsetToData: 0, dataLength: size, flags: 0, blockBufferOut: &blockBuffer)
if status == noErr {
status = CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: blockBuffer, dataReady: true, makeDataReadyCallback: nil, refcon: nil, formatDescription: self, sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil, sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sampleBuffer)
if let sampleBuffer {
return sampleBuffer
}
}
throw NSError(errorCode: .codecVideoReceiveFrame, avErrorCode: status)
// swiftlint:enable line_length
}
}
extension CMVideoCodecType {
var avc: String {
switch self {
case kCMVideoCodecType_MPEG4Video:
return "esds"
case kCMVideoCodecType_H264:
return "avcC"
case kCMVideoCodecType_HEVC:
return "hvcC"
case kCMVideoCodecType_VP9:
return "vpcC"
default: return "avcC"
}
}
}