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

593 lines
20 KiB
Swift

import AVFoundation
import AVKit
#if canImport(UIKit)
import UIKit
#else
import AppKit
public typealias UIImage = NSImage
#endif
import Combine
import CoreGraphics
public final class KSAVPlayerView: UIView {
public let player = AVQueuePlayer()
override public init(frame: CGRect) {
super.init(frame: frame)
#if !canImport(UIKit)
layer = AVPlayerLayer()
#endif
playerLayer.player = player
player.automaticallyWaitsToMinimizeStalling = false
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public var contentMode: UIViewContentMode {
get {
switch playerLayer.videoGravity {
case .resize:
return .scaleToFill
case .resizeAspect:
return .scaleAspectFit
case .resizeAspectFill:
return .scaleAspectFill
default:
return .scaleAspectFit
}
}
set {
switch newValue {
case .scaleToFill:
playerLayer.videoGravity = .resize
case .scaleAspectFit:
playerLayer.videoGravity = .resizeAspect
case .scaleAspectFill:
playerLayer.videoGravity = .resizeAspectFill
case .center:
playerLayer.videoGravity = .resizeAspect
default:
break
}
}
}
#if canImport(UIKit)
override public class var layerClass: AnyClass { AVPlayerLayer.self }
#endif
fileprivate var playerLayer: AVPlayerLayer {
// swiftlint:disable force_cast
layer as! AVPlayerLayer
// swiftlint:enable force_cast
}
}
@MainActor
public class KSAVPlayer {
private var cancellable: AnyCancellable?
private var options: KSOptions {
didSet {
player.currentItem?.preferredForwardBufferDuration = options.preferredForwardBufferDuration
cancellable = options.$preferredForwardBufferDuration.sink { [weak self] newValue in
self?.player.currentItem?.preferredForwardBufferDuration = newValue
}
}
}
private let playerView = KSAVPlayerView()
private var urlAsset: AVURLAsset
private var shouldSeekTo = TimeInterval(0)
private var playerLooper: AVPlayerLooper?
private var statusObservation: NSKeyValueObservation?
private var loadedTimeRangesObservation: NSKeyValueObservation?
private var bufferEmptyObservation: NSKeyValueObservation?
private var likelyToKeepUpObservation: NSKeyValueObservation?
private var bufferFullObservation: NSKeyValueObservation?
private var itemObservation: NSKeyValueObservation?
private var loopCountObservation: NSKeyValueObservation?
private var loopStatusObservation: NSKeyValueObservation?
private var mediaPlayerTracks = [AVMediaPlayerTrack]()
private var error: Error? {
didSet {
if let error {
delegate?.finish(player: self, error: error)
}
}
}
private lazy var _pipController: Any? = {
if #available(tvOS 14.0, *) {
let pip = KSPictureInPictureController(playerLayer: playerView.playerLayer)
return pip
} else {
return nil
}
}()
@available(tvOS 14.0, *)
public var pipController: KSPictureInPictureController? {
_pipController as? KSPictureInPictureController
}
public var naturalSize: CGSize = .zero
public let dynamicInfo: DynamicInfo? = nil
@available(macOS 12.0, iOS 15.0, tvOS 15.0, *)
public var playbackCoordinator: AVPlaybackCoordinator {
playerView.player.playbackCoordinator
}
public private(set) var bufferingProgress = 0 {
didSet {
delegate?.changeBuffering(player: self, progress: bufferingProgress)
}
}
public weak var delegate: MediaPlayerDelegate?
public private(set) var duration: TimeInterval = 0
public private(set) var fileSize: Double = 0
public private(set) var playableTime: TimeInterval = 0
public let chapters: [Chapter] = []
public var playbackRate: Float = 1 {
didSet {
if playbackState == .playing {
player.rate = playbackRate
}
}
}
public var playbackVolume: Float = 1.0 {
didSet {
if player.volume != playbackVolume {
player.volume = playbackVolume
}
}
}
public private(set) var loadState = MediaLoadState.idle {
didSet {
if loadState != oldValue {
playOrPause()
if loadState == .loading || loadState == .idle {
bufferingProgress = 0
}
}
}
}
public private(set) var playbackState = MediaPlaybackState.idle {
didSet {
if playbackState != oldValue {
playOrPause()
if playbackState == .finished {
delegate?.finish(player: self, error: nil)
}
}
}
}
public private(set) var isReadyToPlay = false {
didSet {
if isReadyToPlay != oldValue {
if isReadyToPlay {
options.readyTime = CACurrentMediaTime()
delegate?.readyToPlay(player: self)
}
}
}
}
#if os(xrOS)
public var allowsExternalPlayback = false
public var usesExternalPlaybackWhileExternalScreenIsActive = false
public let isExternalPlaybackActive = false
#else
public var allowsExternalPlayback: Bool {
get {
player.allowsExternalPlayback
}
set {
player.allowsExternalPlayback = newValue
}
}
#if os(macOS)
public var usesExternalPlaybackWhileExternalScreenIsActive = false
#else
public var usesExternalPlaybackWhileExternalScreenIsActive: Bool {
get {
player.usesExternalPlaybackWhileExternalScreenIsActive
}
set {
player.usesExternalPlaybackWhileExternalScreenIsActive = newValue
}
}
#endif
public var isExternalPlaybackActive: Bool {
player.isExternalPlaybackActive
}
#endif
public required init(url: URL, options: KSOptions) {
KSOptions.setAudioSession()
urlAsset = AVURLAsset(url: url, options: options.avOptions)
self.options = options
itemObservation = player.observe(\.currentItem) { [weak self] player, _ in
guard let self else { return }
self.observer(playerItem: player.currentItem)
}
}
}
extension KSAVPlayer {
public var player: AVQueuePlayer { playerView.player }
public var playerLayer: AVPlayerLayer { playerView.playerLayer }
@objc private func moviePlayDidEnd(notification _: Notification) {
if !options.isLoopPlay {
playbackState = .finished
}
}
@objc private func playerItemFailedToPlayToEndTime(notification: Notification) {
var playError: Error?
if let userInfo = notification.userInfo {
if let error = userInfo["error"] as? Error {
playError = error
} else if let error = userInfo[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError {
playError = error
} else if let errorCode = (userInfo["error"] as? NSNumber)?.intValue {
playError = NSError(domain: "AVMoviePlayer", code: errorCode, userInfo: nil)
}
}
delegate?.finish(player: self, error: playError)
}
private func updateStatus(item: AVPlayerItem) {
if item.status == .readyToPlay {
options.findTime = CACurrentMediaTime()
mediaPlayerTracks = item.tracks.map {
AVMediaPlayerTrack(track: $0)
}
let playableVideo = mediaPlayerTracks.first {
$0.mediaType == .video && $0.isPlayable
}
if let playableVideo {
naturalSize = playableVideo.naturalSize
} else {
error = NSError(errorCode: .videoTracksUnplayable)
return
}
//
item.tracks.filter { $0.assetTrack?.mediaType.rawValue == AVMediaType.audio.rawValue }.dropFirst().forEach { $0.isEnabled = false }
duration = item.duration.seconds
let estimatedDataRates = item.tracks.compactMap { $0.assetTrack?.estimatedDataRate }
fileSize = Double(estimatedDataRates.reduce(0, +)) * duration / 8
isReadyToPlay = true
} else if item.status == .failed {
error = item.error
}
}
private func updatePlayableDuration(item: AVPlayerItem) {
let first = item.loadedTimeRanges.first { CMTimeRangeContainsTime($0.timeRangeValue, time: item.currentTime()) }
if let first {
playableTime = first.timeRangeValue.end.seconds
guard playableTime > 0 else { return }
let loadedTime = playableTime - currentPlaybackTime
guard loadedTime > 0 else { return }
bufferingProgress = Int(min(loadedTime * 100 / item.preferredForwardBufferDuration, 100))
if bufferingProgress >= 100 {
loadState = .playable
}
}
}
private func playOrPause() {
if playbackState == .playing {
if loadState == .playable {
player.play()
player.rate = playbackRate
}
} else {
player.pause()
}
delegate?.changeLoadState(player: self)
}
private func replaceCurrentItem(playerItem: AVPlayerItem?) {
player.currentItem?.cancelPendingSeeks()
if options.isLoopPlay {
loopCountObservation?.invalidate()
loopStatusObservation?.invalidate()
playerLooper?.disableLooping()
guard let playerItem else {
playerLooper = nil
return
}
playerLooper = AVPlayerLooper(player: player, templateItem: playerItem)
loopCountObservation = playerLooper?.observe(\.loopCount) { [weak self] playerLooper, _ in
guard let self else { return }
self.delegate?.playBack(player: self, loopCount: playerLooper.loopCount)
}
loopStatusObservation = playerLooper?.observe(\.status) { [weak self] playerLooper, _ in
guard let self else { return }
if playerLooper.status == .failed {
self.error = playerLooper.error
}
}
} else {
player.replaceCurrentItem(with: playerItem)
}
}
private func observer(playerItem: AVPlayerItem?) {
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: playerItem)
statusObservation?.invalidate()
loadedTimeRangesObservation?.invalidate()
bufferEmptyObservation?.invalidate()
likelyToKeepUpObservation?.invalidate()
bufferFullObservation?.invalidate()
guard let playerItem else { return }
NotificationCenter.default.addObserver(self, selector: #selector(moviePlayDidEnd), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
NotificationCenter.default.addObserver(self, selector: #selector(playerItemFailedToPlayToEndTime), name: .AVPlayerItemFailedToPlayToEndTime, object: playerItem)
statusObservation = playerItem.observe(\.status) { [weak self] item, _ in
guard let self else { return }
self.updateStatus(item: item)
}
loadedTimeRangesObservation = playerItem.observe(\.loadedTimeRanges) { [weak self] item, _ in
guard let self else { return }
//
self.updatePlayableDuration(item: item)
}
let changeHandler: (AVPlayerItem, NSKeyValueObservedChange<Bool>) -> Void = { [weak self] _, _ in
guard let self else { return }
// 线
if playerItem.isPlaybackBufferEmpty {
self.loadState = .loading
} else if playerItem.isPlaybackLikelyToKeepUp || playerItem.isPlaybackBufferFull {
self.loadState = .playable
}
}
bufferEmptyObservation = playerItem.observe(\.isPlaybackBufferEmpty, changeHandler: changeHandler)
likelyToKeepUpObservation = playerItem.observe(\.isPlaybackLikelyToKeepUp, changeHandler: changeHandler)
bufferFullObservation = playerItem.observe(\.isPlaybackBufferFull, changeHandler: changeHandler)
}
}
extension KSAVPlayer: MediaPlayerProtocol {
public var subtitleDataSouce: SubtitleDataSouce? { nil }
public var isPlaying: Bool { player.rate > 0 ? true : playbackState == .playing }
public var view: UIView? { playerView }
public var currentPlaybackTime: TimeInterval {
get {
if shouldSeekTo > 0 {
return TimeInterval(shouldSeekTo)
} else {
//
return isReadyToPlay ? player.currentTime().seconds : 0
}
}
set {
seek(time: newValue) { _ in
}
}
}
public var numberOfBytesTransferred: Int64 {
guard let playerItem = player.currentItem, let accesslog = playerItem.accessLog(), let event = accesslog.events.first else {
return 0
}
return event.numberOfBytesTransferred
}
public func thumbnailImageAtCurrentTime() async -> CGImage? {
guard let playerItem = player.currentItem, isReadyToPlay else {
return nil
}
return await withCheckedContinuation { continuation in
urlAsset.thumbnailImage(currentTime: playerItem.currentTime()) { result in
continuation.resume(returning: result)
}
}
}
public func seek(time: TimeInterval, completion: @escaping ((Bool) -> Void)) {
let time = max(time, 0)
shouldSeekTo = time
playbackState = .seeking
runOnMainThread { [weak self] in
self?.bufferingProgress = 0
}
let tolerance: CMTime = options.isAccurateSeek ? .zero : .positiveInfinity
player.seek(to: CMTime(seconds: time), toleranceBefore: tolerance, toleranceAfter: tolerance) {
[weak self] finished in
guard let self else { return }
self.shouldSeekTo = 0
completion(finished)
}
}
public func prepareToPlay() {
KSLog("prepareToPlay \(self)")
options.prepareTime = CACurrentMediaTime()
runOnMainThread { [weak self] in
guard let self else { return }
self.bufferingProgress = 0
let playerItem = AVPlayerItem(asset: self.urlAsset)
self.options.openTime = CACurrentMediaTime()
self.replaceCurrentItem(playerItem: playerItem)
self.player.actionAtItemEnd = .pause
self.player.volume = self.playbackVolume
}
}
public func play() {
KSLog("play \(self)")
playbackState = .playing
}
public func pause() {
KSLog("pause \(self)")
playbackState = .paused
}
public func shutdown() {
KSLog("shutdown \(self)")
isReadyToPlay = false
playbackState = .stopped
loadState = .idle
urlAsset.cancelLoading()
replaceCurrentItem(playerItem: nil)
}
public func replace(url: URL, options: KSOptions) {
KSLog("replaceUrl \(self)")
shutdown()
urlAsset = AVURLAsset(url: url, options: options.avOptions)
self.options = options
}
public var contentMode: UIViewContentMode {
get {
playerView.contentMode
}
set {
playerView.contentMode = newValue
}
}
public func enterBackground() {
playerView.playerLayer.player = nil
}
public func enterForeground() {
playerView.playerLayer.player = playerView.player
}
public var seekable: Bool {
!(player.currentItem?.seekableTimeRanges.isEmpty ?? true)
}
public var isMuted: Bool {
get {
player.isMuted
}
set {
player.isMuted = newValue
}
}
public func tracks(mediaType: AVFoundation.AVMediaType) -> [MediaPlayerTrack] {
player.currentItem?.tracks.filter { $0.assetTrack?.mediaType == mediaType }.map { AVMediaPlayerTrack(track: $0) } ?? []
}
public func select(track: some MediaPlayerTrack) {
player.currentItem?.tracks.filter { $0.assetTrack?.mediaType == track.mediaType }.forEach { $0.isEnabled = false }
track.isEnabled = true
}
}
extension AVFoundation.AVMediaType {
var mediaCharacteristic: AVMediaCharacteristic {
switch self {
case .video:
return .visual
case .audio:
return .audible
case .subtitle:
return .legible
default:
return .easyToRead
}
}
}
extension AVAssetTrack {
func toMediaPlayerTrack() {}
}
class AVMediaPlayerTrack: MediaPlayerTrack {
let formatDescription: CMFormatDescription?
let description: String
private let track: AVPlayerItemTrack
var nominalFrameRate: Float
let trackID: Int32
let rotation: Int16 = 0
let bitDepth: Int32
let bitRate: Int64
let name: String
let languageCode: String?
let mediaType: AVFoundation.AVMediaType
let isImageSubtitle = false
var dovi: DOVIDecoderConfigurationRecord?
let fieldOrder: FFmpegFieldOrder = .unknown
var isPlayable: Bool
@MainActor
var isEnabled: Bool {
get {
track.isEnabled
}
set {
track.isEnabled = newValue
}
}
init(track: AVPlayerItemTrack) {
self.track = track
trackID = track.assetTrack?.trackID ?? 0
mediaType = track.assetTrack?.mediaType ?? .video
name = track.assetTrack?.languageCode ?? ""
languageCode = track.assetTrack?.languageCode
nominalFrameRate = track.assetTrack?.nominalFrameRate ?? 24.0
bitRate = Int64(track.assetTrack?.estimatedDataRate ?? 0)
#if os(xrOS)
isPlayable = false
#else
isPlayable = track.assetTrack?.isPlayable ?? false
#endif
// swiftlint:disable force_cast
if let first = track.assetTrack?.formatDescriptions.first {
formatDescription = first as! CMFormatDescription
} else {
formatDescription = nil
}
bitDepth = formatDescription?.bitDepth ?? 0
// swiftlint:enable force_cast
description = (formatDescription?.mediaSubType ?? .boxed).rawValue.string
#if os(xrOS)
Task {
isPlayable = await (try? track.assetTrack?.load(.isPlayable)) ?? false
}
#endif
}
func load() {}
}
public extension AVAsset {
func createImageGenerator() -> AVAssetImageGenerator {
let imageGenerator = AVAssetImageGenerator(asset: self)
imageGenerator.requestedTimeToleranceBefore = .zero
imageGenerator.requestedTimeToleranceAfter = .zero
return imageGenerator
}
func thumbnailImage(currentTime: CMTime, handler: @escaping (CGImage?) -> Void) {
let imageGenerator = createImageGenerator()
imageGenerator.requestedTimeToleranceBefore = .zero
imageGenerator.requestedTimeToleranceAfter = .zero
imageGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: currentTime)]) { _, cgImage, _, _, _ in
if let cgImage {
handler(cgImage)
} else {
handler(nil)
}
}
}
}