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