Files
simvision/KSPlayer-main/Sources/KSPlayer/AVPlayer/KSPlayerLayer.swift
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

708 lines
26 KiB
Swift
Raw 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.
//
// 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
}