460 lines
16 KiB
Swift
460 lines
16 KiB
Swift
|
//
|
|||
|
// VideoControlsViewController.swift
|
|||
|
// Tusker
|
|||
|
//
|
|||
|
// Created by Shadowfacts on 3/21/24.
|
|||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
|||
|
//
|
|||
|
|
|||
|
import UIKit
|
|||
|
import AVFoundation
|
|||
|
|
|||
|
class VideoControlsViewController: UIViewController {
|
|||
|
private static let formatter: DateComponentsFormatter = {
|
|||
|
let f = DateComponentsFormatter()
|
|||
|
f.allowedUnits = [.minute, .second]
|
|||
|
f.zeroFormattingBehavior = .pad
|
|||
|
return f
|
|||
|
}()
|
|||
|
|
|||
|
private let player: AVPlayer
|
|||
|
@Box private var playbackSpeed: Float
|
|||
|
|
|||
|
private lazy var muteButton = MuteButton().configure {
|
|||
|
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
|
|||
|
$0.setMuted(false, animated: false)
|
|||
|
}
|
|||
|
|
|||
|
private let timestampLabel = UILabel().configure {
|
|||
|
$0.text = "0:00"
|
|||
|
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
|||
|
}
|
|||
|
|
|||
|
private lazy var scrubbingControl = VideoScrubbingControl().configure {
|
|||
|
$0.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|||
|
$0.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
|
|||
|
$0.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
|
|||
|
$0.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
|
|||
|
}
|
|||
|
|
|||
|
private let timeRemainingLabel = UILabel().configure {
|
|||
|
$0.text = "-0:00"
|
|||
|
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
|
|||
|
}
|
|||
|
|
|||
|
private lazy var optionsButton = MenuButton { [unowned self] in
|
|||
|
let imageName: String
|
|||
|
if #available(iOS 17.0, *) {
|
|||
|
switch self.playbackSpeed {
|
|||
|
case 0.5:
|
|||
|
imageName = "gauge.with.dots.needle.0percent"
|
|||
|
case 1:
|
|||
|
imageName = "gauge.with.dots.needle.33percent"
|
|||
|
case 1.25:
|
|||
|
imageName = "gauge.with.dots.needle.50percent"
|
|||
|
case 2:
|
|||
|
imageName = "gauge.with.dots.needle.100percent"
|
|||
|
default:
|
|||
|
imageName = "gauge.with.dots.needle.67percent"
|
|||
|
}
|
|||
|
} else {
|
|||
|
imageName = "speedometer"
|
|||
|
}
|
|||
|
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
|
|||
|
UIAction(title: speed.displayName, state: self.playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
|
|||
|
self.playbackSpeed = speed.rate
|
|||
|
if self.player.rate > 0 {
|
|||
|
self.player.rate = speed.rate
|
|||
|
}
|
|||
|
}
|
|||
|
})
|
|||
|
return UIMenu(children: [speedMenu])
|
|||
|
}
|
|||
|
|
|||
|
private lazy var hStack = UIStackView(arrangedSubviews: [
|
|||
|
muteButton,
|
|||
|
timestampLabel,
|
|||
|
scrubbingControl,
|
|||
|
timeRemainingLabel,
|
|||
|
optionsButton,
|
|||
|
]).configure {
|
|||
|
$0.axis = .horizontal
|
|||
|
$0.spacing = 8
|
|||
|
$0.alignment = .center
|
|||
|
}
|
|||
|
|
|||
|
private var timestampObserverToken: Any?
|
|||
|
private var scrubberObserverToken: Any?
|
|||
|
|
|||
|
private var wasPlayingWhenScrubbingStarted = false
|
|||
|
private var scrubbingTargetTime: CMTime?
|
|||
|
private var isSeeking = false
|
|||
|
|
|||
|
init(player: AVPlayer, playbackSpeed: Box<Float>) {
|
|||
|
self.player = player
|
|||
|
self._playbackSpeed = playbackSpeed
|
|||
|
|
|||
|
super.init(nibName: nil, bundle: nil)
|
|||
|
}
|
|||
|
|
|||
|
required init?(coder: NSCoder) {
|
|||
|
fatalError("init(coder:) has not been implemented")
|
|||
|
}
|
|||
|
|
|||
|
override func viewDidLoad() {
|
|||
|
super.viewDidLoad()
|
|||
|
|
|||
|
hStack.translatesAutoresizingMaskIntoConstraints = false
|
|||
|
view.addSubview(hStack)
|
|||
|
NSLayoutConstraint.activate([
|
|||
|
hStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 4),
|
|||
|
hStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -4),
|
|||
|
hStack.topAnchor.constraint(equalTo: view.topAnchor),
|
|||
|
hStack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|||
|
])
|
|||
|
|
|||
|
timestampObserverToken = player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 2), queue: .main) { [unowned self] _ in
|
|||
|
self.updateTimestamps()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private func updateTimestamps() {
|
|||
|
let current = player.currentTime()
|
|||
|
timestampLabel.text = VideoControlsViewController.formatter.string(from: current.seconds)!
|
|||
|
let duration = player.currentItem!.duration
|
|||
|
if duration != .indefinite {
|
|||
|
let remaining = duration - current
|
|||
|
timeRemainingLabel.text = "-" + VideoControlsViewController.formatter.string(from: remaining.seconds)!
|
|||
|
|
|||
|
if scrubberObserverToken == nil {
|
|||
|
let interval = CMTime(value: 1, timescale: CMTimeScale(self.scrubbingControl.bounds.width))
|
|||
|
if interval.isValid {
|
|||
|
self.scrubberObserverToken = self.player.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { _ in
|
|||
|
self.scrubbingControl.fractionComplete = self.player.currentTime().seconds / duration.seconds
|
|||
|
})
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
@objc private func scrubbingStarted() {
|
|||
|
wasPlayingWhenScrubbingStarted = player.rate > 0
|
|||
|
player.rate = 0
|
|||
|
}
|
|||
|
|
|||
|
@objc private func scrubbingChanged() {
|
|||
|
let duration = player.currentItem!.duration
|
|||
|
let time = CMTime(value: CMTimeValue(scrubbingControl.fractionComplete * duration.seconds * 1_000_000_000), timescale: 1_000_000_000)
|
|||
|
scrubbingTargetTime = time
|
|||
|
if !isSeeking {
|
|||
|
seekToScrubbingTime()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private func seekToScrubbingTime() {
|
|||
|
guard let scrubbingTargetTime else {
|
|||
|
return
|
|||
|
}
|
|||
|
isSeeking = true
|
|||
|
player.seek(to: scrubbingTargetTime) { finished in
|
|||
|
if finished {
|
|||
|
if self.scrubbingTargetTime != scrubbingTargetTime {
|
|||
|
self.seekToScrubbingTime()
|
|||
|
} else {
|
|||
|
self.isSeeking = false
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
@objc private func scrubbingEnded() {
|
|||
|
scrubbingChanged()
|
|||
|
if wasPlayingWhenScrubbingStarted {
|
|||
|
player.rate = playbackSpeed
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
@objc private func muteButtonPressed() {
|
|||
|
player.isMuted.toggle()
|
|||
|
muteButton.setMuted(player.isMuted, animated: true)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private class VideoScrubbingControl: UIControl {
|
|||
|
var fractionComplete: Double = 0 {
|
|||
|
didSet {
|
|||
|
updateFillLayerMask()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private let trackLayer = CAShapeLayer()
|
|||
|
private let fillLayer = CAShapeLayer()
|
|||
|
private let fillMaskLayer = CALayer()
|
|||
|
|
|||
|
private var scrubbingStartFraction: Double?
|
|||
|
private var touchStartLocation: CGPoint?
|
|||
|
private var animator: UIViewPropertyAnimator?
|
|||
|
|
|||
|
#if !os(visionOS)
|
|||
|
private var feedbackGenerator: UIImpactFeedbackGenerator?
|
|||
|
#endif
|
|||
|
|
|||
|
init() {
|
|||
|
super.init(frame: .zero)
|
|||
|
|
|||
|
trackLayer.fillColor = UIColor.systemGray.cgColor
|
|||
|
trackLayer.shadowColor = UIColor.black.cgColor
|
|||
|
layer.addSublayer(trackLayer)
|
|||
|
|
|||
|
fillLayer.fillColor = UIColor.white.cgColor
|
|||
|
fillLayer.mask = fillMaskLayer
|
|||
|
layer.addSublayer(fillLayer)
|
|||
|
|
|||
|
fillMaskLayer.backgroundColor = UIColor.black.cgColor
|
|||
|
}
|
|||
|
|
|||
|
required init?(coder: NSCoder) {
|
|||
|
fatalError("init(coder:) has not been implemented")
|
|||
|
}
|
|||
|
|
|||
|
override func layoutSublayers(of layer: CALayer) {
|
|||
|
super.layoutSublayers(of: layer)
|
|||
|
|
|||
|
let trackFrame = CGRect(x: 0, y: (layer.bounds.height - 8) / 2, width: layer.bounds.width, height: 8)
|
|||
|
|
|||
|
trackLayer.frame = trackFrame
|
|||
|
trackLayer.path = CGPath(roundedRect: CGRect(x: 0, y: 0, width: trackFrame.width, height: trackFrame.height), cornerWidth: 4, cornerHeight: 4, transform: nil)
|
|||
|
trackLayer.shadowPath = trackLayer.path
|
|||
|
|
|||
|
fillLayer.frame = trackFrame
|
|||
|
fillLayer.path = trackLayer.path
|
|||
|
updateFillLayerMask()
|
|||
|
}
|
|||
|
|
|||
|
private func updateFillLayerMask() {
|
|||
|
// I don't know where this animation is coming from
|
|||
|
CATransaction.begin()
|
|||
|
CATransaction.setDisableActions(true)
|
|||
|
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
|||
|
CATransaction.commit()
|
|||
|
}
|
|||
|
|
|||
|
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
|||
|
touchStartLocation = touch.location(in: self)
|
|||
|
scrubbingStartFraction = fractionComplete
|
|||
|
|
|||
|
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
|||
|
animator!.addAnimations {
|
|||
|
self.transform = CGAffineTransform(scaleX: 1, y: 1.5)
|
|||
|
}
|
|||
|
animator!.startAnimation()
|
|||
|
|
|||
|
sendActions(for: .editingDidBegin)
|
|||
|
|
|||
|
#if !os(visionOS)
|
|||
|
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
|||
|
feedbackGenerator!.prepare()
|
|||
|
#endif
|
|||
|
|
|||
|
return true
|
|||
|
}
|
|||
|
|
|||
|
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
|||
|
guard let touchStartLocation,
|
|||
|
let scrubbingStartFraction else {
|
|||
|
return false
|
|||
|
}
|
|||
|
let location = touch.location(in: self)
|
|||
|
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
|
|||
|
let scrubbingAmount = translation.x / bounds.width
|
|||
|
let unclampedFractionComplete = scrubbingStartFraction + scrubbingAmount
|
|||
|
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
|
|||
|
#if !os(visionOS)
|
|||
|
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
|
|||
|
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
|||
|
}
|
|||
|
#endif
|
|||
|
fractionComplete = newFractionComplete
|
|||
|
sendActions(for: .editingChanged)
|
|||
|
|
|||
|
if unclampedFractionComplete < 0 || unclampedFractionComplete > 1 {
|
|||
|
let stretchFactor: CGFloat
|
|||
|
if unclampedFractionComplete < 0 {
|
|||
|
stretchFactor = 1/(unclampedFractionComplete * bounds.width / 10 - 1) + 1
|
|||
|
} else {
|
|||
|
stretchFactor = -1/((unclampedFractionComplete-1) * bounds.width / 10 + 1) + 1
|
|||
|
}
|
|||
|
let stretchAmount = 8 * stretchFactor
|
|||
|
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
|
|||
|
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
|
|||
|
}
|
|||
|
|
|||
|
return true
|
|||
|
}
|
|||
|
|
|||
|
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
|||
|
touchStartLocation = nil
|
|||
|
resetScale()
|
|||
|
sendActions(for: .editingDidEnd)
|
|||
|
#if !os(visionOS)
|
|||
|
feedbackGenerator = nil
|
|||
|
#endif
|
|||
|
}
|
|||
|
|
|||
|
override func cancelTracking(with event: UIEvent?) {
|
|||
|
touchStartLocation = nil
|
|||
|
resetScale()
|
|||
|
sendActions(for: .editingDidEnd)
|
|||
|
#if !os(visionOS)
|
|||
|
feedbackGenerator = nil
|
|||
|
#endif
|
|||
|
}
|
|||
|
|
|||
|
private func resetScale() {
|
|||
|
if let animator,
|
|||
|
animator.isRunning {
|
|||
|
animator.isReversed = true
|
|||
|
animator.startAnimation()
|
|||
|
} else {
|
|||
|
animator?.pauseAnimation()
|
|||
|
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
|||
|
animator!.addAnimations {
|
|||
|
self.transform = .identity
|
|||
|
}
|
|||
|
animator!.startAnimation()
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|||
|
false
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private class MuteButton: UIControl {
|
|||
|
private let imageView = UIImageView()
|
|||
|
|
|||
|
override var intrinsicContentSize: CGSize {
|
|||
|
CGSize(width: 32, height: 32)
|
|||
|
}
|
|||
|
|
|||
|
init() {
|
|||
|
super.init(frame: .zero)
|
|||
|
|
|||
|
imageView.contentMode = .scaleAspectFit
|
|||
|
imageView.tintColor = .white
|
|||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|||
|
addSubview(imageView)
|
|||
|
NSLayoutConstraint.activate([
|
|||
|
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|||
|
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|||
|
])
|
|||
|
}
|
|||
|
|
|||
|
required init?(coder: NSCoder) {
|
|||
|
fatalError("init(coder:) has not been implemented")
|
|||
|
}
|
|||
|
|
|||
|
func setMuted(_ muted: Bool, animated: Bool) {
|
|||
|
let image = UIImage(systemName: muted ? "speaker.slash.fill" : "speaker.wave.3.fill")!
|
|||
|
if animated,
|
|||
|
#available(iOS 17.0, *) {
|
|||
|
imageView.setSymbolImage(image, contentTransition: .replace.wholeSymbol, options: .speed(5))
|
|||
|
} else {
|
|||
|
imageView.image = image
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|||
|
!(gestureRecognizer is UITapGestureRecognizer)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private class MenuButton: UIControl {
|
|||
|
private let menuProvider: () -> UIMenu
|
|||
|
|
|||
|
private let imageView = UIImageView()
|
|||
|
|
|||
|
override var intrinsicContentSize: CGSize {
|
|||
|
CGSize(width: 32, height: 32)
|
|||
|
}
|
|||
|
|
|||
|
init(menuProvider: @escaping () -> UIMenu) {
|
|||
|
self.menuProvider = menuProvider
|
|||
|
|
|||
|
super.init(frame: .zero)
|
|||
|
|
|||
|
imageView.image = UIImage(systemName: "ellipsis.circle")
|
|||
|
imageView.contentMode = .scaleAspectFit
|
|||
|
imageView.tintColor = .white
|
|||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|||
|
addSubview(imageView)
|
|||
|
NSLayoutConstraint.activate([
|
|||
|
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|||
|
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|||
|
])
|
|||
|
|
|||
|
isContextMenuInteractionEnabled = true
|
|||
|
showsMenuAsPrimaryAction = true
|
|||
|
}
|
|||
|
|
|||
|
required init?(coder: NSCoder) {
|
|||
|
fatalError("init(coder:) has not been implemented")
|
|||
|
}
|
|||
|
|
|||
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
|||
|
UIContextMenuConfiguration(actionProvider: { _ in
|
|||
|
self.menuProvider()
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
|
|||
|
animator?.addAnimations {
|
|||
|
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: (any UIContextMenuInteractionAnimating)?) {
|
|||
|
if let animator {
|
|||
|
animator.addAnimations {
|
|||
|
self.transform = .identity
|
|||
|
}
|
|||
|
} else {
|
|||
|
self.transform = .identity
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private enum PlaybackSpeed: CaseIterable {
|
|||
|
case half, regular, oneAndAQuarter, oneAndAHalf, two
|
|||
|
|
|||
|
var rate: Float {
|
|||
|
switch self {
|
|||
|
case .half:
|
|||
|
0.5
|
|||
|
case .regular:
|
|||
|
1
|
|||
|
case .oneAndAQuarter:
|
|||
|
1.25
|
|||
|
case .oneAndAHalf:
|
|||
|
1.5
|
|||
|
case .two:
|
|||
|
2
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
var displayName: String {
|
|||
|
switch self {
|
|||
|
case .half:
|
|||
|
"0.5×"
|
|||
|
case .regular:
|
|||
|
"1×"
|
|||
|
case .oneAndAQuarter:
|
|||
|
"1.25×"
|
|||
|
case .oneAndAHalf:
|
|||
|
"1.5×"
|
|||
|
case .two:
|
|||
|
"2×"
|
|||
|
}
|
|||
|
}
|
|||
|
}
|