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,225 @@
//
// BrightnessVolume.swift
// KSPlayer
//
// Created by kintan on 2017/11/3.
//
#if canImport(UIKit)
import UIKit
@MainActor
open class BrightnessVolume {
private var brightnessObservation: NSKeyValueObservation?
public static let shared = BrightnessVolume()
public var progressView: BrightnessVolumeViewProtocol & UIView = ProgressView()
init() {
#if !os(tvOS) && !os(xrOS)
brightnessObservation = UIScreen.main.observe(\.brightness, options: .new) { [weak self] _, change in
guard KSOptions.enableBrightnessGestures else { return }
if let self, let value = change.newValue {
self.appearView()
self.progressView.setProgress(Float(value), type: 0)
}
}
#endif
let name = NSNotification.Name(rawValue: "AVSystemController_SystemVolumeDidChangeNotification")
NotificationCenter.default.addObserver(self, selector: #selector(volumeIsChanged(notification:)), name: name, object: nil)
progressView.alpha = 0.0
}
public func move(to view: UIView) {
progressView.move(to: view)
}
@objc private func volumeIsChanged(notification: NSNotification) {
guard KSOptions.enableVolumeGestures else { return }
if let changeReason = notification.userInfo?["AVSystemController_AudioVolumeChangeReasonNotificationParameter"] as? String, changeReason == "ExplicitVolumeChange" {
if let volume = notification.userInfo?["AVSystemController_AudioVolumeNotificationParameter"] as? CGFloat {
appearView()
progressView.setProgress(Float(volume), type: 1)
}
}
}
private func appearView() {
if progressView.alpha == 0.0 {
progressView.alpha = 1.0
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { [weak self] () in
self?.disAppearView()
}
}
}
private func disAppearView() {
if progressView.alpha == 1.0 {
UIView.animate(withDuration: 0.8) { [weak self] () in
self?.progressView.alpha = 0.0
}
}
}
deinit {
brightnessObservation?.invalidate()
}
}
public protocol BrightnessVolumeViewProtocol {
// type: 0 brightness type: 1 volume
func setProgress(_ progress: Float, type: UInt)
func move(to view: UIView)
}
private final class SystemView: UIVisualEffectView {
private let stackView = UIStackView()
private let imageView = UIImageView()
private let titleLabel = UILabel()
private lazy var brightnessImage = UIImage(systemName: "sun.max")
private lazy var volumeImage = UIImage(systemName: "speaker.wave.3.fill")
private convenience init() {
self.init(effect: UIBlurEffect(style: .extraLight))
clipsToBounds = true
cornerRadius = 10
imageView.image = brightnessImage
contentView.addSubview(imageView)
titleLabel.font = .systemFont(ofSize: 16)
titleLabel.textColor = UIColor(red: 0.25, green: 0.22, blue: 0.21, alpha: 1)
titleLabel.textAlignment = .center
titleLabel.text = "亮度"
contentView.addSubview(titleLabel)
let longView = UIView()
longView.backgroundColor = titleLabel.textColor
contentView.addSubview(longView)
stackView.alignment = .center
stackView.distribution = .fillEqually
stackView.axis = .horizontal
stackView.spacing = 1
longView.addSubview(stackView)
for _ in 0 ..< 16 {
let tipView = UIView()
tipView.backgroundColor = .white
stackView.addArrangedSubview(tipView)
tipView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tipView.heightAnchor.constraint(equalTo: stackView.heightAnchor),
])
}
translatesAutoresizingMaskIntoConstraints = false
imageView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
longView.translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 79),
imageView.heightAnchor.constraint(equalToConstant: 76),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 5),
titleLabel.widthAnchor.constraint(equalTo: widthAnchor),
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
titleLabel.heightAnchor.constraint(equalToConstant: 30),
longView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13),
longView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13),
longView.heightAnchor.constraint(equalToConstant: 7),
longView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16),
stackView.leadingAnchor.constraint(equalTo: longView.leadingAnchor, constant: 1),
stackView.trailingAnchor.constraint(equalTo: longView.trailingAnchor, constant: -1),
stackView.topAnchor.constraint(equalTo: longView.topAnchor, constant: 1),
stackView.bottomAnchor.constraint(equalTo: longView.bottomAnchor, constant: -1),
])
}
}
extension SystemView: BrightnessVolumeViewProtocol {
public func setProgress(_ progress: Float, type: UInt) {
if type == 0 {
imageView.image = brightnessImage
titleLabel.text = NSLocalizedString("brightness", comment: "")
} else {
imageView.image = volumeImage
titleLabel.text = NSLocalizedString("volume", comment: "")
}
let level = Int(progress * Float(stackView.arrangedSubviews.count))
for i in 0 ..< stackView.arrangedSubviews.count {
let view = stackView.arrangedSubviews[i]
if i <= level, level > 0 {
view.alpha = 1
} else {
view.alpha = 0
}
}
}
public func move(to view: UIView) {
if superview != view {
removeFromSuperview()
view.addSubview(self)
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerYAnchor.constraint(equalTo: view.centerYAnchor),
heightAnchor.constraint(equalToConstant: 155),
widthAnchor.constraint(equalToConstant: 155),
])
}
}
}
private final class ProgressView: UIView {
private lazy var brightnessImage = UIImage(systemName: "sun.max")
private lazy var volumeImage = UIImage(systemName: "speaker.fill")
private lazy var brightnessOffImage = UIImage(systemName: "sun.min")
private lazy var volumeOffImage = UIImage(systemName: "speaker.slash.fill")
private let progressView = UIProgressView()
private let imageView = UIImageView()
override init(frame _: CGRect) {
super.init(frame: .zero)
addSubview(progressView)
addSubview(imageView)
progressView.progressTintColor = UIColor.white
progressView.trackTintColor = UIColor.white.withAlphaComponent(0.5)
progressView.translatesAutoresizingMaskIntoConstraints = false
progressView.centerRotate(byDegrees: -90)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
progressView.widthAnchor.constraint(equalToConstant: 115),
progressView.heightAnchor.constraint(equalToConstant: 2),
progressView.centerXAnchor.constraint(equalTo: centerXAnchor),
progressView.topAnchor.constraint(equalTo: topAnchor, constant: 57),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension ProgressView: BrightnessVolumeViewProtocol {
func setProgress(_ progress: Float, type: UInt) {
progressView.setProgress(progress, animated: false)
if progress == 0 {
imageView.image = type == 0 ? brightnessOffImage : volumeOffImage
} else {
imageView.image = type == 0 ? brightnessImage : volumeImage
}
}
func move(to view: UIView) {
if superview != view {
removeFromSuperview()
view.addSubview(self)
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: -10),
centerYAnchor.constraint(equalTo: view.centerYAnchor),
heightAnchor.constraint(equalToConstant: 150),
widthAnchor.constraint(equalToConstant: 24),
])
}
}
}
#endif

View File

@@ -0,0 +1,474 @@
//
// IOSVideoPlayerView.swift
// Pods
//
// Created by kintan on 2018/10/31.
//
#if canImport(UIKit) && canImport(CallKit)
import AVKit
import Combine
import CoreServices
import MediaPlayer
import UIKit
open class IOSVideoPlayerView: VideoPlayerView {
private weak var originalSuperView: UIView?
private var originalframeConstraints: [NSLayoutConstraint]?
private var originalFrame = CGRect.zero
private var originalOrientations: UIInterfaceOrientationMask?
private weak var fullScreenDelegate: PlayerViewFullScreenDelegate?
private var isVolume = false
private let volumeView = BrightnessVolume()
public var volumeViewSlider = UXSlider()
public var backButton = UIButton()
public var airplayStatusView: UIView = AirplayStatusView()
#if !os(xrOS)
public var routeButton = AVRoutePickerView()
#endif
private let routeDetector = AVRouteDetector()
/// Image view to show video cover
public var maskImageView = UIImageView()
public var landscapeButton: UIControl = UIButton()
override open var isMaskShow: Bool {
didSet {
fullScreenDelegate?.player(isMaskShow: isMaskShow, isFullScreen: landscapeButton.isSelected)
}
}
#if !os(xrOS)
private var brightness: CGFloat = UIScreen.main.brightness {
didSet {
UIScreen.main.brightness = brightness
}
}
#endif
override open func customizeUIComponents() {
super.customizeUIComponents()
if UIDevice.current.userInterfaceIdiom == .phone {
subtitleLabel.font = .systemFont(ofSize: 14)
}
insertSubview(maskImageView, at: 0)
maskImageView.contentMode = .scaleAspectFit
toolBar.addArrangedSubview(landscapeButton)
landscapeButton.tag = PlayerButtonType.landscape.rawValue
landscapeButton.addTarget(self, action: #selector(onButtonPressed(_:)), for: .touchUpInside)
landscapeButton.tintColor = .white
if let landscapeButton = landscapeButton as? UIButton {
landscapeButton.setImage(UIImage(systemName: "arrow.up.left.and.arrow.down.right"), for: .normal)
landscapeButton.setImage(UIImage(systemName: "arrow.down.right.and.arrow.up.left"), for: .selected)
}
backButton.tag = PlayerButtonType.back.rawValue
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.addTarget(self, action: #selector(onButtonPressed(_:)), for: .touchUpInside)
backButton.tintColor = .white
navigationBar.insertArrangedSubview(backButton, at: 0)
addSubview(airplayStatusView)
volumeView.move(to: self)
#if !targetEnvironment(macCatalyst)
let tmp = MPVolumeView(frame: CGRect(x: -100, y: -100, width: 0, height: 0))
if let first = (tmp.subviews.first { $0 is UISlider }) as? UISlider {
volumeViewSlider = first
}
#endif
backButton.translatesAutoresizingMaskIntoConstraints = false
landscapeButton.translatesAutoresizingMaskIntoConstraints = false
maskImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
maskImageView.topAnchor.constraint(equalTo: topAnchor),
maskImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
maskImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
maskImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
backButton.widthAnchor.constraint(equalToConstant: 25),
landscapeButton.widthAnchor.constraint(equalToConstant: 30),
airplayStatusView.centerXAnchor.constraint(equalTo: centerXAnchor),
airplayStatusView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
#if !os(xrOS)
routeButton.isHidden = true
navigationBar.addArrangedSubview(routeButton)
routeButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
routeButton.widthAnchor.constraint(equalToConstant: 25),
])
#endif
addNotification()
}
override open func resetPlayer() {
super.resetPlayer()
maskImageView.alpha = 1
maskImageView.image = nil
panGesture.isEnabled = false
#if !os(xrOS)
routeButton.isHidden = !routeDetector.multipleRoutesDetected
#endif
}
override open func onButtonPressed(type: PlayerButtonType, button: UIButton) {
if type == .back, viewController is PlayerFullScreenViewController {
updateUI(isFullScreen: false)
return
}
super.onButtonPressed(type: type, button: button)
if type == .lock {
button.isSelected.toggle()
isMaskShow = !button.isSelected
button.alpha = 1.0
} else if type == .landscape {
updateUI(isFullScreen: !landscapeButton.isSelected)
}
}
open func isHorizonal() -> Bool {
playerLayer?.player.naturalSize.isHorizonal ?? true
}
open func updateUI(isFullScreen: Bool) {
guard let viewController else {
return
}
landscapeButton.isSelected = isFullScreen
let isHorizonal = isHorizonal()
viewController.navigationController?.interactivePopGestureRecognizer?.isEnabled = !isFullScreen
if isFullScreen {
if viewController is PlayerFullScreenViewController {
return
}
originalSuperView = superview
originalframeConstraints = frameConstraints
if let originalframeConstraints {
NSLayoutConstraint.deactivate(originalframeConstraints)
}
originalFrame = frame
originalOrientations = viewController.supportedInterfaceOrientations
let fullVC = PlayerFullScreenViewController(isHorizonal: isHorizonal)
fullScreenDelegate = fullVC
fullVC.view.addSubview(self)
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topAnchor.constraint(equalTo: fullVC.view.readableTopAnchor),
leadingAnchor.constraint(equalTo: fullVC.view.leadingAnchor),
trailingAnchor.constraint(equalTo: fullVC.view.trailingAnchor),
bottomAnchor.constraint(equalTo: fullVC.view.bottomAnchor),
])
fullVC.modalPresentationStyle = .fullScreen
fullVC.modalPresentationCapturesStatusBarAppearance = true
fullVC.transitioningDelegate = self
viewController.present(fullVC, animated: true) {
KSOptions.supportedInterfaceOrientations = fullVC.supportedInterfaceOrientations
}
} else {
guard viewController is PlayerFullScreenViewController else {
return
}
let presentingVC = viewController.presentingViewController ?? viewController
if let originalOrientations {
KSOptions.supportedInterfaceOrientations = originalOrientations
}
presentingVC.dismiss(animated: true) {
self.originalSuperView?.addSubview(self)
if let constraints = self.originalframeConstraints, !constraints.isEmpty {
NSLayoutConstraint.activate(constraints)
} else {
self.translatesAutoresizingMaskIntoConstraints = true
self.frame = self.originalFrame
}
}
}
let isLandscape = isFullScreen && isHorizonal
updateUI(isLandscape: isLandscape)
}
open func updateUI(isLandscape: Bool) {
if isLandscape {
topMaskView.isHidden = KSOptions.topBarShowInCase == .none
} else {
topMaskView.isHidden = KSOptions.topBarShowInCase != .always
}
toolBar.playbackRateButton.isHidden = false
toolBar.srtButton.isHidden = srtControl.subtitleInfos.isEmpty
if UIDevice.current.userInterfaceIdiom == .phone {
if isLandscape {
landscapeButton.isHidden = true
toolBar.srtButton.isHidden = srtControl.subtitleInfos.isEmpty
} else {
toolBar.srtButton.isHidden = true
if let image = maskImageView.image {
landscapeButton.isHidden = image.size.width < image.size.height
} else {
landscapeButton.isHidden = false
}
}
toolBar.playbackRateButton.isHidden = !isLandscape
} else {
landscapeButton.isHidden = true
}
lockButton.isHidden = !isLandscape
judgePanGesture()
}
override open func player(layer: KSPlayerLayer, state: KSPlayerState) {
super.player(layer: layer, state: state)
if state == .readyToPlay {
UIView.animate(withDuration: 0.3) {
self.maskImageView.alpha = 0.0
}
}
judgePanGesture()
}
override open func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
airplayStatusView.isHidden = !layer.player.isExternalPlaybackActive
super.player(layer: layer, currentTime: currentTime, totalTime: totalTime)
}
override open func set(resource: KSPlayerResource, definitionIndex: Int = 0, isSetUrl: Bool = true) {
super.set(resource: resource, definitionIndex: definitionIndex, isSetUrl: isSetUrl)
maskImageView.image(url: resource.cover)
}
override open func change(definitionIndex: Int) {
Task {
let image = await playerLayer?.player.thumbnailImageAtCurrentTime()
if let image {
self.maskImageView.image = UIImage(cgImage: image)
self.maskImageView.alpha = 1
}
super.change(definitionIndex: definitionIndex)
}
}
override open func panGestureBegan(location point: CGPoint, direction: KSPanDirection) {
if direction == .vertical {
if point.x > bounds.size.width / 2 {
isVolume = true
tmpPanValue = volumeViewSlider.value
} else {
isVolume = false
}
} else {
super.panGestureBegan(location: point, direction: direction)
}
}
override open func panGestureChanged(velocity point: CGPoint, direction: KSPanDirection) {
if direction == .vertical {
if isVolume {
if KSOptions.enableVolumeGestures {
tmpPanValue += panValue(velocity: point, direction: direction, currentTime: Float(toolBar.currentTime), totalTime: Float(totalTime))
tmpPanValue = max(min(tmpPanValue, 1), 0)
volumeViewSlider.value = tmpPanValue
}
} else if KSOptions.enableBrightnessGestures {
#if !os(xrOS)
brightness += CGFloat(panValue(velocity: point, direction: direction, currentTime: Float(toolBar.currentTime), totalTime: Float(totalTime)))
#endif
}
} else {
super.panGestureChanged(velocity: point, direction: direction)
}
}
open func judgePanGesture() {
if landscapeButton.isSelected || UIDevice.current.userInterfaceIdiom == .pad {
panGesture.isEnabled = isPlayed && !replayButton.isSelected
} else {
panGesture.isEnabled = toolBar.playButton.isSelected
}
}
}
extension IOSVideoPlayerView: UIViewControllerTransitioningDelegate {
public func animationController(forPresented _: UIViewController, presenting _: UIViewController, source _: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let originalSuperView, let animationView = playerLayer?.player.view {
return PlayerTransitionAnimator(containerView: originalSuperView, animationView: animationView)
}
return nil
}
public func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let originalSuperView, let animationView = playerLayer?.player.view {
return PlayerTransitionAnimator(containerView: originalSuperView, animationView: animationView, isDismiss: true)
} else {
return nil
}
}
}
// MARK: - private functions
extension IOSVideoPlayerView {
private func addNotification() {
// NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIApplication.didChangeStatusBarOrientationNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(routesAvailableDidChange), name: .AVRouteDetectorMultipleRoutesDetectedDidChange, object: nil)
}
@objc private func routesAvailableDidChange(notification _: Notification) {
#if !os(xrOS)
routeButton.isHidden = !routeDetector.multipleRoutesDetected
#endif
}
@objc private func orientationChanged(notification _: Notification) {
guard isHorizonal() else {
return
}
updateUI(isFullScreen: UIApplication.isLandscape)
}
}
public class AirplayStatusView: UIView {
override public init(frame: CGRect) {
super.init(frame: frame)
let airplayicon = UIImageView(image: UIImage(systemName: "airplayvideo"))
addSubview(airplayicon)
let airplaymessage = UILabel()
airplaymessage.backgroundColor = .clear
airplaymessage.textColor = .white
airplaymessage.font = .systemFont(ofSize: 14)
airplaymessage.text = NSLocalizedString("AirPlay 投放中", comment: "")
airplaymessage.textAlignment = .center
addSubview(airplaymessage)
translatesAutoresizingMaskIntoConstraints = false
airplayicon.translatesAutoresizingMaskIntoConstraints = false
airplaymessage.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
widthAnchor.constraint(equalToConstant: 100),
heightAnchor.constraint(equalToConstant: 115),
airplayicon.topAnchor.constraint(equalTo: topAnchor),
airplayicon.centerXAnchor.constraint(equalTo: centerXAnchor),
airplayicon.widthAnchor.constraint(equalToConstant: 100),
airplayicon.heightAnchor.constraint(equalToConstant: 100),
airplaymessage.bottomAnchor.constraint(equalTo: bottomAnchor),
airplaymessage.leadingAnchor.constraint(equalTo: leadingAnchor),
airplaymessage.trailingAnchor.constraint(equalTo: trailingAnchor),
airplaymessage.heightAnchor.constraint(equalToConstant: 15),
])
isHidden = true
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public extension KSOptions {
/// func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask
static var supportedInterfaceOrientations = UIInterfaceOrientationMask.portrait
}
extension UIApplication {
static var isLandscape: Bool {
UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? false
}
}
// MARK: - menu
extension IOSVideoPlayerView {
override open var canBecomeFirstResponder: Bool {
true
}
override open func canPerformAction(_ action: Selector, withSender _: Any?) -> Bool {
if action == #selector(IOSVideoPlayerView.openFileAction) {
return true
}
return true
}
@objc fileprivate func openFileAction(_: AnyObject) {
let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypeAudio, kUTTypeMovie, kUTTypePlainText] as [String], in: .open)
documentPicker.delegate = self
viewController?.present(documentPicker, animated: true, completion: nil)
}
}
extension IOSVideoPlayerView: UIDocumentPickerDelegate {
public func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
if let url = urls.first {
if url.isMovie || url.isAudio {
set(url: url, options: KSOptions())
} else {
srtControl.selectedSubtitleInfo = URLSubtitleInfo(url: url)
}
}
}
}
#endif
#if os(iOS)
@MainActor
public class MenuController {
public init(with builder: UIMenuBuilder) {
builder.remove(menu: .format)
builder.insertChild(MenuController.openFileMenu(), atStartOfMenu: .file)
// builder.insertChild(MenuController.openURLMenu(), atStartOfMenu: .file)
// builder.insertChild(MenuController.navigationMenu(), atStartOfMenu: .file)
}
class func openFileMenu() -> UIMenu {
let openCommand = UIKeyCommand(input: "O", modifierFlags: .command, action: #selector(IOSVideoPlayerView.openFileAction(_:)))
openCommand.title = NSLocalizedString("Open File", comment: "")
let openMenu = UIMenu(title: "",
image: nil,
identifier: UIMenu.Identifier("com.example.apple-samplecode.menus.openFileMenu"),
options: .displayInline,
children: [openCommand])
return openMenu
}
// class func openURLMenu() -> UIMenu {
// let openCommand = UIKeyCommand(input: "O", modifierFlags: [.command, .shift], action: #selector(IOSVideoPlayerView.openURLAction(_:)))
// openCommand.title = NSLocalizedString("Open URL", comment: "")
// let openMenu = UIMenu(title: "",
// image: nil,
// identifier: UIMenu.Identifier("com.example.apple-samplecode.menus.openURLMenu"),
// options: .displayInline,
// children: [openCommand])
// return openMenu
// }
// class func navigationMenu() -> UIMenu {
// let arrowKeyChildrenCommands = Arrows.allCases.map { arrow in
// UIKeyCommand(title: arrow.localizedString(),
// image: nil,
// action: #selector(IOSVideoPlayerView.navigationMenuAction(_:)),
// input: arrow.command,
// modifierFlags: .command)
// }
// return UIMenu(title: NSLocalizedString("NavigationTitle", comment: ""),
// image: nil,
// identifier: UIMenu.Identifier("com.example.apple-samplecode.menus.navigationMenu"),
// options: [],
// children: arrowKeyChildrenCommands)
// }
enum Arrows: String, CaseIterable {
case rightArrow
case leftArrow
case upArrow
case downArrow
func localizedString() -> String {
NSLocalizedString("\(rawValue)", comment: "")
}
@MainActor
var command: String {
switch self {
case .rightArrow:
return UIKeyCommand.inputRightArrow
case .leftArrow:
return UIKeyCommand.inputLeftArrow
case .upArrow:
return UIKeyCommand.inputUpArrow
case .downArrow:
return UIKeyCommand.inputDownArrow
}
}
}
}
#endif

View File

@@ -0,0 +1,99 @@
//
// KSMenu.swift
// KSPlayer
//
// Created by Alanko5 on 15/12/2022.
//
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
extension UIMenu {
func updateActionState(actionTitle: String? = nil) -> UIMenu {
for action in children {
guard let action = action as? UIAction else {
continue
}
action.state = action.title == actionTitle ? .on : .off
}
return self
}
@available(tvOS 15.0, *)
convenience init?<U>(title: String, current: U?, list: [U], addDisabled: Bool = false, titleFunc: (U) -> String, completition: @escaping (String, U?) -> Void) {
if list.count < (addDisabled ? 1 : 2) {
return nil
}
var actions = list.map { value in
let item = UIAction(title: titleFunc(value)) { item in
completition(item.title, value)
}
if let current, titleFunc(value) == titleFunc(current) {
item.state = .on
}
return item
}
if addDisabled {
actions.insert(UIAction(title: "Disabled") { item in
completition(item.title, nil)
}, at: 0)
}
self.init(title: title, children: actions)
}
}
#if !os(tvOS)
extension UIButton {
@available(iOS 14.0, *)
func setMenu<U>(title: String, current: U?, list: [U], addDisabled: Bool = false, titleFunc: (U) -> String, completition handler: @escaping (U?) -> Void) {
menu = UIMenu(title: title, current: current, list: list, addDisabled: addDisabled, titleFunc: titleFunc) { [weak self] title, value in
guard let self else { return }
handler(value)
self.menu = self.menu?.updateActionState(actionTitle: title)
}
}
}
#endif
#if canImport(UIKit)
#else
public typealias UIMenu = NSMenu
public final class UIAction: NSMenuItem {
private let handler: (UIAction) -> Void
init(title: String, handler: @escaping (UIAction) -> Void) {
self.handler = handler
super.init(title: title, action: #selector(menuPressed), keyEquivalent: "")
state = .off
target = self
}
@objc private func menuPressed() {
handler(self)
}
@available(*, unavailable)
required init(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension UIMenu {
var children: [NSMenuItem] {
items
}
convenience init(title: String, children: [UIAction]) {
self.init(title: title)
for item in children {
addItem(item)
}
}
}
#endif

View File

@@ -0,0 +1,147 @@
//
// KSPlayerItem.swift
// Pods
//
// Created by kintan on 16/5/21.
//
//
import AVFoundation
import Foundation
import MediaPlayer
public class KSPlayerResource: Equatable, Hashable {
public static func == (lhs: KSPlayerResource, rhs: KSPlayerResource) -> Bool {
lhs.definitions == rhs.definitions
}
public let name: String
public let definitions: [KSPlayerResourceDefinition]
public let cover: URL?
public let subtitleDataSouce: SubtitleDataSouce?
public var nowPlayingInfo: KSNowPlayableMetadata?
public let extinf: [String: String]?
/**
Player recource item with url, used to play single difinition video
- parameter name: video name
- parameter url: video url
- parameter cover: video cover, will show before playing, and hide when play
- parameter subtitleURLs: video subtitles
*/
public convenience init(url: URL, options: KSOptions = KSOptions(), name: String = "", cover: URL? = nil, subtitleURLs: [URL]? = nil, extinf: [String: String]? = nil) {
let definition = KSPlayerResourceDefinition(url: url, definition: "", options: options)
let subtitleDataSouce: URLSubtitleDataSouce?
if let subtitleURLs {
subtitleDataSouce = URLSubtitleDataSouce(urls: subtitleURLs)
} else {
subtitleDataSouce = nil
}
self.init(name: name, definitions: [definition], cover: cover, subtitleDataSouce: subtitleDataSouce, extinf: extinf)
}
/**
Play resouce with multi definitions
- parameter name: video name
- parameter definitions: video definitions
- parameter cover: video cover
- parameter subtitle: video subtitle
*/
public init(name: String, definitions: [KSPlayerResourceDefinition], cover: URL? = nil, subtitleDataSouce: SubtitleDataSouce? = nil, extinf: [String: String]? = nil) {
self.name = name
self.cover = cover
self.subtitleDataSouce = subtitleDataSouce
self.definitions = definitions
self.extinf = extinf
nowPlayingInfo = KSNowPlayableMetadata(title: name)
}
public func hash(into hasher: inout Hasher) {
hasher.combine(definitions)
}
}
extension KSPlayerResource: Identifiable {
public var id: KSPlayerResource { self }
}
public struct KSPlayerResourceDefinition: Hashable {
public static func == (lhs: KSPlayerResourceDefinition, rhs: KSPlayerResourceDefinition) -> Bool {
lhs.url == rhs.url
}
public let url: URL
public let definition: String
public let options: KSOptions
public init(url: URL) {
self.init(url: url, definition: url.lastPathComponent)
}
/**
Video recource item with defination name and specifying options
- parameter url: video url
- parameter definition: url deifination
- parameter options: specifying options for the initialization of the AVURLAsset
*/
public init(url: URL, definition: String, options: KSOptions = KSOptions()) {
self.url = url
self.definition = definition
self.options = options
}
public func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
}
extension KSPlayerResourceDefinition: Identifiable {
public var id: Self { self }
}
public struct KSNowPlayableMetadata {
private let mediaType: MPNowPlayingInfoMediaType?
private let isLiveStream: Bool?
private let title: String
private let artist: String?
private let artwork: MPMediaItemArtwork?
private let albumArtist: String?
private let albumTitle: String?
var nowPlayingInfo: [String: Any] {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = mediaType?.rawValue
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = isLiveStream
nowPlayingInfo[MPMediaItemPropertyTitle] = title
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
if #available(OSX 10.13.2, *) {
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
}
nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = albumArtist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
return nowPlayingInfo
}
public init(mediaType: MPNowPlayingInfoMediaType? = nil, isLiveStream: Bool? = nil, title: String, artist: String? = nil,
artwork: MPMediaItemArtwork? = nil, albumArtist: String? = nil, albumTitle: String? = nil)
{
self.mediaType = mediaType
self.isLiveStream = isLiveStream
self.title = title
self.artist = artist
self.artwork = artwork
self.albumArtist = albumArtist
self.albumTitle = albumTitle
}
public init(mediaType: MPNowPlayingInfoMediaType? = nil, isLiveStream: Bool? = nil, title: String, artist: String? = nil, image: UIImage, albumArtist: String? = nil, albumTitle: String? = nil) {
self.mediaType = mediaType
self.isLiveStream = isLiveStream
self.title = title
self.artist = artist
self.albumArtist = albumArtist
self.albumTitle = albumTitle
artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
}
}

View File

@@ -0,0 +1,209 @@
//
// MacVideoPlayerView.swift
// Pods
//
// Created by kintan on 2018/10/31.
//
#if !canImport(UIKit)
import AppKit
import AVFoundation
public extension NSPasteboard.PasteboardType {
static let nsURL = NSPasteboard.PasteboardType("NSURL")
static let nsFilenames = NSPasteboard.PasteboardType("NSFilenamesPboardType")
}
public extension NSDraggingInfo {
@MainActor
func getUrl() -> URL? {
guard let types = draggingPasteboard.types else { return nil }
if types.contains(.nsFilenames) {
guard let paths = draggingPasteboard.propertyList(forType: .nsFilenames) as? [String] else { return nil }
let urls = paths.map { URL(fileURLWithPath: $0) }
return urls.first
}
return nil
}
}
open class MacVideoPlayerView: VideoPlayerView {
override open func customizeUIComponents() {
super.customizeUIComponents()
registerForDraggedTypes([.nsFilenames, .nsURL, .string])
}
override open func player(layer: KSPlayerLayer, state: KSPlayerState) {
super.player(layer: layer, state: state)
if state == .readyToPlay {
let naturalSize = layer.player.naturalSize
window?.aspectRatio = naturalSize
}
}
}
extension MacVideoPlayerView {
override open func updateTrackingAreas() {
for trackingArea in trackingAreas {
removeTrackingArea(trackingArea)
}
let trackingArea = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow], owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
override open func mouseEntered(with _: NSEvent) {
isMaskShow = true
}
override open func mouseMoved(with _: NSEvent) {
isMaskShow = true
}
override open func mouseExited(with _: NSEvent) {
isMaskShow = false
}
override open func scrollWheel(with event: NSEvent) {
if event.phase.contains(.began) {
if event.scrollingDeltaX != 0 {
scrollDirection = .horizontal
tmpPanValue = toolBar.timeSlider.value
} else if event.scrollingDeltaY != 0 {
scrollDirection = .vertical
tmpPanValue = 1
}
} else if event.phase.contains(.changed) {
let delta = scrollDirection == .horizontal ? event.scrollingDeltaX : event.scrollingDeltaY
if scrollDirection == .horizontal {
tmpPanValue += Float(delta / 10000) * Float(totalTime)
showSeekToView(second: Double(tmpPanValue), isAdd: delta > 0)
} else {
if KSOptions.enableVolumeGestures {
tmpPanValue -= Float(delta / 1000)
tmpPanValue = max(min(tmpPanValue, 1), 0)
}
}
} else if event.phase.contains(.ended) {
if scrollDirection == .horizontal {
slider(value: Double(tmpPanValue), event: .touchUpInside)
hideSeekToView()
} else {
if KSOptions.enableVolumeGestures {
playerLayer?.player.playbackVolume = tmpPanValue
}
}
}
}
override open var acceptsFirstResponder: Bool {
true
}
override open func keyDown(with event: NSEvent) {
if let specialKey = event.specialKey {
if specialKey == .rightArrow {
slider(value: Double(toolBar.timeSlider.value) + 0.01 * totalTime, event: .touchUpInside)
} else if specialKey == .leftArrow {
slider(value: Double(toolBar.timeSlider.value) - 0.01 * totalTime, event: .touchUpInside)
}
} else if let character = event.characters?.first {
if character == " " {
onButtonPressed(toolBar.playButton)
}
}
}
override open func draggingEntered(_: NSDraggingInfo) -> NSDragOperation {
.copy
}
override open func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
if let url = sender.getUrl() {
if url.isMovie || url.isAudio {
set(resource: KSPlayerResource(url: url, options: KSOptions()))
return true
} else {
srtControl.selectedSubtitleInfo = URLSubtitleInfo(url: url)
return true
}
}
return false
}
}
class UIActivityIndicatorView: UIView {
private let loadingView = NSView()
private let progressLabel = UILabel()
public var progress: Double = 0 {
didSet {
print("new progress: \(progress)")
progressLabel.stringValue = "\(Int(progress * 100))%"
}
}
override init(frame frameRect: CGRect) {
super.init(frame: frameRect)
wantsLayer = true
backingLayer?.backgroundColor = UIColor(white: 0, alpha: 0.2).cgColor
setupLoadingView()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupLoadingView() {
loadingView.wantsLayer = true
addSubview(loadingView)
let imageView = NSImageView()
imageView.image = KSOptions.image(named: "loading")
loadingView.addSubview(imageView)
imageView.imageScaling = .scaleAxesIndependently
imageView.translatesAutoresizingMaskIntoConstraints = false
loadingView.translatesAutoresizingMaskIntoConstraints = false
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
widthAnchor.constraint(equalToConstant: 110),
heightAnchor.constraint(equalToConstant: 110),
loadingView.centerXAnchor.constraint(equalTo: centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: centerYAnchor),
loadingView.widthAnchor.constraint(equalTo: widthAnchor),
loadingView.heightAnchor.constraint(equalTo: heightAnchor),
imageView.bottomAnchor.constraint(equalTo: loadingView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: loadingView.leadingAnchor),
imageView.heightAnchor.constraint(equalTo: widthAnchor),
imageView.widthAnchor.constraint(equalTo: heightAnchor),
])
progressLabel.alignment = .center
progressLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
addSubview(progressLabel)
progressLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
progressLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
progressLabel.topAnchor.constraint(equalTo: loadingView.bottomAnchor, constant: 20),
progressLabel.widthAnchor.constraint(equalToConstant: 100),
progressLabel.heightAnchor.constraint(equalToConstant: 22),
])
startAnimating()
}
}
extension UIActivityIndicatorView: LoadingIndector {
func startAnimating() {
loadingView.backingLayer?.position = CGPoint(x: loadingView.layer!.frame.midX, y: loadingView.layer!.frame.midY)
loadingView.backingLayer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.duration = 1.0
rotationAnimation.repeatCount = MAXFLOAT
rotationAnimation.fromValue = 0.0
rotationAnimation.toValue = Float.pi * -2
loadingView.backingLayer?.add(rotationAnimation, forKey: "loading")
}
func stopAnimating() {
loadingView.backingLayer?.removeAnimation(forKey: "loading")
}
}
#endif

View File

@@ -0,0 +1,69 @@
//
// PlayerFullScreenViewController.swift
// KSPlayer
//
// Created by kintan on 2021/8/20.
//
#if canImport(UIKit) && !os(tvOS)
import UIKit
protocol PlayerViewFullScreenDelegate: AnyObject {
func player(isMaskShow: Bool, isFullScreen: Bool)
}
class PlayerFullScreenViewController: UIViewController {
private let isHorizonal: Bool
private var statusHiden = false
init(isHorizonal: Bool) {
self.isHorizonal = isHorizonal
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
KSOptions.supportedInterfaceOrientations = isHorizonal ? .landscapeRight : .portrait
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.isHidden = true
UIApplication.shared.isIdleTimerDisabled = true
}
override var shouldAutorotate: Bool {
KSOptions.supportedInterfaceOrientations == .all
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
override var prefersHomeIndicatorAutoHidden: Bool {
true
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override var prefersStatusBarHidden: Bool {
statusHiden
}
}
extension PlayerFullScreenViewController: PlayerViewFullScreenDelegate {
func player(isMaskShow: Bool, isFullScreen: Bool) {
if isFullScreen {
statusHiden = !isMaskShow
setNeedsFocusUpdate()
}
}
}
#endif

View File

@@ -0,0 +1,71 @@
//
// PlayerTransitionAnimator.swift
// KSPlayer
//
// Created by kintan on 2021/8/20.
//
#if canImport(UIKit)
import UIKit
class PlayerTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let isDismiss: Bool
private let containerView: UIView
private let animationView: UIView
private let fromCenter: CGPoint
init(containerView: UIView, animationView: UIView, isDismiss: Bool = false) {
self.containerView = containerView
self.animationView = animationView
self.isDismiss = isDismiss
fromCenter = containerView.superview?.convert(containerView.center, to: nil) ?? .zero
super.init()
}
func transitionDuration(using _: UIViewControllerContextTransitioning?) -> TimeInterval {
0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let animationSuperView = animationView.superview
let animationViewIndex = animationSuperView?.subviews.firstIndex(of: animationView) ?? 0
let initSize = animationView.frame.size
let animationFrameConstraints = animationView.frameConstraints
guard let presentedView = transitionContext.view(forKey: isDismiss ? .from : .to) else {
return
}
if isDismiss {
containerView.layoutIfNeeded()
presentedView.bounds = containerView.bounds
presentedView.removeFromSuperview()
} else {
if let viewController = transitionContext.viewController(forKey: .to) {
presentedView.frame = transitionContext.finalFrame(for: viewController)
}
}
presentedView.layoutIfNeeded()
transitionContext.containerView.addSubview(animationView)
animationView.translatesAutoresizingMaskIntoConstraints = true
guard let transform = transitionContext.viewController(forKey: .from)?.view.transform else {
return
}
animationView.transform = CGAffineTransform(scaleX: initSize.width / animationView.frame.size.width, y: initSize.height / animationView.frame.size.height).concatenating(transform)
let toCenter = transitionContext.containerView.center
let fromCenter = transform == .identity ? fromCenter : fromCenter.reverse
animationView.center = isDismiss ? toCenter : fromCenter
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveEaseInOut) {
self.animationView.transform = .identity
self.animationView.center = self.isDismiss ? fromCenter : toCenter
} completion: { _ in
animationSuperView?.insertSubview(self.animationView, at: animationViewIndex)
if !animationFrameConstraints.isEmpty {
self.animationView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(animationFrameConstraints)
}
if !self.isDismiss {
transitionContext.containerView.addSubview(presentedView)
}
transitionContext.completeTransition(true)
}
}
}
#endif

View File

@@ -0,0 +1,75 @@
//
// SeekView.swift
// KSPlayer-iOS
//
// Created by kintan on 2018/11/14.
//
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol SeekViewProtocol {
func set(text: String, isAdd: Bool)
}
class SeekView: UIView {
private let seekToViewImage = UIImageView()
private let seekToLabel = UILabel()
override public init(frame: CGRect) {
super.init(frame: frame)
addSubview(seekToViewImage)
addSubview(seekToLabel)
seekToLabel.font = .systemFont(ofSize: 13)
seekToLabel.textColor = UIColor(red: 0.9098, green: 0.9098, blue: 0.9098, alpha: 1.0)
backgroundColor = UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.7)
cornerRadius = 4
clipsToBounds = true
isHidden = true
if #available(macOS 11.0, *) {
seekToViewImage.image = UIImage(systemName: "forward.fill")
}
translatesAutoresizingMaskIntoConstraints = false
seekToViewImage.translatesAutoresizingMaskIntoConstraints = false
seekToLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
seekToViewImage.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15),
seekToViewImage.centerYAnchor.constraint(equalTo: centerYAnchor),
seekToViewImage.widthAnchor.constraint(equalToConstant: 25),
seekToViewImage.heightAnchor.constraint(equalToConstant: 15),
seekToLabel.leadingAnchor.constraint(equalTo: seekToViewImage.trailingAnchor, constant: 10),
seekToLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#if canImport(AppKit)
var backgroundColor: UIColor? {
get {
if let layer, let cgColor = layer.backgroundColor {
return UIColor(cgColor: cgColor)
} else {
return nil
}
}
set {
backingLayer?.backgroundColor = newValue?.cgColor
}
}
#endif
}
extension SeekView: SeekViewProtocol {
public func set(text: String, isAdd: Bool) {
seekToLabel.text = text
if !isAdd {
seekToViewImage.backingLayer?.position = CGPoint(x: seekToViewImage.backingLayer!.frame.midX, y: seekToViewImage.backingLayer!.frame.midY)
seekToViewImage.backingLayer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
}
seekToViewImage.centerRotate(byDegrees: isAdd ? 0.0 : 180)
}
}

File diff suppressed because it is too large Load Diff