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,707 @@
//
// KSPlayerLayerView.swift
// Pods
//
// Created by kintan on 16/4/28.
//
//
import AVFoundation
import AVKit
import MediaPlayer
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
/**
Player status emun
- setURL: set url
- readyToPlay: player ready to play
- buffering: player buffering
- bufferFinished: buffer finished
- playedToTheEnd: played to the End
- error: error with playing
*/
public enum KSPlayerState: CustomStringConvertible {
case initialized
case preparing
case readyToPlay
case buffering
case bufferFinished
case paused
case playedToTheEnd
case error
public var description: String {
switch self {
case .initialized:
return "initialized"
case .preparing:
return "preparing"
case .readyToPlay:
return "readyToPlay"
case .buffering:
return "buffering"
case .bufferFinished:
return "bufferFinished"
case .paused:
return "paused"
case .playedToTheEnd:
return "playedToTheEnd"
case .error:
return "error"
}
}
public var isPlaying: Bool { self == .buffering || self == .bufferFinished }
}
@MainActor
public protocol KSPlayerLayerDelegate: AnyObject {
func player(layer: KSPlayerLayer, state: KSPlayerState)
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval)
func player(layer: KSPlayerLayer, finish error: Error?)
func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval)
}
open class KSPlayerLayer: NSObject {
public weak var delegate: KSPlayerLayerDelegate?
@Published
public var bufferingProgress: Int = 0
@Published
public var loopCount: Int = 0
@Published
public var isPipActive = false {
didSet {
if #available(tvOS 14.0, *) {
guard let pipController = player.pipController else {
return
}
if isPipActive {
// asyncpip
DispatchQueue.main.async { [weak self] in
guard let self else { return }
pipController.start(view: self)
}
} else {
pipController.stop(restoreUserInterface: true)
}
}
}
}
public private(set) var options: KSOptions
public var player: MediaPlayerProtocol {
didSet {
KSLog("player is \(player)")
state = .initialized
runOnMainThread { [weak self] in
guard let self else { return }
if let oldView = oldValue.view, let superview = oldView.superview, let view = player.view {
#if canImport(UIKit)
superview.insertSubview(view, belowSubview: oldView)
#else
superview.addSubview(view, positioned: .below, relativeTo: oldView)
#endif
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: superview.topAnchor),
view.leadingAnchor.constraint(equalTo: superview.leadingAnchor),
view.bottomAnchor.constraint(equalTo: superview.bottomAnchor),
view.trailingAnchor.constraint(equalTo: superview.trailingAnchor),
])
}
oldValue.view?.removeFromSuperview()
}
player.playbackRate = oldValue.playbackRate
player.playbackVolume = oldValue.playbackVolume
player.delegate = self
player.contentMode = .scaleAspectFit
if isAutoPlay {
prepareToPlay()
}
}
}
public private(set) var url: URL {
didSet {
let firstPlayerType: MediaPlayerProtocol.Type
if isWirelessRouteActive {
// airplay使KSAVPlayer
firstPlayerType = KSAVPlayer.self
} else if options.display != .plane {
// ARKSMEPlayer
// swiftlint:disable force_cast
firstPlayerType = NSClassFromString("KSPlayer.KSMEPlayer") as! MediaPlayerProtocol.Type
// swiftlint:enable force_cast
} else {
firstPlayerType = KSOptions.firstPlayerType
}
if type(of: player) == firstPlayerType {
if url == oldValue {
if isAutoPlay {
play()
}
} else {
stop()
player.replace(url: url, options: options)
if isAutoPlay {
prepareToPlay()
}
}
} else {
stop()
player = firstPlayerType.init(url: url, options: options)
}
}
}
///
public private(set) var state = KSPlayerState.initialized {
willSet {
if state != newValue {
runOnMainThread { [weak self] in
guard let self else { return }
KSLog("playerStateDidChange - \(newValue)")
self.delegate?.player(layer: self, state: newValue)
}
}
}
}
private lazy var timer: Timer = .scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self, self.player.isReadyToPlay else {
return
}
self.delegate?.player(layer: self, currentTime: self.player.currentPlaybackTime, totalTime: self.player.duration)
if self.player.playbackState == .playing, self.player.loadState == .playable, self.state == .buffering {
//
self.state = .bufferFinished
}
if self.player.isPlaying {
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentPlaybackTime
}
}
private var urls = [URL]()
private var isAutoPlay: Bool
private var isWirelessRouteActive = false
private var bufferedCount = 0
private var shouldSeekTo: TimeInterval = 0
private var startTime: TimeInterval = 0
public init(url: URL, isAutoPlay: Bool = KSOptions.isAutoPlay, options: KSOptions, delegate: KSPlayerLayerDelegate? = nil) {
self.url = url
self.options = options
self.delegate = delegate
let firstPlayerType: MediaPlayerProtocol.Type
if options.display != .plane {
// ARKSMEPlayer
// swiftlint:disable force_cast
firstPlayerType = NSClassFromString("KSPlayer.KSMEPlayer") as! MediaPlayerProtocol.Type
// swiftlint:enable force_cast
} else {
firstPlayerType = KSOptions.firstPlayerType
}
player = firstPlayerType.init(url: url, options: options)
self.isAutoPlay = isAutoPlay
super.init()
player.playbackRate = options.startPlayRate
if options.registerRemoteControll {
registerRemoteControllEvent()
}
player.delegate = self
player.contentMode = .scaleAspectFit
if isAutoPlay {
prepareToPlay()
}
#if canImport(UIKit)
runOnMainThread { [weak self] in
guard let self else { return }
NotificationCenter.default.addObserver(self, selector: #selector(enterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(enterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}
#if !os(xrOS)
NotificationCenter.default.addObserver(self, selector: #selector(wirelessRouteActiveDidChange(notification:)), name: .MPVolumeViewWirelessRouteActiveDidChange, object: nil)
#endif
#endif
#if !os(macOS)
NotificationCenter.default.addObserver(self, selector: #selector(audioInterrupted), name: AVAudioSession.interruptionNotification, object: nil)
#endif
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if #available(iOS 15.0, tvOS 15.0, macOS 12.0, *) {
player.pipController?.contentSource = nil
}
NotificationCenter.default.removeObserver(self)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().stopCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().changeRepeatModeCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().changePlaybackRateCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().skipForwardCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().skipBackwardCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().enableLanguageOptionCommand.removeTarget(nil)
options.playerLayerDeinit()
}
public func set(url: URL, options: KSOptions) {
self.options = options
runOnMainThread {
self.url = url
}
}
public func set(urls: [URL], options: KSOptions) {
self.options = options
self.urls.removeAll()
self.urls.append(contentsOf: urls)
if let first = urls.first {
runOnMainThread {
self.url = first
}
}
}
open func play() {
runOnMainThread {
UIApplication.shared.isIdleTimerDisabled = true
}
isAutoPlay = true
if state == .error || state == .initialized {
prepareToPlay()
}
if player.isReadyToPlay {
if state == .playedToTheEnd {
player.seek(time: 0) { [weak self] finished in
guard let self else { return }
if finished {
self.player.play()
}
}
} else {
player.play()
}
timer.fireDate = Date.distantPast
}
state = player.loadState == .playable ? .bufferFinished : .buffering
MPNowPlayingInfoCenter.default().playbackState = .playing
if #available(tvOS 14.0, *) {
KSPictureInPictureController.mute()
}
}
open func pause() {
isAutoPlay = false
player.pause()
timer.fireDate = Date.distantFuture
state = .paused
MPNowPlayingInfoCenter.default().playbackState = .paused
runOnMainThread {
UIApplication.shared.isIdleTimerDisabled = false
}
}
public func stop() {
KSLog("stop Player")
state = .initialized
player.shutdown()
bufferedCount = 0
shouldSeekTo = 0
player.playbackRate = 1
player.playbackVolume = 1
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
runOnMainThread {
UIApplication.shared.isIdleTimerDisabled = false
}
}
open func seek(time: TimeInterval, autoPlay: Bool, completion: @escaping ((Bool) -> Void)) {
if time.isInfinite || time.isNaN {
completion(false)
}
if player.isReadyToPlay, player.seekable {
player.seek(time: time) { [weak self] finished in
guard let self else { return }
if finished, autoPlay {
self.play()
}
completion(finished)
}
} else {
isAutoPlay = autoPlay
shouldSeekTo = time
completion(false)
}
}
}
// MARK: - MediaPlayerDelegate
extension KSPlayerLayer: MediaPlayerDelegate {
public func readyToPlay(player: some MediaPlayerProtocol) {
state = .readyToPlay
#if os(macOS)
runOnMainThread { [weak self] in
guard let self else { return }
if let window = player.view?.window {
window.isMovableByWindowBackground = true
if options.automaticWindowResize {
let naturalSize = player.naturalSize
if naturalSize.width > 0, naturalSize.height > 0 {
window.aspectRatio = naturalSize
var frame = window.frame
frame.size.height = frame.width * naturalSize.height / naturalSize.width
window.setFrame(frame, display: true)
}
}
}
}
#endif
#if !os(macOS) && !os(tvOS)
if #available(iOS 14.2, *) {
if options.canStartPictureInPictureAutomaticallyFromInline {
player.pipController?.canStartPictureInPictureAutomaticallyFromInline = true
}
}
#endif
updateNowPlayingInfo()
if isAutoPlay {
if shouldSeekTo > 0 {
seek(time: shouldSeekTo, autoPlay: true) { [weak self] _ in
guard let self else { return }
self.shouldSeekTo = 0
}
} else {
play()
}
}
}
public func changeLoadState(player: some MediaPlayerProtocol) {
guard player.playbackState != .seeking else { return }
if player.loadState == .playable, startTime > 0 {
let diff = CACurrentMediaTime() - startTime
runOnMainThread { [weak self] in
guard let self else { return }
delegate?.player(layer: self, bufferedCount: bufferedCount, consumeTime: diff)
}
if bufferedCount == 0 {
var dic = ["firstTime": diff]
if options.tcpConnectedTime > 0 {
dic["initTime"] = options.dnsStartTime - startTime
dic["dnsTime"] = options.tcpStartTime - options.dnsStartTime
dic["tcpTime"] = options.tcpConnectedTime - options.tcpStartTime
dic["openTime"] = options.openTime - options.tcpConnectedTime
dic["findTime"] = options.findTime - options.openTime
} else {
dic["openTime"] = options.openTime - startTime
}
dic["findTime"] = options.findTime - options.openTime
dic["readyTime"] = options.readyTime - options.findTime
dic["readVideoTime"] = options.readVideoTime - options.readyTime
dic["readAudioTime"] = options.readAudioTime - options.readyTime
dic["decodeVideoTime"] = options.decodeVideoTime - options.readVideoTime
dic["decodeAudioTime"] = options.decodeAudioTime - options.readAudioTime
KSLog(dic)
}
bufferedCount += 1
startTime = 0
}
guard state.isPlaying else { return }
if player.loadState == .playable {
state = .bufferFinished
} else {
if state == .bufferFinished {
startTime = CACurrentMediaTime()
}
state = .buffering
}
}
public func changeBuffering(player _: some MediaPlayerProtocol, progress: Int) {
bufferingProgress = progress
}
public func playBack(player _: some MediaPlayerProtocol, loopCount: Int) {
self.loopCount = loopCount
}
public func finish(player: some MediaPlayerProtocol, error: Error?) {
if let error {
if type(of: player) != KSOptions.secondPlayerType, let secondPlayerType = KSOptions.secondPlayerType {
self.player = secondPlayerType.init(url: url, options: options)
return
}
state = .error
KSLog(error as CustomStringConvertible)
} else {
let duration = player.duration
runOnMainThread { [weak self] in
guard let self else { return }
delegate?.player(layer: self, currentTime: duration, totalTime: duration)
}
state = .playedToTheEnd
}
timer.fireDate = Date.distantFuture
bufferedCount = 1
runOnMainThread { [weak self] in
guard let self else { return }
delegate?.player(layer: self, finish: error)
}
if error == nil {
nextPlayer()
}
}
}
// MARK: - AVPictureInPictureControllerDelegate
@available(tvOS 14.0, *)
extension KSPlayerLayer: AVPictureInPictureControllerDelegate {
public func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
player.pipController?.stop(restoreUserInterface: false)
}
public func pictureInPictureController(_: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler _: @escaping (Bool) -> Void) {
isPipActive = false
}
}
// MARK: - private functions
extension KSPlayerLayer {
open func prepareToPlay() {
state = .preparing
startTime = CACurrentMediaTime()
bufferedCount = 0
player.prepareToPlay()
}
private func updateNowPlayingInfo() {
if MPNowPlayingInfoCenter.default().nowPlayingInfo == nil {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [MPMediaItemPropertyPlaybackDuration: player.duration]
} else {
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = player.duration
}
if MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyTitle] == nil, let title = player.dynamicInfo?.metadata["title"] {
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyTitle] = title
}
if MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyArtist] == nil, let artist = player.dynamicInfo?.metadata["artist"] {
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyArtist] = artist
}
var current: [MPNowPlayingInfoLanguageOption] = []
var langs: [MPNowPlayingInfoLanguageOptionGroup] = []
for track in player.tracks(mediaType: .audio) {
if let lang = track.language {
let audioLang = MPNowPlayingInfoLanguageOption(type: .audible, languageTag: lang, characteristics: nil, displayName: track.name, identifier: track.name)
let audioGroup = MPNowPlayingInfoLanguageOptionGroup(languageOptions: [audioLang], defaultLanguageOption: nil, allowEmptySelection: false)
langs.append(audioGroup)
if track.isEnabled {
current.append(audioLang)
}
}
}
if !langs.isEmpty {
MPRemoteCommandCenter.shared().enableLanguageOptionCommand.isEnabled = true
}
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyAvailableLanguageOptions] = langs
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyCurrentLanguageOptions] = current
}
private func nextPlayer() {
if urls.count > 1, let index = urls.firstIndex(of: url), index < urls.count - 1 {
isAutoPlay = true
url = urls[index + 1]
}
}
private func previousPlayer() {
if urls.count > 1, let index = urls.firstIndex(of: url), index > 0 {
isAutoPlay = true
url = urls[index - 1]
}
}
func seek(time: TimeInterval) {
seek(time: time, autoPlay: options.isSeekedAutoPlay) { _ in
}
}
public func registerRemoteControllEvent() {
let remoteCommand = MPRemoteCommandCenter.shared()
remoteCommand.playCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
self.play()
return .success
}
remoteCommand.pauseCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
self.pause()
return .success
}
remoteCommand.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
if self.state.isPlaying {
self.pause()
} else {
self.play()
}
return .success
}
remoteCommand.stopCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
self.player.shutdown()
return .success
}
remoteCommand.nextTrackCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
self.nextPlayer()
return .success
}
remoteCommand.previousTrackCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
self.previousPlayer()
return .success
}
remoteCommand.changeRepeatModeCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPChangeRepeatModeCommandEvent else {
return .commandFailed
}
self.options.isLoopPlay = event.repeatType != .off
return .success
}
remoteCommand.changeShuffleModeCommand.isEnabled = false
// remoteCommand.changeShuffleModeCommand.addTarget {})
remoteCommand.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 1, 1.5, 2]
remoteCommand.changePlaybackRateCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPChangePlaybackRateCommandEvent else {
return .commandFailed
}
self.player.playbackRate = event.playbackRate
return .success
}
remoteCommand.skipForwardCommand.preferredIntervals = [15]
remoteCommand.skipForwardCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self.seek(time: self.player.currentPlaybackTime + event.interval)
return .success
}
remoteCommand.skipBackwardCommand.preferredIntervals = [15]
remoteCommand.skipBackwardCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self.seek(time: self.player.currentPlaybackTime - event.interval)
return .success
}
remoteCommand.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
self.seek(time: event.positionTime)
return .success
}
remoteCommand.enableLanguageOptionCommand.addTarget { [weak self] event in
guard let self, let event = event as? MPChangeLanguageOptionCommandEvent else {
return .commandFailed
}
let selectLang = event.languageOption
if selectLang.languageOptionType == .audible,
let trackToSelect = self.player.tracks(mediaType: .audio).first(where: { $0.name == selectLang.displayName })
{
self.player.select(track: trackToSelect)
}
return .success
}
}
@objc private func enterBackground() {
guard state.isPlaying, !player.isExternalPlaybackActive else {
return
}
if #available(tvOS 14.0, *), player.pipController?.isPictureInPictureActive == true {
return
}
if KSOptions.canBackgroundPlay {
player.enterBackground()
return
}
pause()
}
@objc private func enterForeground() {
if KSOptions.canBackgroundPlay {
player.enterForeground()
}
}
#if canImport(UIKit) && !os(xrOS)
@MainActor
@objc private func wirelessRouteActiveDidChange(notification: Notification) {
guard let volumeView = notification.object as? MPVolumeView, isWirelessRouteActive != volumeView.isWirelessRouteActive else { return }
if volumeView.isWirelessRouteActive {
if !player.allowsExternalPlayback {
isWirelessRouteActive = true
}
player.usesExternalPlaybackWhileExternalScreenIsActive = true
}
isWirelessRouteActive = volumeView.isWirelessRouteActive
}
#endif
#if !os(macOS)
@objc private func audioInterrupted(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
pause()
case .ended:
// An interruption ended. Resume playback, if appropriate.
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
play()
}
default:
break
}
}
#endif
}