Files
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

385 lines
16 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.
//
// Resample.swift
// KSPlayer-iOS
//
// Created by kintan on 2020/1/27.
//
import AVFoundation
import CoreGraphics
import CoreMedia
import Libavcodec
import Libswresample
import Libswscale
protocol FrameTransfer {
func transfer(avframe: UnsafeMutablePointer<AVFrame>) -> UnsafeMutablePointer<AVFrame>
func shutdown()
}
protocol FrameChange {
func change(avframe: UnsafeMutablePointer<AVFrame>) throws -> MEFrame
func shutdown()
}
class VideoSwscale: FrameTransfer {
private var imgConvertCtx: OpaquePointer?
private var format: AVPixelFormat = AV_PIX_FMT_NONE
private var height: Int32 = 0
private var width: Int32 = 0
private var outFrame: UnsafeMutablePointer<AVFrame>?
private func setup(format: AVPixelFormat, width: Int32, height: Int32, linesize _: Int32) {
if self.format == format, self.width == width, self.height == height {
return
}
self.format = format
self.height = height
self.width = width
if format.osType() != nil {
sws_freeContext(imgConvertCtx)
imgConvertCtx = nil
outFrame = nil
} else {
let dstFormat = format.bestPixelFormat
imgConvertCtx = sws_getCachedContext(imgConvertCtx, width, height, self.format, width, height, dstFormat, SWS_BICUBIC, nil, nil, nil)
outFrame = av_frame_alloc()
outFrame?.pointee.format = dstFormat.rawValue
outFrame?.pointee.width = width
outFrame?.pointee.height = height
}
}
func transfer(avframe: UnsafeMutablePointer<AVFrame>) -> UnsafeMutablePointer<AVFrame> {
setup(format: AVPixelFormat(rawValue: avframe.pointee.format), width: avframe.pointee.width, height: avframe.pointee.height, linesize: avframe.pointee.linesize.0)
if let imgConvertCtx, let outFrame {
sws_scale_frame(imgConvertCtx, outFrame, avframe)
return outFrame
}
return avframe
}
func shutdown() {
sws_freeContext(imgConvertCtx)
imgConvertCtx = nil
}
}
class VideoSwresample: FrameChange {
private var imgConvertCtx: OpaquePointer?
private var format: AVPixelFormat = AV_PIX_FMT_NONE
private var height: Int32 = 0
private var width: Int32 = 0
private var pool: CVPixelBufferPool?
private var dstHeight: Int32?
private var dstWidth: Int32?
private let dstFormat: AVPixelFormat?
private let fps: Float
private let isDovi: Bool
init(dstWidth: Int32? = nil, dstHeight: Int32? = nil, dstFormat: AVPixelFormat? = nil, fps: Float = 60, isDovi: Bool) {
self.dstWidth = dstWidth
self.dstHeight = dstHeight
self.dstFormat = dstFormat
self.fps = fps
self.isDovi = isDovi
}
func change(avframe: UnsafeMutablePointer<AVFrame>) throws -> MEFrame {
let frame = VideoVTBFrame(fps: fps, isDovi: isDovi)
if avframe.pointee.format == AV_PIX_FMT_VIDEOTOOLBOX.rawValue {
frame.corePixelBuffer = unsafeBitCast(avframe.pointee.data.3, to: CVPixelBuffer.self)
} else {
frame.corePixelBuffer = transfer(frame: avframe.pointee)
}
return frame
}
private func setup(format: AVPixelFormat, width: Int32, height: Int32, linesize: Int32) {
if self.format == format, self.width == width, self.height == height {
return
}
self.format = format
self.height = height
self.width = width
let dstWidth = dstWidth ?? width
let dstHeight = dstHeight ?? height
let pixelFormatType: OSType
if self.dstWidth == nil, self.dstHeight == nil, dstFormat == nil, let osType = format.osType() {
pixelFormatType = osType
sws_freeContext(imgConvertCtx)
imgConvertCtx = nil
} else {
let dstFormat = dstFormat ?? format.bestPixelFormat
pixelFormatType = dstFormat.osType()!
// imgConvertCtx = sws_getContext(width, height, self.format, width, height, dstFormat, SWS_FAST_BILINEAR, nil, nil, nil)
// AV_PIX_FMT_VIDEOTOOLBOXswscale
imgConvertCtx = sws_getCachedContext(imgConvertCtx, width, height, self.format, dstWidth, dstHeight, dstFormat, SWS_FAST_BILINEAR, nil, nil, nil)
}
pool = CVPixelBufferPool.create(width: dstWidth, height: dstHeight, bytesPerRowAlignment: linesize, pixelFormatType: pixelFormatType)
}
func transfer(frame: AVFrame) -> PixelBufferProtocol? {
let format = AVPixelFormat(rawValue: frame.format)
let width = frame.width
let height = frame.height
if format.leftShift > 0 {
return PixelBuffer(frame: frame)
}
let pbuf = transfer(format: format, width: width, height: height, data: Array(tuple: frame.data), linesize: Array(tuple: frame.linesize))
if let pbuf {
pbuf.aspectRatio = frame.sample_aspect_ratio.size
pbuf.yCbCrMatrix = frame.colorspace.ycbcrMatrix
pbuf.colorPrimaries = frame.color_primaries.colorPrimaries
pbuf.transferFunction = frame.color_trc.transferFunction
// vt_pixbuf_set_colorspace
if pbuf.transferFunction == kCVImageBufferTransferFunction_UseGamma {
let gamma = NSNumber(value: frame.color_trc == AVCOL_TRC_GAMMA22 ? 2.2 : 2.8)
CVBufferSetAttachment(pbuf, kCVImageBufferGammaLevelKey, gamma, .shouldPropagate)
}
if let chroma = frame.chroma_location.chroma {
CVBufferSetAttachment(pbuf, kCVImageBufferChromaLocationTopFieldKey, chroma, .shouldPropagate)
}
pbuf.colorspace = KSOptions.colorSpace(ycbcrMatrix: pbuf.yCbCrMatrix, transferFunction: pbuf.transferFunction)
}
return pbuf
}
func transfer(format: AVPixelFormat, width: Int32, height: Int32, data: [UnsafeMutablePointer<UInt8>?], linesize: [Int32]) -> CVPixelBuffer? {
setup(format: format, width: width, height: height, linesize: linesize[1] == 0 ? linesize[0] : linesize[1])
guard let pool else {
return nil
}
return autoreleasepool {
var pbuf: CVPixelBuffer?
let ret = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pbuf)
guard let pbuf, ret == kCVReturnSuccess else {
return nil
}
CVPixelBufferLockBaseAddress(pbuf, CVPixelBufferLockFlags(rawValue: 0))
let bufferPlaneCount = pbuf.planeCount
if let imgConvertCtx {
let bytesPerRow = (0 ..< bufferPlaneCount).map { i in
Int32(CVPixelBufferGetBytesPerRowOfPlane(pbuf, i))
}
let contents = (0 ..< bufferPlaneCount).map { i in
pbuf.baseAddressOfPlane(at: i)?.assumingMemoryBound(to: UInt8.self)
}
_ = sws_scale(imgConvertCtx, data.map { UnsafePointer($0) }, linesize, 0, height, contents, bytesPerRow)
} else {
let planeCount = format.planeCount
let byteCount = format.bitDepth > 8 ? 2 : 1
for i in 0 ..< bufferPlaneCount {
let height = pbuf.heightOfPlane(at: i)
let size = Int(linesize[i])
let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(pbuf, i)
var contents = pbuf.baseAddressOfPlane(at: i)
var source = data[i]!
if bufferPlaneCount < planeCount, i + 2 == planeCount {
var sourceU = data[i]!
var sourceV = data[i + 1]!
var k = 0
while k < height {
var j = 0
while j < size {
contents?.advanced(by: 2 * j).copyMemory(from: sourceU.advanced(by: j), byteCount: byteCount)
contents?.advanced(by: 2 * j + byteCount).copyMemory(from: sourceV.advanced(by: j), byteCount: byteCount)
j += byteCount
}
contents = contents?.advanced(by: bytesPerRow)
sourceU = sourceU.advanced(by: size)
sourceV = sourceV.advanced(by: size)
k += 1
}
} else if bytesPerRow == size {
contents?.copyMemory(from: source, byteCount: height * size)
} else {
var j = 0
while j < height {
contents?.advanced(by: j * bytesPerRow).copyMemory(from: source.advanced(by: j * size), byteCount: size)
j += 1
}
}
}
}
CVPixelBufferUnlockBaseAddress(pbuf, CVPixelBufferLockFlags(rawValue: 0))
return pbuf
}
}
func shutdown() {
sws_freeContext(imgConvertCtx)
imgConvertCtx = nil
}
}
extension BinaryInteger {
func alignment(value: Self) -> Self {
let remainder = self % value
return remainder == 0 ? self : self + value - remainder
}
}
typealias SwrContext = OpaquePointer
class AudioSwresample: FrameChange {
private var swrContext: SwrContext?
private var descriptor: AudioDescriptor
private var outChannel: AVChannelLayout
init(audioDescriptor: AudioDescriptor) {
descriptor = audioDescriptor
outChannel = audioDescriptor.outChannel
_ = setup(descriptor: descriptor)
}
private func setup(descriptor: AudioDescriptor) -> Bool {
var result = swr_alloc_set_opts2(&swrContext, &descriptor.outChannel, descriptor.audioFormat.sampleFormat, Int32(descriptor.audioFormat.sampleRate), &descriptor.channel, descriptor.sampleFormat, descriptor.sampleRate, 0, nil)
result = swr_init(swrContext)
if result < 0 {
shutdown()
return false
} else {
outChannel = descriptor.outChannel
return true
}
}
func change(avframe: UnsafeMutablePointer<AVFrame>) throws -> MEFrame {
if !(descriptor == avframe.pointee) || outChannel != descriptor.outChannel {
let newDescriptor = AudioDescriptor(frame: avframe.pointee)
if setup(descriptor: newDescriptor) {
descriptor = newDescriptor
} else {
throw NSError(errorCode: .auidoSwrInit, userInfo: ["outChannel": newDescriptor.outChannel, "inChannel": newDescriptor.channel])
}
}
let numberOfSamples = avframe.pointee.nb_samples
let outSamples = swr_get_out_samples(swrContext, numberOfSamples)
var frameBuffer = Array(tuple: avframe.pointee.data).map { UnsafePointer<UInt8>($0) }
let channels = descriptor.outChannel.nb_channels
var bufferSize = [Int32(0)]
//
_ = av_samples_get_buffer_size(&bufferSize, channels, outSamples, descriptor.audioFormat.sampleFormat, 1)
let frame = AudioFrame(dataSize: Int(bufferSize[0]), audioFormat: descriptor.audioFormat)
frame.numberOfSamples = UInt32(swr_convert(swrContext, &frame.data, outSamples, &frameBuffer, numberOfSamples))
return frame
}
func shutdown() {
swr_free(&swrContext)
}
}
public class AudioDescriptor: Equatable {
// static let defaultValue = AudioDescriptor()
public let sampleRate: Int32
public private(set) var audioFormat: AVAudioFormat
fileprivate(set) var channel: AVChannelLayout
fileprivate let sampleFormat: AVSampleFormat
fileprivate var outChannel: AVChannelLayout
private convenience init() {
self.init(sampleFormat: AV_SAMPLE_FMT_FLT, sampleRate: 48000, channel: AVChannelLayout.defaultValue)
}
convenience init(codecpar: AVCodecParameters) {
self.init(sampleFormat: AVSampleFormat(rawValue: codecpar.format), sampleRate: codecpar.sample_rate, channel: codecpar.ch_layout)
}
convenience init(frame: AVFrame) {
self.init(sampleFormat: AVSampleFormat(rawValue: frame.format), sampleRate: frame.sample_rate, channel: frame.ch_layout)
}
init(sampleFormat: AVSampleFormat, sampleRate: Int32, channel: AVChannelLayout) {
self.channel = channel
outChannel = channel
if sampleRate <= 0 {
self.sampleRate = 48000
} else {
self.sampleRate = sampleRate
}
self.sampleFormat = sampleFormat
#if os(macOS)
let channelCount = AVAudioChannelCount(2)
#else
let channelCount = KSOptions.outputNumberOfChannels(channelCount: AVAudioChannelCount(outChannel.nb_channels))
#endif
audioFormat = AudioDescriptor.audioFormat(sampleFormat: sampleFormat, sampleRate: self.sampleRate, outChannel: &outChannel, channelCount: channelCount)
}
public static func == (lhs: AudioDescriptor, rhs: AudioDescriptor) -> Bool {
lhs.sampleFormat == rhs.sampleFormat && lhs.sampleRate == rhs.sampleRate && lhs.channel == rhs.channel
}
public static func == (lhs: AudioDescriptor, rhs: AVFrame) -> Bool {
var sampleRate = rhs.sample_rate
if sampleRate <= 0 {
sampleRate = 48000
}
return lhs.sampleFormat == AVSampleFormat(rawValue: rhs.format) && lhs.sampleRate == sampleRate && lhs.channel == rhs.ch_layout
}
static func audioFormat(sampleFormat: AVSampleFormat, sampleRate: Int32, outChannel: inout AVChannelLayout, channelCount: AVAudioChannelCount) -> AVAudioFormat {
if channelCount != AVAudioChannelCount(outChannel.nb_channels) {
av_channel_layout_default(&outChannel, Int32(channelCount))
}
let layoutTag: AudioChannelLayoutTag
if let tag = outChannel.layoutTag {
layoutTag = tag
} else {
av_channel_layout_default(&outChannel, Int32(channelCount))
if let tag = outChannel.layoutTag {
layoutTag = tag
} else {
av_channel_layout_default(&outChannel, 2)
layoutTag = outChannel.layoutTag!
}
}
KSLog("[audio] out channelLayout: \(outChannel)")
var commonFormat: AVAudioCommonFormat
var interleaved: Bool
switch sampleFormat {
case AV_SAMPLE_FMT_S16:
commonFormat = .pcmFormatInt16
interleaved = true
case AV_SAMPLE_FMT_S32:
commonFormat = .pcmFormatInt32
interleaved = true
case AV_SAMPLE_FMT_FLT:
commonFormat = .pcmFormatFloat32
interleaved = true
case AV_SAMPLE_FMT_DBL:
commonFormat = .pcmFormatFloat64
interleaved = true
case AV_SAMPLE_FMT_S16P:
commonFormat = .pcmFormatInt16
interleaved = false
case AV_SAMPLE_FMT_S32P:
commonFormat = .pcmFormatInt32
interleaved = false
case AV_SAMPLE_FMT_FLTP:
commonFormat = .pcmFormatFloat32
interleaved = false
case AV_SAMPLE_FMT_DBLP:
commonFormat = .pcmFormatFloat64
interleaved = false
default:
commonFormat = .pcmFormatFloat32
interleaved = false
}
interleaved = KSOptions.audioPlayerType == AudioRendererPlayer.self
if !(KSOptions.audioPlayerType == AudioRendererPlayer.self || KSOptions.audioPlayerType == AudioUnitPlayer.self) {
commonFormat = .pcmFormatFloat32
}
return AVAudioFormat(commonFormat: commonFormat, sampleRate: Double(sampleRate), interleaved: interleaved, channelLayout: AVAudioChannelLayout(layoutTag: layoutTag)!)
// AVAudioChannelLayout(layout: outChannel.layoutTag.channelLayout)
}
public func updateAudioFormat() {
#if os(macOS)
let channelCount = AVAudioChannelCount(2)
#else
let channelCount = KSOptions.outputNumberOfChannels(channelCount: AVAudioChannelCount(channel.nb_channels))
#endif
audioFormat = AudioDescriptor.audioFormat(sampleFormat: sampleFormat, sampleRate: sampleRate, outChannel: &outChannel, channelCount: channelCount)
}
}