jellyflood/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift

443 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2023 Jellyfin & Jellyfin Contributors
//
// Modification of https://github.com/zattoo/TvOSSlider
import SwiftUI
import UIKit
// TODO: Replace
private let trackViewHeight: CGFloat = 5
private let animationDuration: TimeInterval = 0.3
private let defaultValue: Float = 0
private let defaultMinimumValue: Float = 0
private let defaultMaximumValue: Float = 1
private let defaultIsContinuous: Bool = true
private let defaultThumbTintColor: UIColor = .white
private let defaultTrackColor: UIColor = .gray
private let defaultMininumTrackTintColor: UIColor = .blue
private let defaultFocusScaleFactor: CGFloat = 1.05
private let defaultStepValue: Float = 0.1
private let decelerationRate: Float = 0.92
private let decelerationMaxVelocity: Float = 1000
/// A control used to select a single value from a continuous range of values.
final class UITVOSSlider: UIControl {
/// The sliders current value.
var value: Float {
get {
storedValue
}
set {
storedValue = min(maximumValue, newValue)
storedValue = max(minimumValue, storedValue)
var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue))
offset = min(trackView.bounds.width, offset)
thumbViewCenterXConstraint.constant = offset
}
}
/// The minimum value of the slider.
var minimumValue: Float = defaultMinimumValue {
didSet {
value = max(value, minimumValue)
}
}
/// The maximum value of the slider.
var maximumValue: Float = defaultMaximumValue {
didSet {
value = min(value, maximumValue)
}
}
/// A Boolean value indicating whether changes in the sliders value generate continuous update events.
var isContinuous: Bool = defaultIsContinuous
/// The color used to tint the default minimum track images.
var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor {
didSet {
minimumTrackView.backgroundColor = minimumTrackTintColor
}
}
/// The color used to tint the default maximum track images.
var maximumTrackTintColor: UIColor? {
didSet {
maximumTrackView.backgroundColor = maximumTrackTintColor
}
}
/// The color used to tint the default thumb images.
var thumbTintColor: UIColor = defaultThumbTintColor {
didSet {
thumbView.backgroundColor = thumbTintColor
}
}
/// Scale factor applied to the slider when receiving the focus
var focusScaleFactor: CGFloat = defaultFocusScaleFactor {
didSet {
updateStateDependantViews()
}
}
/// Value added or subtracted from the current value on steps left or right updates
var stepValue: Float = defaultStepValue
/// Damping value for panning gestures
var panDampingValue: Float = 5
// Size for thumb view
var thumbSize: CGFloat = 30
var fineTunningVelocityThreshold: Float = 600
/**
Sets the sliders current value, allowing you to animate the change visually.
- Parameters:
- value: The new value to assign to the value property
- animated: Specify true to animate the change in value; otherwise, specify false to update the sliders appearance immediately. Animations are performed asynchronously and do not block the calling thread.
*/
func setValue(_ value: Float, animated: Bool) {
self.value = value
stopDeceleratingTimer()
if animated {
UIView.animate(withDuration: animationDuration) {
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
}
/**
Assigns a minimum track image to the specified control states.
- Parameters:
- image: The minimum track image to associate with the specified states.
- state: The control state with which to associate the image.
*/
func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) {
minimumTrackViewImages[state.rawValue] = image
updateStateDependantViews()
}
/**
Assigns a maximum track image to the specified control states.
- Parameters:
- image: The maximum track image to associate with the specified states.
- state: The control state with which to associate the image.
*/
func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) {
maximumTrackViewImages[state.rawValue] = image
updateStateDependantViews()
}
/**
Assigns a thumb image to the specified control states.
- Parameters:
- image: The thumb image to associate with the specified states.
- state: The control state with which to associate the image.
*/
func setThumbImage(_ image: UIImage?, for state: UIControl.State) {
thumbViewImages[state.rawValue] = image
updateStateDependantViews()
}
// MARK: - Initializers
private var onEditingChanged: (Bool) -> Void
private var valueBinding: Binding<CGFloat>
init(
value: Binding<CGFloat>,
onEditingChanged: @escaping (Bool) -> Void
) {
self.onEditingChanged = onEditingChanged
self.valueBinding = value
super.init(frame: .zero)
setUpView()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UIControlStates
/// :nodoc:
override var isEnabled: Bool {
didSet {
panGestureRecognizer.isEnabled = isEnabled
updateStateDependantViews()
}
}
/// :nodoc:
override var isSelected: Bool {
didSet {
updateStateDependantViews()
}
}
/// :nodoc:
override var isHighlighted: Bool {
didSet {
updateStateDependantViews()
}
}
/// :nodoc:
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
coordinator.addCoordinatedAnimations({
self.updateStateDependantViews()
}, completion: nil)
}
// MARK: - Private
private typealias ControlState = UInt
private var storedValue: Float = defaultValue
private var thumbViewImages: [ControlState: UIImage] = [:]
private var thumbView: UIImageView!
private var trackViewImages: [ControlState: UIImage] = [:]
private var trackView: UIImageView!
private var minimumTrackViewImages: [ControlState: UIImage] = [:]
private var minimumTrackView: UIImageView!
private var maximumTrackViewImages: [ControlState: UIImage] = [:]
private var maximumTrackView: UIImageView!
private var panGestureRecognizer: UIPanGestureRecognizer!
private var leftTapGestureRecognizer: UITapGestureRecognizer!
private var rightTapGestureRecognizer: UITapGestureRecognizer!
private var thumbViewCenterXConstraint: NSLayoutConstraint!
private weak var deceleratingTimer: Timer?
private var deceleratingVelocity: Float = 0
private var thumbViewCenterXConstraintConstant: Float = 0
private func setUpView() {
setUpTrackView()
setUpMinimumTrackView()
setUpMaximumTrackView()
setUpThumbView()
setUpTrackViewConstraints()
setUpMinimumTrackViewConstraints()
setUpMaximumTrackViewConstraints()
setUpThumbViewConstraints()
setUpGestures()
updateStateDependantViews()
}
private func setUpThumbView() {
thumbView = UIImageView()
thumbView.layer.cornerRadius = thumbSize / 6
thumbView.backgroundColor = thumbTintColor
addSubview(thumbView)
}
private func setUpTrackView() {
trackView = UIImageView()
trackView.layer.cornerRadius = trackViewHeight / 2
trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3)
addSubview(trackView)
}
private func setUpMinimumTrackView() {
minimumTrackView = UIImageView()
minimumTrackView.layer.cornerRadius = trackViewHeight / 2
minimumTrackView.backgroundColor = minimumTrackTintColor
addSubview(minimumTrackView)
}
private func setUpMaximumTrackView() {
maximumTrackView = UIImageView()
maximumTrackView.layer.cornerRadius = trackViewHeight / 2
maximumTrackView.backgroundColor = maximumTrackTintColor
addSubview(maximumTrackView)
}
private func setUpTrackViewConstraints() {
trackView.translatesAutoresizingMaskIntoConstraints = false
trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpMinimumTrackViewConstraints() {
minimumTrackView.translatesAutoresizingMaskIntoConstraints = false
minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true
minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true
minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpMaximumTrackViewConstraints() {
maximumTrackView.translatesAutoresizingMaskIntoConstraints = false
maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true
maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true
maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpThumbViewConstraints() {
thumbView.translatesAutoresizingMaskIntoConstraints = false
thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true
thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true
thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value))
thumbViewCenterXConstraint.isActive = true
}
private func setUpGestures() {
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:)))
addGestureRecognizer(panGestureRecognizer)
leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered))
leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)]
leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
addGestureRecognizer(leftTapGestureRecognizer)
rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered))
rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)]
rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
addGestureRecognizer(rightTapGestureRecognizer)
}
private func updateStateDependantViews() {
thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue]
if isFocused {
thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor)
} else {
thumbView.transform = CGAffineTransform.identity
}
}
@objc
private func handleDeceleratingTimer(timer: Timer) {
let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01
let percent = centerX / Float(trackView.frame.width)
value = minimumValue + ((maximumValue - minimumValue) * percent)
if isContinuous {
sendActions(for: .valueChanged)
}
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
deceleratingVelocity *= decelerationRate
if !isFocused || abs(deceleratingVelocity) < 1 {
stopDeceleratingTimer()
}
valueBinding.wrappedValue = CGFloat(percent)
onEditingChanged(false)
}
private func stopDeceleratingTimer() {
deceleratingTimer?.invalidate()
deceleratingTimer = nil
deceleratingVelocity = 0
sendActions(for: .valueChanged)
}
private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool {
let translation = recognizer.translation(in: self)
if abs(translation.y) > abs(translation.x) {
return true
}
return false
}
// MARK: - Actions
@objc
private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) {
guard !isVerticalGesture(panGestureRecognizer) else { return }
let translation = Float(panGestureRecognizer.translation(in: self).x)
let velocity = Float(panGestureRecognizer.velocity(in: self).x)
switch panGestureRecognizer.state {
case .began:
onEditingChanged(true)
stopDeceleratingTimer()
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
case .changed:
let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue
let percent = centerX / Float(trackView.frame.width)
value = minimumValue + ((maximumValue - minimumValue) * percent)
if isContinuous {
sendActions(for: .valueChanged)
}
valueBinding.wrappedValue = CGFloat(percent)
case .ended, .cancelled:
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
if abs(velocity) > fineTunningVelocityThreshold {
let direction: Float = velocity > 0 ? 1 : -1
deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity
deceleratingTimer = Timer.scheduledTimer(
timeInterval: 0.01,
target: self,
selector: #selector(handleDeceleratingTimer(timer:)),
userInfo: nil,
repeats: true
)
} else {
onEditingChanged(false)
stopDeceleratingTimer()
}
default:
break
}
}
@objc
private func leftTapWasTriggered() {
// setValue(value-stepValue, animated: true)
// viewModel.playerOverlayDelegate?.didSelectBackward()
}
@objc
private func rightTapWasTriggered() {
// setValue(value+stepValue, animated: true)
// viewModel.playerOverlayDelegate?.didSelectForward()
}
}