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×"
|
||
}
|
||
}
|
||
}
|