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:
474
KSPlayer-main/Sources/KSPlayer/Video/IOSVideoPlayerView.swift
Normal file
474
KSPlayer-main/Sources/KSPlayer/Video/IOSVideoPlayerView.swift
Normal 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
|
||||
Reference in New Issue
Block a user