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