Files
simvision/KSPlayer-main/Sources/KSPlayer/Video/IOSVideoPlayerView.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

475 lines
19 KiB
Swift

//
// 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