Tusker/Tusker/Screens/Gallery/VideoControlsViewController...

460 lines
16 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.

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