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,316 @@
//
// Decoder.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
import AVFoundation
import CoreMedia
import Libavformat
protocol PlayerItemTrackProtocol: CapacityProtocol, AnyObject {
init(mediaType: AVFoundation.AVMediaType, frameCapacity: UInt8, options: KSOptions)
//
var isLoopModel: Bool { get set }
var isEndOfFile: Bool { get set }
var delegate: CodecCapacityDelegate? { get set }
func decode()
func seek(time: TimeInterval)
func putPacket(packet: Packet)
// func getOutputRender<Frame: ObjectQueueItem>(where predicate: ((Frame) -> Bool)?) -> Frame?
func shutdown()
}
class SyncPlayerItemTrack<Frame: MEFrame>: PlayerItemTrackProtocol, CustomStringConvertible {
var seekTime = 0.0
fileprivate let options: KSOptions
fileprivate var decoderMap = [Int32: DecodeProtocol]()
fileprivate var state = MECodecState.idle {
didSet {
if state == .finished {
seekTime = 0
}
}
}
var isEndOfFile: Bool = false
var packetCount: Int { 0 }
let description: String
weak var delegate: CodecCapacityDelegate?
let mediaType: AVFoundation.AVMediaType
let outputRenderQueue: CircularBuffer<Frame>
var isLoopModel = false
var frameCount: Int { outputRenderQueue.count }
var frameMaxCount: Int {
outputRenderQueue.maxCount
}
var fps: Float {
outputRenderQueue.fps
}
required init(mediaType: AVFoundation.AVMediaType, frameCapacity: UInt8, options: KSOptions) {
self.options = options
self.mediaType = mediaType
description = mediaType.rawValue
// ,4
if mediaType == .audio {
outputRenderQueue = CircularBuffer(initialCapacity: Int(frameCapacity), expanding: false)
} else if mediaType == .video {
outputRenderQueue = CircularBuffer(initialCapacity: Int(frameCapacity), sorted: true, expanding: false)
} else {
//
outputRenderQueue = CircularBuffer(initialCapacity: Int(frameCapacity), sorted: true)
}
}
func decode() {
isEndOfFile = false
state = .decoding
}
func seek(time: TimeInterval) {
if options.isAccurateSeek {
seekTime = time
} else {
seekTime = 0
}
isEndOfFile = false
state = .flush
outputRenderQueue.flush()
isLoopModel = false
}
func putPacket(packet: Packet) {
if state == .flush {
decoderMap.values.forEach { $0.doFlushCodec() }
state = .decoding
}
if state == .decoding {
doDecode(packet: packet)
}
}
func getOutputRender(where predicate: ((Frame, Int) -> Bool)?) -> Frame? {
let outputFecthRender = outputRenderQueue.pop(where: predicate)
if outputFecthRender == nil {
if state == .finished, frameCount == 0 {
delegate?.codecDidFinished(track: self)
}
}
return outputFecthRender
}
func shutdown() {
if state == .idle {
return
}
state = .closed
outputRenderQueue.shutdown()
}
private var lastPacketBytes = Int32(0)
private var lastPacketSeconds = Double(-1)
var bitrate = Double(0)
fileprivate func doDecode(packet: Packet) {
if packet.isKeyFrame, packet.assetTrack.mediaType != .subtitle {
let seconds = packet.seconds
let diff = seconds - lastPacketSeconds
if lastPacketSeconds < 0 || diff < 0 {
bitrate = 0
lastPacketBytes = 0
lastPacketSeconds = seconds
} else if diff > 1 {
bitrate = Double(lastPacketBytes) / diff
lastPacketBytes = 0
lastPacketSeconds = seconds
}
}
lastPacketBytes += packet.size
let decoder = decoderMap.value(for: packet.assetTrack.trackID, default: makeDecode(assetTrack: packet.assetTrack))
// var startTime = CACurrentMediaTime()
decoder.decodeFrame(from: packet) { [weak self] result in
guard let self else {
return
}
do {
// if packet.assetTrack.mediaType == .video {
// print("[video] decode time: \(CACurrentMediaTime()-startTime)")
// startTime = CACurrentMediaTime()
// }
let frame = try result.get()
if self.state == .flush || self.state == .closed {
return
}
if self.seekTime > 0 {
let timestamp = frame.timestamp + frame.duration
// KSLog("seektime \(self.seekTime), frame \(frame.seconds), mediaType \(packet.assetTrack.mediaType)")
if timestamp <= 0 || frame.timebase.cmtime(for: timestamp).seconds < self.seekTime {
return
} else {
self.seekTime = 0.0
}
}
if let frame = frame as? Frame {
self.outputRenderQueue.push(frame)
self.outputRenderQueue.fps = packet.assetTrack.nominalFrameRate
}
} catch {
KSLog("Decoder did Failed : \(error)")
if decoder is VideoToolboxDecode {
decoder.shutdown()
self.decoderMap[packet.assetTrack.trackID] = FFmpegDecode(assetTrack: packet.assetTrack, options: self.options)
KSLog("VideoCodec switch to software decompression")
self.doDecode(packet: packet)
} else {
self.state = .failed
}
}
}
if options.decodeAudioTime == 0, mediaType == .audio {
options.decodeAudioTime = CACurrentMediaTime()
}
if options.decodeVideoTime == 0, mediaType == .video {
options.decodeVideoTime = CACurrentMediaTime()
}
}
}
final class AsyncPlayerItemTrack<Frame: MEFrame>: SyncPlayerItemTrack<Frame> {
private let operationQueue = OperationQueue()
private var decodeOperation: BlockOperation!
// 使PacketQueue
private var loopPacketQueue: CircularBuffer<Packet>?
var packetQueue = CircularBuffer<Packet>()
override var packetCount: Int { packetQueue.count }
override var isLoopModel: Bool {
didSet {
if isLoopModel {
loopPacketQueue = CircularBuffer<Packet>()
isEndOfFile = true
} else {
if let loopPacketQueue {
packetQueue.shutdown()
packetQueue = loopPacketQueue
self.loopPacketQueue = nil
if decodeOperation.isFinished {
decode()
}
}
}
}
}
required init(mediaType: AVFoundation.AVMediaType, frameCapacity: UInt8, options: KSOptions) {
super.init(mediaType: mediaType, frameCapacity: frameCapacity, options: options)
operationQueue.name = "KSPlayer_" + mediaType.rawValue
operationQueue.maxConcurrentOperationCount = 1
operationQueue.qualityOfService = .userInteractive
}
override func putPacket(packet: Packet) {
if isLoopModel {
loopPacketQueue?.push(packet)
} else {
packetQueue.push(packet)
}
}
override func decode() {
isEndOfFile = false
guard operationQueue.operationCount == 0 else { return }
decodeOperation = BlockOperation { [weak self] in
guard let self else { return }
Thread.current.name = self.operationQueue.name
Thread.current.stackSize = KSOptions.stackSize
self.decodeThread()
}
decodeOperation.queuePriority = .veryHigh
decodeOperation.qualityOfService = .userInteractive
operationQueue.addOperation(decodeOperation)
}
private func decodeThread() {
state = .decoding
isEndOfFile = false
decoderMap.values.forEach { $0.decode() }
outerLoop: while !decodeOperation.isCancelled {
switch state {
case .idle:
break outerLoop
case .finished, .closed, .failed:
decoderMap.values.forEach { $0.shutdown() }
decoderMap.removeAll()
break outerLoop
case .flush:
decoderMap.values.forEach { $0.doFlushCodec() }
state = .decoding
case .decoding:
if isEndOfFile, packetQueue.count == 0 {
state = .finished
} else {
guard let packet = packetQueue.pop(wait: true), state != .flush, state != .closed else {
continue
}
autoreleasepool {
doDecode(packet: packet)
}
}
}
}
}
override func seek(time: TimeInterval) {
if decodeOperation.isFinished {
decode()
}
packetQueue.flush()
super.seek(time: time)
loopPacketQueue = nil
}
override func shutdown() {
if state == .idle {
return
}
super.shutdown()
packetQueue.shutdown()
}
}
public extension Dictionary {
mutating func value(for key: Key, default defaultValue: @autoclosure () -> Value) -> Value {
if let value = self[key] {
return value
} else {
let value = defaultValue()
self[key] = value
return value
}
}
}
protocol DecodeProtocol {
func decode()
func decodeFrame(from packet: Packet, completionHandler: @escaping (Result<MEFrame, Error>) -> Void)
func doFlushCodec()
func shutdown()
}
extension SyncPlayerItemTrack {
func makeDecode(assetTrack: FFmpegAssetTrack) -> DecodeProtocol {
autoreleasepool {
if mediaType == .subtitle {
return SubtitleDecode(assetTrack: assetTrack, options: options)
} else {
if mediaType == .video, options.asynchronousDecompression, options.hardwareDecode,
let session = DecompressionSession(assetTrack: assetTrack, options: options)
{
return VideoToolboxDecode(options: options, session: session)
} else {
return FFmpegDecode(assetTrack: assetTrack, options: options)
}
}
}
}
}