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>
475 lines
19 KiB
Swift
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
|