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>
1055 lines
44 KiB
Swift
1055 lines
44 KiB
Swift
//
|
||
// VideoPlayerView.swift
|
||
// Pods
|
||
//
|
||
// Created by kintan on 16/4/29.
|
||
//
|
||
//
|
||
import AVKit
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#else
|
||
import AppKit
|
||
#endif
|
||
import Combine
|
||
import MediaPlayer
|
||
|
||
/// internal enum to check the pan direction
|
||
public enum KSPanDirection {
|
||
case horizontal
|
||
case vertical
|
||
}
|
||
|
||
public protocol LoadingIndector {
|
||
func startAnimating()
|
||
func stopAnimating()
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
extension UIActivityIndicatorView: LoadingIndector {}
|
||
#endif
|
||
// swiftlint:disable type_body_length file_length
|
||
open class VideoPlayerView: PlayerView {
|
||
private var delayItem: DispatchWorkItem?
|
||
/// Gesture used to show / hide control view
|
||
public let tapGesture = UITapGestureRecognizer()
|
||
public let doubleTapGesture = UITapGestureRecognizer()
|
||
public let panGesture = UIPanGestureRecognizer()
|
||
/// 滑动方向
|
||
var scrollDirection = KSPanDirection.horizontal
|
||
var tmpPanValue: Float = 0
|
||
private var isSliderSliding = false
|
||
|
||
public let bottomMaskView = LayerContainerView()
|
||
public let topMaskView = LayerContainerView()
|
||
// 是否播放过
|
||
private(set) var isPlayed = false
|
||
private var cancellable: AnyCancellable?
|
||
|
||
public private(set) var currentDefinition = 0 {
|
||
didSet {
|
||
if let resource {
|
||
toolBar.definitionButton.setTitle(resource.definitions[currentDefinition].definition, for: .normal)
|
||
}
|
||
}
|
||
}
|
||
|
||
public private(set) var resource: KSPlayerResource? {
|
||
didSet {
|
||
if let resource, oldValue != resource {
|
||
if let subtitleDataSouce = resource.subtitleDataSouce {
|
||
srtControl.addSubtitle(dataSouce: subtitleDataSouce)
|
||
}
|
||
subtitleBackView.isHidden = true
|
||
subtitleBackView.image = nil
|
||
subtitleLabel.attributedText = nil
|
||
titleLabel.text = resource.name
|
||
toolBar.definitionButton.isHidden = resource.definitions.count < 2
|
||
autoFadeOutViewWithAnimation()
|
||
isMaskShow = true
|
||
MPNowPlayingInfoCenter.default().nowPlayingInfo = resource.nowPlayingInfo?.nowPlayingInfo
|
||
}
|
||
}
|
||
}
|
||
|
||
public let contentOverlayView = UIView()
|
||
public let controllerView = UIView()
|
||
public var navigationBar = UIStackView()
|
||
public var titleLabel = UILabel()
|
||
public var subtitleLabel = UILabel()
|
||
public var subtitleBackView = UIImageView()
|
||
/// Activty Indector for loading
|
||
public var loadingIndector: UIView & LoadingIndector = UIActivityIndicatorView(frame: CGRect(x: 0, y: 0, width: 30, height: 30))
|
||
public var seekToView: UIView & SeekViewProtocol = SeekView()
|
||
public var replayButton = UIButton()
|
||
public var lockButton = UIButton()
|
||
public var isLock: Bool { lockButton.isSelected }
|
||
open var isMaskShow = true {
|
||
didSet {
|
||
let alpha: CGFloat = isMaskShow && !isLock ? 1.0 : 0.0
|
||
UIView.animate(withDuration: 0.3) {
|
||
if self.isPlayed {
|
||
self.replayButton.alpha = self.isMaskShow ? 1.0 : 0.0
|
||
}
|
||
self.lockButton.alpha = self.isMaskShow ? 1.0 : 0.0
|
||
self.topMaskView.alpha = alpha
|
||
self.bottomMaskView.alpha = alpha
|
||
self.delegate?.playerController(maskShow: self.isMaskShow)
|
||
self.layoutIfNeeded()
|
||
}
|
||
if isMaskShow {
|
||
autoFadeOutViewWithAnimation()
|
||
}
|
||
}
|
||
}
|
||
|
||
private var originalPlaybackRate: Float = 1.0
|
||
|
||
public let speedTipLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.textAlignment = .center
|
||
label.textColor = .green
|
||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||
label.alpha = 0
|
||
label.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||
label.backingLayer?.cornerRadius = 4
|
||
label.clipsToBounds = true
|
||
label.isHidden = true
|
||
return label
|
||
}()
|
||
|
||
override public var playerLayer: KSPlayerLayer? {
|
||
didSet {
|
||
oldValue?.player.view?.removeFromSuperview()
|
||
if let view = playerLayer?.player.view {
|
||
#if canImport(UIKit)
|
||
insertSubview(view, belowSubview: contentOverlayView)
|
||
#else
|
||
addSubview(view, positioned: .below, relativeTo: contentOverlayView)
|
||
#endif
|
||
view.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
view.topAnchor.constraint(equalTo: topAnchor),
|
||
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
])
|
||
}
|
||
}
|
||
}
|
||
|
||
override public init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUIComponents()
|
||
cancellable = playerLayer?.$isPipActive.assign(to: \.isSelected, on: toolBar.pipButton)
|
||
toolBar.onFocusUpdate = { [weak self] _ in
|
||
self?.autoFadeOutViewWithAnimation()
|
||
}
|
||
}
|
||
|
||
// MARK: - Action Response
|
||
|
||
override open func onButtonPressed(type: PlayerButtonType, button: UIButton) {
|
||
autoFadeOutViewWithAnimation()
|
||
super.onButtonPressed(type: type, button: button)
|
||
if type == .pictureInPicture {
|
||
if #available(tvOS 14.0, *) {
|
||
playerLayer?.isPipActive.toggle()
|
||
}
|
||
}
|
||
#if os(tvOS)
|
||
if type == .srt {
|
||
changeSrt(button: button)
|
||
} else if type == .rate {
|
||
changePlaybackRate(button: button)
|
||
} else if type == .definition {
|
||
changeDefinitions(button: button)
|
||
} else if type == .audioSwitch || type == .videoSwitch {
|
||
changeAudioVideo(type, button: button)
|
||
}
|
||
#elseif os(macOS)
|
||
// if let menu = button.menu, let event = NSApplication.shared.currentEvent {
|
||
// NSMenu.popUpContextMenu(menu, with: event, for: button)
|
||
// }
|
||
#endif
|
||
}
|
||
|
||
// MARK: - setup UI
|
||
|
||
open func setupUIComponents() {
|
||
addSubview(contentOverlayView)
|
||
addSubview(controllerView)
|
||
#if os(macOS)
|
||
topMaskView.gradientLayer.colors = [UIColor.clear.cgColor, UIColor.black.withAlphaComponent(0.5).cgColor]
|
||
#else
|
||
topMaskView.gradientLayer.colors = [UIColor.black.withAlphaComponent(0.5).cgColor, UIColor.clear.cgColor]
|
||
#endif
|
||
bottomMaskView.gradientLayer.colors = topMaskView.gradientLayer.colors
|
||
topMaskView.isHidden = KSOptions.topBarShowInCase != .always
|
||
topMaskView.gradientLayer.startPoint = .zero
|
||
topMaskView.gradientLayer.endPoint = CGPoint(x: 0, y: 1)
|
||
bottomMaskView.gradientLayer.startPoint = CGPoint(x: 0, y: 1)
|
||
bottomMaskView.gradientLayer.endPoint = .zero
|
||
|
||
loadingIndector.isHidden = true
|
||
controllerView.addSubview(loadingIndector)
|
||
// Top views
|
||
topMaskView.addSubview(navigationBar)
|
||
navigationBar.addArrangedSubview(titleLabel)
|
||
titleLabel.textColor = .white
|
||
titleLabel.font = .systemFont(ofSize: 16)
|
||
// Bottom views
|
||
bottomMaskView.addSubview(toolBar)
|
||
toolBar.timeSlider.delegate = self
|
||
controllerView.addSubview(seekToView)
|
||
controllerView.addSubview(replayButton)
|
||
replayButton.cornerRadius = 32
|
||
replayButton.titleFont = .systemFont(ofSize: 16)
|
||
replayButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||
replayButton.addTarget(self, action: #selector(onButtonPressed(_:)), for: .primaryActionTriggered)
|
||
replayButton.tag = PlayerButtonType.replay.rawValue
|
||
lockButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||
lockButton.cornerRadius = 32
|
||
lockButton.tag = PlayerButtonType.lock.rawValue
|
||
lockButton.addTarget(self, action: #selector(onButtonPressed(_:)), for: .primaryActionTriggered)
|
||
lockButton.isHidden = true
|
||
if #available(macOS 11.0, *) {
|
||
replayButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
|
||
replayButton.setImage(UIImage(systemName: "arrow.counterclockwise"), for: .selected)
|
||
lockButton.setImage(UIImage(systemName: "lock.open"), for: .normal)
|
||
lockButton.setImage(UIImage(systemName: "lock"), for: .selected)
|
||
}
|
||
lockButton.tintColor = .white
|
||
replayButton.tintColor = .white
|
||
controllerView.addSubview(lockButton)
|
||
controllerView.addSubview(topMaskView)
|
||
controllerView.addSubview(bottomMaskView)
|
||
controllerView.addSubview(speedTipLabel)
|
||
speedTipLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
speedTipLabel.topAnchor.constraint(equalTo: safeTopAnchor, constant: 50),
|
||
speedTipLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
speedTipLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 80),
|
||
speedTipLabel.heightAnchor.constraint(equalToConstant: 30),
|
||
])
|
||
addConstraint()
|
||
customizeUIComponents()
|
||
setupSrtControl()
|
||
layoutIfNeeded()
|
||
}
|
||
|
||
/// Add Customize functions here
|
||
open func customizeUIComponents() {
|
||
tapGesture.addTarget(self, action: #selector(tapGestureAction(_:)))
|
||
tapGesture.numberOfTapsRequired = 1
|
||
controllerView.addGestureRecognizer(tapGesture)
|
||
panGesture.addTarget(self, action: #selector(panGestureAction(_:)))
|
||
controllerView.addGestureRecognizer(panGesture)
|
||
panGesture.isEnabled = false
|
||
doubleTapGesture.addTarget(self, action: #selector(doubleTapGestureAction))
|
||
doubleTapGesture.numberOfTapsRequired = 2
|
||
tapGesture.require(toFail: doubleTapGesture)
|
||
controllerView.addGestureRecognizer(doubleTapGesture)
|
||
#if canImport(UIKit)
|
||
longPressGesture.addTarget(self, action: #selector(longPressGestureAction(_:)))
|
||
longPressGesture.minimumPressDuration = 0.5
|
||
controllerView.addGestureRecognizer(longPressGesture)
|
||
addRemoteControllerGestures()
|
||
#endif
|
||
}
|
||
|
||
override open func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
|
||
guard !isSliderSliding else { return }
|
||
super.player(layer: layer, currentTime: currentTime, totalTime: totalTime)
|
||
if srtControl.subtitle(currentTime: currentTime) {
|
||
if let part = srtControl.parts.first {
|
||
subtitleBackView.image = part.image
|
||
subtitleLabel.attributedText = part.text
|
||
subtitleBackView.isHidden = false
|
||
} else {
|
||
subtitleBackView.image = nil
|
||
subtitleLabel.attributedText = nil
|
||
subtitleBackView.isHidden = true
|
||
}
|
||
}
|
||
}
|
||
|
||
override open func player(layer: KSPlayerLayer, state: KSPlayerState) {
|
||
super.player(layer: layer, state: state)
|
||
switch state {
|
||
case .readyToPlay:
|
||
toolBar.timeSlider.isPlayable = true
|
||
toolBar.videoSwitchButton.isHidden = layer.player.tracks(mediaType: .video).count < 2
|
||
toolBar.audioSwitchButton.isHidden = layer.player.tracks(mediaType: .audio).count < 2
|
||
if #available(iOS 14.0, tvOS 15.0, *) {
|
||
buildMenusForButtons()
|
||
}
|
||
if let subtitleDataSouce = layer.player.subtitleDataSouce {
|
||
// 要延后增加内嵌字幕。因为有些内嵌字幕是放在视频流的。所以会比readyToPlay回调晚。
|
||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
|
||
guard let self else { return }
|
||
self.srtControl.addSubtitle(dataSouce: subtitleDataSouce)
|
||
if self.srtControl.selectedSubtitleInfo == nil, layer.options.autoSelectEmbedSubtitle {
|
||
self.srtControl.selectedSubtitleInfo = self.srtControl.subtitleInfos.first { $0.isEnabled }
|
||
}
|
||
self.toolBar.srtButton.isHidden = self.srtControl.subtitleInfos.isEmpty
|
||
if #available(iOS 14.0, tvOS 15.0, *) {
|
||
self.buildMenusForButtons()
|
||
}
|
||
}
|
||
}
|
||
case .buffering:
|
||
isPlayed = true
|
||
replayButton.isHidden = true
|
||
replayButton.isSelected = false
|
||
showLoader()
|
||
case .bufferFinished:
|
||
isPlayed = true
|
||
replayButton.isHidden = true
|
||
replayButton.isSelected = false
|
||
hideLoader()
|
||
autoFadeOutViewWithAnimation()
|
||
case .paused, .playedToTheEnd, .error:
|
||
hideLoader()
|
||
replayButton.isHidden = false
|
||
seekToView.isHidden = true
|
||
delayItem?.cancel()
|
||
isMaskShow = true
|
||
if state == .playedToTheEnd {
|
||
replayButton.isSelected = true
|
||
}
|
||
case .initialized, .preparing:
|
||
break
|
||
}
|
||
}
|
||
|
||
override open func resetPlayer() {
|
||
super.resetPlayer()
|
||
delayItem = nil
|
||
toolBar.reset()
|
||
isMaskShow = false
|
||
hideLoader()
|
||
replayButton.isSelected = false
|
||
replayButton.isHidden = false
|
||
seekToView.isHidden = true
|
||
isPlayed = false
|
||
lockButton.isSelected = false
|
||
}
|
||
|
||
// MARK: - KSSliderDelegate
|
||
|
||
override open func slider(value: Double, event: ControlEvents) {
|
||
if event == .valueChanged {
|
||
delayItem?.cancel()
|
||
} else if event == .touchUpInside {
|
||
autoFadeOutViewWithAnimation()
|
||
}
|
||
super.slider(value: value, event: event)
|
||
if event == .touchDown {
|
||
isSliderSliding = true
|
||
} else if event == .touchUpInside {
|
||
isSliderSliding = false
|
||
}
|
||
}
|
||
|
||
open func change(definitionIndex: Int) {
|
||
guard let resource else { return }
|
||
var shouldSeekTo = 0.0
|
||
if let playerLayer, playerLayer.state != .playedToTheEnd {
|
||
shouldSeekTo = playerLayer.player.currentPlaybackTime
|
||
}
|
||
currentDefinition = definitionIndex >= resource.definitions.count ? resource.definitions.count - 1 : definitionIndex
|
||
let asset = resource.definitions[currentDefinition]
|
||
super.set(url: asset.url, options: asset.options)
|
||
if shouldSeekTo > 0 {
|
||
seek(time: shouldSeekTo) { _ in }
|
||
}
|
||
}
|
||
|
||
open func set(resource: KSPlayerResource, definitionIndex: Int = 0, isSetUrl: Bool = true) {
|
||
currentDefinition = definitionIndex >= resource.definitions.count ? resource.definitions.count - 1 : definitionIndex
|
||
if isSetUrl {
|
||
let asset = resource.definitions[currentDefinition]
|
||
super.set(url: asset.url, options: asset.options)
|
||
}
|
||
self.resource = resource
|
||
}
|
||
|
||
override open func set(url: URL, options: KSOptions) {
|
||
set(resource: KSPlayerResource(url: url, options: options))
|
||
}
|
||
|
||
@objc open func doubleTapGestureAction() {
|
||
toolBar.playButton.sendActions(for: .primaryActionTriggered)
|
||
isMaskShow = true
|
||
}
|
||
|
||
@objc open func tapGestureAction(_: UITapGestureRecognizer) {
|
||
isMaskShow.toggle()
|
||
}
|
||
|
||
open func panGestureBegan(location _: CGPoint, direction: KSPanDirection) {
|
||
if direction == .horizontal {
|
||
// 给tmpPanValue初值
|
||
if totalTime > 0 {
|
||
tmpPanValue = toolBar.timeSlider.value
|
||
}
|
||
}
|
||
}
|
||
|
||
open func panGestureChanged(velocity point: CGPoint, direction: KSPanDirection) {
|
||
if direction == .horizontal {
|
||
if !KSOptions.enablePlaytimeGestures {
|
||
return
|
||
}
|
||
isSliderSliding = true
|
||
if totalTime > 0 {
|
||
// 每次滑动需要叠加时间,通过一定的比例,使滑动一直处于统一水平
|
||
tmpPanValue += panValue(velocity: point, direction: direction, currentTime: Float(toolBar.currentTime), totalTime: Float(totalTime))
|
||
tmpPanValue = max(min(tmpPanValue, Float(totalTime)), 0)
|
||
showSeekToView(second: Double(tmpPanValue), isAdd: point.x > 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
open func panValue(velocity point: CGPoint, direction: KSPanDirection, currentTime _: Float, totalTime: Float) -> Float {
|
||
if direction == .horizontal {
|
||
return max(min(Float(point.x) / 0x40000, 0.01), -0.01) * totalTime
|
||
} else {
|
||
return -Float(point.y) / 0x2800
|
||
}
|
||
}
|
||
|
||
open func panGestureEnded() {
|
||
// 移动结束也需要判断垂直或者平移
|
||
// 比如水平移动结束时,要快进到指定位置,如果这里没有判断,当我们调节音量完之后,会出现屏幕跳动的bug
|
||
if scrollDirection == .horizontal, KSOptions.enablePlaytimeGestures {
|
||
hideSeekToView()
|
||
isSliderSliding = false
|
||
slider(value: Double(tmpPanValue), event: .touchUpInside)
|
||
tmpPanValue = 0.0
|
||
}
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
public let longPressGesture = UILongPressGestureRecognizer()
|
||
@objc open func longPressGestureAction(_ gesture: UILongPressGestureRecognizer) {
|
||
guard let playerLayer else { return }
|
||
|
||
switch gesture.state {
|
||
case .began:
|
||
originalPlaybackRate = playerLayer.player.playbackRate
|
||
playerLayer.player.playbackRate = 2.0
|
||
showSpeedTip("2x")
|
||
|
||
case .ended, .cancelled:
|
||
playerLayer.player.playbackRate = originalPlaybackRate
|
||
showSpeedTip("1x")
|
||
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
#endif
|
||
|
||
private func showSpeedTip(_ text: String) {
|
||
speedTipLabel.text = text
|
||
speedTipLabel.isHidden = false
|
||
|
||
// 显示动画
|
||
UIView.animate(withDuration: 0.2) {
|
||
self.speedTipLabel.alpha = 1
|
||
}
|
||
|
||
// 延迟后隐藏
|
||
delayItem?.cancel()
|
||
delayItem = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
UIView.animate(withDuration: 0.2) {
|
||
self.speedTipLabel.alpha = 0
|
||
} completion: { _ in
|
||
self.speedTipLabel.isHidden = true
|
||
}
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: delayItem!)
|
||
}
|
||
}
|
||
|
||
// MARK: - Action Response
|
||
|
||
extension VideoPlayerView {
|
||
@available(iOS 14.0, tvOS 15.0, *)
|
||
func buildMenusForButtons() {
|
||
#if !os(tvOS)
|
||
toolBar.definitionButton.setMenu(title: NSLocalizedString("video quality", comment: ""), current: resource?.definitions[currentDefinition], list: resource?.definitions ?? []) { value in
|
||
value.definition
|
||
} completition: { [weak self] value in
|
||
guard let self else { return }
|
||
if let value, let index = self.resource?.definitions.firstIndex(of: value) {
|
||
self.change(definitionIndex: index)
|
||
}
|
||
}
|
||
let videoTracks = playerLayer?.player.tracks(mediaType: .video) ?? []
|
||
toolBar.videoSwitchButton.setMenu(title: NSLocalizedString("switch video", comment: ""), current: videoTracks.first(where: { $0.isEnabled }), list: videoTracks) { value in
|
||
value.name + " \(value.naturalSize.width)x\(value.naturalSize.height)"
|
||
} completition: { [weak self] value in
|
||
guard let self else { return }
|
||
if let value {
|
||
self.playerLayer?.player.select(track: value)
|
||
}
|
||
}
|
||
let audioTracks = playerLayer?.player.tracks(mediaType: .audio) ?? []
|
||
toolBar.audioSwitchButton.setMenu(title: NSLocalizedString("switch audio", comment: ""), current: audioTracks.first(where: { $0.isEnabled }), list: audioTracks) { value in
|
||
value.description
|
||
} completition: { [weak self] value in
|
||
guard let self else { return }
|
||
if let value {
|
||
self.playerLayer?.player.select(track: value)
|
||
}
|
||
}
|
||
toolBar.playbackRateButton.setMenu(title: NSLocalizedString("speed", comment: ""), current: playerLayer?.player.playbackRate ?? 1, list: [0.75, 1.0, 1.25, 1.5, 2.0]) { value in
|
||
"\(value) x"
|
||
} completition: { [weak self] value in
|
||
guard let self else { return }
|
||
if let value {
|
||
self.playerLayer?.player.playbackRate = value
|
||
}
|
||
}
|
||
toolBar.srtButton.setMenu(title: NSLocalizedString("subtitle", comment: ""), current: srtControl.selectedSubtitleInfo, list: srtControl.subtitleInfos, addDisabled: true) { value in
|
||
value.name
|
||
} completition: { [weak self] value in
|
||
guard let self else { return }
|
||
self.srtControl.selectedSubtitleInfo = value
|
||
}
|
||
#if os(iOS)
|
||
toolBar.definitionButton.showsMenuAsPrimaryAction = true
|
||
toolBar.videoSwitchButton.showsMenuAsPrimaryAction = true
|
||
toolBar.audioSwitchButton.showsMenuAsPrimaryAction = true
|
||
toolBar.playbackRateButton.showsMenuAsPrimaryAction = true
|
||
toolBar.srtButton.showsMenuAsPrimaryAction = true
|
||
#endif
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: - playback rate, definitions, audio and video tracks change
|
||
|
||
public extension VideoPlayerView {
|
||
private func changeAudioVideo(_ type: PlayerButtonType, button _: UIButton) {
|
||
guard let tracks = playerLayer?.player.tracks(mediaType: type == .audioSwitch ? .audio : .video) else {
|
||
return
|
||
}
|
||
let alertController = UIAlertController(title: NSLocalizedString(type == .audioSwitch ? "switch audio" : "switch video", comment: ""), message: nil, preferredStyle: preferredStyle())
|
||
for track in tracks {
|
||
let isEnabled = track.isEnabled
|
||
var title = track.name
|
||
if type == .videoSwitch {
|
||
title += " \(track.naturalSize.width)x\(track.naturalSize.height)"
|
||
}
|
||
let action = UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||
guard let self, !isEnabled else { return }
|
||
self.playerLayer?.player.select(track: track)
|
||
}
|
||
alertController.addAction(action)
|
||
if isEnabled {
|
||
alertController.preferredAction = action
|
||
action.setValue(isEnabled, forKey: "checked")
|
||
}
|
||
}
|
||
alertController.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel, handler: nil))
|
||
viewController?.present(alertController, animated: true, completion: nil)
|
||
}
|
||
|
||
private func changeDefinitions(button _: UIButton) {
|
||
guard let resource, resource.definitions.count > 1 else { return }
|
||
let alertController = UIAlertController(title: NSLocalizedString("select video quality", comment: ""), message: nil, preferredStyle: preferredStyle())
|
||
for (index, definition) in resource.definitions.enumerated() {
|
||
let action = UIAlertAction(title: definition.definition, style: .default) { [weak self] _ in
|
||
guard let self, index != self.currentDefinition else { return }
|
||
self.change(definitionIndex: index)
|
||
}
|
||
alertController.addAction(action)
|
||
if index == currentDefinition {
|
||
alertController.preferredAction = action
|
||
action.setValue(true, forKey: "checked")
|
||
}
|
||
}
|
||
alertController.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel, handler: nil))
|
||
viewController?.present(alertController, animated: true, completion: nil)
|
||
}
|
||
|
||
private func changeSrt(button _: UIButton) {
|
||
let availableSubtitles = srtControl.subtitleInfos
|
||
guard !availableSubtitles.isEmpty else { return }
|
||
|
||
let alertController = UIAlertController(title: NSLocalizedString("subtitle", comment: ""),
|
||
message: nil,
|
||
preferredStyle: preferredStyle())
|
||
|
||
let currentSub = srtControl.selectedSubtitleInfo
|
||
|
||
let disableAction = UIAlertAction(title: NSLocalizedString("Disabled", comment: ""), style: .default) { [weak self] _ in
|
||
self?.srtControl.selectedSubtitleInfo = nil
|
||
}
|
||
alertController.addAction(disableAction)
|
||
if currentSub == nil {
|
||
alertController.preferredAction = disableAction
|
||
disableAction.setValue(true, forKey: "checked")
|
||
}
|
||
|
||
for (_, srt) in availableSubtitles.enumerated() {
|
||
let action = UIAlertAction(title: srt.name, style: .default) { [weak self] _ in
|
||
self?.srtControl.selectedSubtitleInfo = srt
|
||
}
|
||
alertController.addAction(action)
|
||
if currentSub?.subtitleID == srt.subtitleID {
|
||
alertController.preferredAction = action
|
||
action.setValue(true, forKey: "checked")
|
||
}
|
||
}
|
||
|
||
alertController.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel, handler: nil))
|
||
viewController?.present(alertController, animated: true, completion: nil)
|
||
}
|
||
|
||
private func changePlaybackRate(button: UIButton) {
|
||
let alertController = UIAlertController(title: NSLocalizedString("select speed", comment: ""), message: nil, preferredStyle: preferredStyle())
|
||
for rate in [0.75, 1.0, 1.25, 1.5, 2.0] {
|
||
let title = "\(rate) x"
|
||
let action = UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||
guard let self else { return }
|
||
button.setTitle(title, for: .normal)
|
||
self.playerLayer?.player.playbackRate = Float(rate)
|
||
}
|
||
alertController.addAction(action)
|
||
|
||
if Float(rate) == playerLayer?.player.playbackRate {
|
||
alertController.preferredAction = action
|
||
action.setValue(true, forKey: "checked")
|
||
}
|
||
}
|
||
alertController.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel, handler: nil))
|
||
viewController?.present(alertController, animated: true, completion: nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - seekToView
|
||
|
||
public extension VideoPlayerView {
|
||
/**
|
||
Call when User use the slide to seek function
|
||
|
||
- parameter second: target time
|
||
- parameter isAdd: isAdd
|
||
*/
|
||
func showSeekToView(second: TimeInterval, isAdd: Bool) {
|
||
isMaskShow = true
|
||
seekToView.isHidden = false
|
||
toolBar.currentTime = second
|
||
seekToView.set(text: second.toString(for: toolBar.timeType), isAdd: isAdd)
|
||
}
|
||
|
||
func hideSeekToView() {
|
||
seekToView.isHidden = true
|
||
}
|
||
}
|
||
|
||
// MARK: - private functions
|
||
|
||
extension VideoPlayerView {
|
||
@objc private func panGestureAction(_ pan: UIPanGestureRecognizer) {
|
||
// 播放结束时,忽略手势,锁屏状态忽略手势
|
||
guard !replayButton.isSelected, !isLock else { return }
|
||
// 根据上次和本次移动的位置,算出一个速率的point
|
||
let velocityPoint = pan.velocity(in: self)
|
||
switch pan.state {
|
||
case .began:
|
||
// 使用绝对值来判断移动的方向
|
||
if abs(velocityPoint.x) > abs(velocityPoint.y) {
|
||
scrollDirection = .horizontal
|
||
} else {
|
||
scrollDirection = .vertical
|
||
}
|
||
panGestureBegan(location: pan.location(in: self), direction: scrollDirection)
|
||
case .changed:
|
||
panGestureChanged(velocity: velocityPoint, direction: scrollDirection)
|
||
case .ended:
|
||
panGestureEnded()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// change during playback
|
||
public func updateSrt() {
|
||
subtitleLabel.font = SubtitleModel.textFont
|
||
if #available(macOS 11.0, iOS 14, tvOS 14, *) {
|
||
subtitleLabel.textColor = UIColor(SubtitleModel.textColor)
|
||
subtitleBackView.backgroundColor = UIColor(SubtitleModel.textBackgroundColor)
|
||
}
|
||
}
|
||
|
||
private func setupSrtControl() {
|
||
subtitleLabel.numberOfLines = 0
|
||
subtitleLabel.textAlignment = .center
|
||
subtitleLabel.backingLayer?.shadowColor = UIColor.black.cgColor
|
||
subtitleLabel.backingLayer?.shadowOffset = CGSize(width: 1.0, height: 1.0)
|
||
subtitleLabel.backingLayer?.shadowOpacity = 0.9
|
||
subtitleLabel.backingLayer?.shadowRadius = 1.0
|
||
subtitleLabel.backingLayer?.shouldRasterize = true
|
||
updateSrt()
|
||
subtitleBackView.contentMode = .scaleAspectFit
|
||
subtitleBackView.cornerRadius = 2
|
||
subtitleBackView.addSubview(subtitleLabel)
|
||
subtitleBackView.isHidden = true
|
||
addSubview(subtitleBackView)
|
||
subtitleBackView.translatesAutoresizingMaskIntoConstraints = false
|
||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
subtitleBackView.bottomAnchor.constraint(equalTo: safeBottomAnchor, constant: -5),
|
||
subtitleBackView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
subtitleBackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -10),
|
||
subtitleLabel.leadingAnchor.constraint(equalTo: subtitleBackView.leadingAnchor, constant: 10),
|
||
subtitleLabel.trailingAnchor.constraint(equalTo: subtitleBackView.trailingAnchor, constant: -10),
|
||
subtitleLabel.topAnchor.constraint(equalTo: subtitleBackView.topAnchor, constant: 2),
|
||
subtitleLabel.bottomAnchor.constraint(equalTo: subtitleBackView.bottomAnchor, constant: -2),
|
||
])
|
||
}
|
||
|
||
/**
|
||
auto fade out controll view with animtion
|
||
*/
|
||
private func autoFadeOutViewWithAnimation() {
|
||
delayItem?.cancel()
|
||
// 播放的时候才自动隐藏
|
||
guard toolBar.playButton.isSelected else { return }
|
||
delayItem = DispatchWorkItem { [weak self] in
|
||
self?.isMaskShow = false
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + KSOptions.animateDelayTimeInterval,
|
||
execute: delayItem!)
|
||
}
|
||
|
||
private func showLoader() {
|
||
loadingIndector.isHidden = false
|
||
loadingIndector.startAnimating()
|
||
}
|
||
|
||
private func hideLoader() {
|
||
loadingIndector.isHidden = true
|
||
loadingIndector.stopAnimating()
|
||
}
|
||
|
||
private func addConstraint() {
|
||
if #available(macOS 11.0, *) {
|
||
#if !targetEnvironment(macCatalyst)
|
||
toolBar.timeSlider.setThumbImage(UIImage(systemName: "circle.fill"), for: .normal)
|
||
#if os(macOS)
|
||
toolBar.timeSlider.setThumbImage(UIImage(systemName: "circle.fill"), for: .highlighted)
|
||
#else
|
||
toolBar.timeSlider.setThumbImage(UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .large)), for: .highlighted)
|
||
#endif
|
||
#endif
|
||
}
|
||
bottomMaskView.addSubview(toolBar.timeSlider)
|
||
toolBar.audioSwitchButton.isHidden = true
|
||
toolBar.videoSwitchButton.isHidden = true
|
||
toolBar.pipButton.isHidden = true
|
||
contentOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||
controllerView.translatesAutoresizingMaskIntoConstraints = false
|
||
toolBar.timeSlider.translatesAutoresizingMaskIntoConstraints = false
|
||
topMaskView.translatesAutoresizingMaskIntoConstraints = false
|
||
bottomMaskView.translatesAutoresizingMaskIntoConstraints = false
|
||
navigationBar.translatesAutoresizingMaskIntoConstraints = false
|
||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
loadingIndector.translatesAutoresizingMaskIntoConstraints = false
|
||
seekToView.translatesAutoresizingMaskIntoConstraints = false
|
||
replayButton.translatesAutoresizingMaskIntoConstraints = false
|
||
lockButton.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
contentOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
||
contentOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
contentOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
contentOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
controllerView.topAnchor.constraint(equalTo: topAnchor),
|
||
controllerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
controllerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
controllerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
topMaskView.topAnchor.constraint(equalTo: topAnchor),
|
||
topMaskView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
topMaskView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
topMaskView.heightAnchor.constraint(equalToConstant: 105),
|
||
navigationBar.topAnchor.constraint(equalTo: topMaskView.topAnchor),
|
||
navigationBar.leadingAnchor.constraint(equalTo: topMaskView.safeLeadingAnchor, constant: 15),
|
||
navigationBar.trailingAnchor.constraint(equalTo: topMaskView.safeTrailingAnchor, constant: -15),
|
||
navigationBar.heightAnchor.constraint(equalToConstant: 44),
|
||
bottomMaskView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
bottomMaskView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
bottomMaskView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
bottomMaskView.heightAnchor.constraint(equalToConstant: 105),
|
||
loadingIndector.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
loadingIndector.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
seekToView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
seekToView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
seekToView.widthAnchor.constraint(equalToConstant: 100),
|
||
seekToView.heightAnchor.constraint(equalToConstant: 40),
|
||
replayButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
replayButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
lockButton.leadingAnchor.constraint(equalTo: safeLeadingAnchor, constant: 22),
|
||
lockButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
])
|
||
|
||
configureToolBarConstraints()
|
||
}
|
||
|
||
private func configureToolBarConstraints() {
|
||
#if os(tvOS)
|
||
toolBar.spacing = 10
|
||
toolBar.addArrangedSubview(toolBar.playButton)
|
||
toolBar.addArrangedSubview(toolBar.timeLabel)
|
||
toolBar.addArrangedSubview(toolBar.playbackRateButton)
|
||
toolBar.addArrangedSubview(toolBar.definitionButton)
|
||
toolBar.addArrangedSubview(toolBar.audioSwitchButton)
|
||
toolBar.addArrangedSubview(toolBar.videoSwitchButton)
|
||
toolBar.addArrangedSubview(toolBar.srtButton)
|
||
toolBar.addArrangedSubview(toolBar.pipButton)
|
||
|
||
toolBar.setCustomSpacing(20, after: toolBar.timeLabel)
|
||
toolBar.setCustomSpacing(20, after: toolBar.playbackRateButton)
|
||
toolBar.setCustomSpacing(20, after: toolBar.definitionButton)
|
||
toolBar.setCustomSpacing(20, after: toolBar.srtButton)
|
||
|
||
NSLayoutConstraint.activate([
|
||
toolBar.bottomAnchor.constraint(equalTo: bottomMaskView.safeBottomAnchor),
|
||
toolBar.leadingAnchor.constraint(equalTo: bottomMaskView.safeLeadingAnchor, constant: 10),
|
||
toolBar.trailingAnchor.constraint(equalTo: bottomMaskView.safeTrailingAnchor, constant: -15),
|
||
toolBar.timeSlider.bottomAnchor.constraint(equalTo: toolBar.topAnchor, constant: -8),
|
||
toolBar.timeSlider.leadingAnchor.constraint(equalTo: bottomMaskView.safeLeadingAnchor, constant: 15),
|
||
toolBar.timeSlider.trailingAnchor.constraint(equalTo: bottomMaskView.safeTrailingAnchor, constant: -15),
|
||
toolBar.timeSlider.heightAnchor.constraint(equalToConstant: 16),
|
||
])
|
||
|
||
#else
|
||
|
||
toolBar.playButton.tintColor = .white
|
||
toolBar.playbackRateButton.tintColor = .white
|
||
toolBar.definitionButton.tintColor = .white
|
||
toolBar.audioSwitchButton.tintColor = .white
|
||
toolBar.videoSwitchButton.tintColor = .white
|
||
toolBar.srtButton.tintColor = .white
|
||
toolBar.pipButton.tintColor = .white
|
||
|
||
toolBar.spacing = 10
|
||
toolBar.addArrangedSubview(toolBar.playButton)
|
||
toolBar.addArrangedSubview(toolBar.timeLabel)
|
||
toolBar.addArrangedSubview(toolBar.playbackRateButton)
|
||
toolBar.addArrangedSubview(toolBar.definitionButton)
|
||
toolBar.addArrangedSubview(toolBar.audioSwitchButton)
|
||
toolBar.addArrangedSubview(toolBar.videoSwitchButton)
|
||
toolBar.addArrangedSubview(toolBar.srtButton)
|
||
toolBar.addArrangedSubview(toolBar.pipButton)
|
||
|
||
toolBar.setCustomSpacing(20, after: toolBar.timeLabel)
|
||
toolBar.setCustomSpacing(20, after: toolBar.playbackRateButton)
|
||
toolBar.setCustomSpacing(20, after: toolBar.definitionButton)
|
||
toolBar.setCustomSpacing(20, after: toolBar.srtButton)
|
||
|
||
NSLayoutConstraint.activate([
|
||
toolBar.bottomAnchor.constraint(equalTo: bottomMaskView.safeBottomAnchor),
|
||
toolBar.leadingAnchor.constraint(equalTo: bottomMaskView.safeLeadingAnchor, constant: 10),
|
||
toolBar.trailingAnchor.constraint(equalTo: bottomMaskView.safeTrailingAnchor, constant: -15),
|
||
toolBar.timeSlider.bottomAnchor.constraint(equalTo: toolBar.topAnchor),
|
||
toolBar.timeSlider.leadingAnchor.constraint(equalTo: bottomMaskView.safeLeadingAnchor, constant: 15),
|
||
toolBar.timeSlider.trailingAnchor.constraint(equalTo: bottomMaskView.safeTrailingAnchor, constant: -15),
|
||
toolBar.timeSlider.heightAnchor.constraint(equalToConstant: 30),
|
||
])
|
||
#endif
|
||
}
|
||
|
||
private func preferredStyle() -> UIAlertController.Style {
|
||
#if canImport(UIKit)
|
||
return UIDevice.current.userInterfaceIdiom == .phone ? .actionSheet : .alert
|
||
#else
|
||
return .alert
|
||
#endif
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
private func addRemoteControllerGestures() {
|
||
let rightPressRecognizer = UITapGestureRecognizer()
|
||
rightPressRecognizer.addTarget(self, action: #selector(rightArrowButtonPressed(_:)))
|
||
rightPressRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)]
|
||
addGestureRecognizer(rightPressRecognizer)
|
||
|
||
let leftPressRecognizer = UITapGestureRecognizer()
|
||
leftPressRecognizer.addTarget(self, action: #selector(leftArrowButtonPressed(_:)))
|
||
leftPressRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)]
|
||
addGestureRecognizer(leftPressRecognizer)
|
||
|
||
let selectPressRecognizer = UITapGestureRecognizer()
|
||
selectPressRecognizer.addTarget(self, action: #selector(selectButtonPressed(_:)))
|
||
selectPressRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.select.rawValue)]
|
||
addGestureRecognizer(selectPressRecognizer)
|
||
|
||
let swipeUpRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedUp(_:)))
|
||
swipeUpRecognizer.direction = .up
|
||
addGestureRecognizer(swipeUpRecognizer)
|
||
|
||
let swipeDownRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedDown(_:)))
|
||
swipeDownRecognizer.direction = .down
|
||
addGestureRecognizer(swipeDownRecognizer)
|
||
}
|
||
|
||
@objc
|
||
private func rightArrowButtonPressed(_: UITapGestureRecognizer) {
|
||
guard let playerLayer, playerLayer.state.isPlaying, toolBar.isSeekable else { return }
|
||
seek(time: toolBar.currentTime + 15) { _ in }
|
||
}
|
||
|
||
@objc
|
||
private func leftArrowButtonPressed(_: UITapGestureRecognizer) {
|
||
guard let playerLayer, playerLayer.state.isPlaying, toolBar.isSeekable else { return }
|
||
seek(time: toolBar.currentTime - 15) { _ in }
|
||
}
|
||
|
||
@objc
|
||
private func selectButtonPressed(_: UITapGestureRecognizer) {
|
||
guard toolBar.isSeekable else { return }
|
||
if let playerLayer, playerLayer.state.isPlaying {
|
||
pause()
|
||
} else {
|
||
play()
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func swipedUp(_: UISwipeGestureRecognizer) {
|
||
guard let playerLayer, playerLayer.state.isPlaying else { return }
|
||
if isMaskShow == false {
|
||
isMaskShow = true
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func swipedDown(_: UISwipeGestureRecognizer) {
|
||
guard let playerLayer, playerLayer.state.isPlaying else { return }
|
||
if isMaskShow == true {
|
||
isMaskShow = false
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
public enum KSPlayerTopBarShowCase {
|
||
/// 始终显示
|
||
case always
|
||
/// 只在横屏界面显示
|
||
case horizantalOnly
|
||
/// 不显示
|
||
case none
|
||
}
|
||
|
||
public extension KSOptions {
|
||
/// 顶部返回、标题、AirPlay按钮 显示选项,默认.Always,可选.HorizantalOnly、.None
|
||
static var topBarShowInCase = KSPlayerTopBarShowCase.always
|
||
/// 自动隐藏操作栏的时间间隔 默认5秒
|
||
static var animateDelayTimeInterval = TimeInterval(5)
|
||
/// 开启亮度手势 默认true
|
||
static var enableBrightnessGestures = true
|
||
/// 开启音量手势 默认true
|
||
static var enableVolumeGestures = true
|
||
/// 开启进度滑动手势 默认true
|
||
static var enablePlaytimeGestures = true
|
||
/// 播放内核选择策略 先使用firstPlayer,失败了自动切换到secondPlayer,播放内核有KSAVPlayer、KSMEPlayer两个选项
|
||
/// 是否能后台播放视频
|
||
static var canBackgroundPlay = false
|
||
}
|
||
|
||
extension UIView {
|
||
var widthConstraint: NSLayoutConstraint? {
|
||
// 防止返回NSContentSizeLayoutConstraint
|
||
constraints.first { $0.isMember(of: NSLayoutConstraint.self) && $0.firstAttribute == .width }
|
||
}
|
||
|
||
var heightConstraint: NSLayoutConstraint? {
|
||
// 防止返回NSContentSizeLayoutConstraint
|
||
constraints.first { $0.isMember(of: NSLayoutConstraint.self) && $0.firstAttribute == .height }
|
||
}
|
||
|
||
var trailingConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .trailing }
|
||
}
|
||
|
||
var leadingConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .leading }
|
||
}
|
||
|
||
var topConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .top }
|
||
}
|
||
|
||
var bottomConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .bottom }
|
||
}
|
||
|
||
var centerXConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .centerX }
|
||
}
|
||
|
||
var centerYConstraint: NSLayoutConstraint? {
|
||
superview?.constraints.first { $0.firstItem === self && $0.firstAttribute == .centerY }
|
||
}
|
||
|
||
var frameConstraints: [NSLayoutConstraint] {
|
||
var frameConstraint = superview?.constraints.filter { constraint in
|
||
constraint.firstItem === self
|
||
} ?? [NSLayoutConstraint]()
|
||
for constraint in constraints where
|
||
constraint.isMember(of: NSLayoutConstraint.self) && constraint.firstItem === self && (constraint.firstAttribute == .width || constraint.firstAttribute == .height)
|
||
{
|
||
frameConstraint.append(constraint)
|
||
}
|
||
return frameConstraint
|
||
}
|
||
|
||
var safeTopAnchor: NSLayoutYAxisAnchor {
|
||
if #available(macOS 11.0, *) {
|
||
return self.safeAreaLayoutGuide.topAnchor
|
||
} else {
|
||
return topAnchor
|
||
}
|
||
}
|
||
|
||
var readableTopAnchor: NSLayoutYAxisAnchor {
|
||
#if os(macOS)
|
||
topAnchor
|
||
#else
|
||
readableContentGuide.topAnchor
|
||
#endif
|
||
}
|
||
|
||
var safeLeadingAnchor: NSLayoutXAxisAnchor {
|
||
if #available(macOS 11.0, *) {
|
||
return self.safeAreaLayoutGuide.leadingAnchor
|
||
} else {
|
||
return leadingAnchor
|
||
}
|
||
}
|
||
|
||
var safeTrailingAnchor: NSLayoutXAxisAnchor {
|
||
if #available(macOS 11.0, *) {
|
||
return self.safeAreaLayoutGuide.trailingAnchor
|
||
} else {
|
||
return trailingAnchor
|
||
}
|
||
}
|
||
|
||
var safeBottomAnchor: NSLayoutYAxisAnchor {
|
||
if #available(macOS 11.0, *) {
|
||
return self.safeAreaLayoutGuide.bottomAnchor
|
||
} else {
|
||
return bottomAnchor
|
||
}
|
||
}
|
||
}
|