Video gallery controls

See #450
This commit is contained in:
Shadowfacts 2024-03-28 21:32:11 -04:00
parent 42e29862ac
commit 6857529d06
8 changed files with 780 additions and 2 deletions

View File

@ -13,6 +13,7 @@ public protocol GalleryContentViewController: UIViewController {
var contentSize: CGSize { get }
var activityItemsForSharing: [Any] { get }
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
@ -20,6 +21,10 @@ public protocol GalleryContentViewController: UIViewController {
}
public extension GalleryContentViewController {
var contentOverlayAccessoryViewController: UIViewController? {
nil
}
var bottomControlsAccessoryViewController: UIViewController? {
nil
}

View File

@ -14,4 +14,5 @@ public protocol GalleryContentViewControllerContainer {
func setGalleryContentLoading(_ loading: Bool)
func galleryContentChanged()
func disableGalleryScrollAndZoom()
func setGalleryControlsVisible(_ visible: Bool, animated: Bool)
}

View File

@ -21,6 +21,7 @@ class GalleryItemViewController: UIViewController {
let itemIndex: Int
let content: GalleryContentViewController
private var overlayVC: UIViewController?
private var activityIndicator: UIActivityIndicatorView?
private(set) var scrollView: UIScrollView!
@ -32,6 +33,9 @@ class GalleryItemViewController: UIViewController {
private var closeButtonTopConstraint: NSLayoutConstraint!
private var bottomControlsView: UIStackView!
private(set) var captionTextView: UITextView!
private var singleTap: UITapGestureRecognizer!
private var doubleTap: UITapGestureRecognizer!
private var contentViewLeadingConstraint: NSLayoutConstraint?
private var contentViewTopConstraint: NSLayoutConstraint?
@ -70,6 +74,19 @@ class GalleryItemViewController: UIViewController {
addContent()
centerContent()
overlayVC = content.contentOverlayAccessoryViewController
if let overlayVC {
overlayVC.view.isHidden = activityIndicator != nil
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
])
}
topControlsView = UIView()
topControlsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topControlsView)
@ -118,6 +135,7 @@ class GalleryItemViewController: UIViewController {
captionTextView.isSelectable = true
captionTextView.font = .preferredFont(forTextStyle: .body)
captionTextView.adjustsFontForContentSizeCategory = true
captionTextView.alwaysBounceVertical = true
updateCaptionTextView()
bottomControlsView.addArrangedSubview(captionTextView)
@ -151,8 +169,10 @@ class GalleryItemViewController: UIViewController {
captionTextView.heightAnchor.constraint(equalToConstant: 150),
])
let singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
singleTap.delegate = self
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
doubleTap.delegate = self
doubleTap.numberOfTapsRequired = 2
// this requirement is needed to make sure the double tap is ever recognized
singleTap.require(toFail: doubleTap)
@ -192,6 +212,8 @@ class GalleryItemViewController: UIViewController {
}
func addContent() {
content.loadViewIfNeeded()
content.setControlsVisible(controlsVisible, animated: false)
content.view.translatesAutoresizingMaskIntoConstraints = false
@ -219,6 +241,16 @@ class GalleryItemViewController: UIViewController {
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
if let overlayVC {
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
content.view.layoutIfNeeded()
}
@ -405,6 +437,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
func setGalleryContentLoading(_ loading: Bool) {
if loading {
overlayVC?.view.isHidden = true
if activityIndicator == nil {
let activityIndicator = UIActivityIndicatorView(style: .large)
self.activityIndicator = activityIndicator
@ -430,6 +463,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
} else {
activityIndicator.removeFromSuperview()
self.activityIndicator = nil
self.overlayVC?.view.isHidden = false
}
}
}
@ -450,6 +484,10 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
addContent()
}
}
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
setControlsVisible(visible, animated: animated)
}
}
extension GalleryItemViewController: UIScrollViewDelegate {
@ -472,3 +510,17 @@ extension GalleryItemViewController: UIScrollViewDelegate {
scrollView.layoutIfNeeded()
}
}
extension GalleryItemViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == singleTap {
let loc = gestureRecognizer.location(in: view)
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
} else if gestureRecognizer == doubleTap {
let loc = gestureRecognizer.location(in: content.view)
return content.view.bounds.contains(loc)
} else {
return true
}
}
}

View File

@ -185,6 +185,8 @@
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */; };
D69261272BB3BA610023152C /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261262BB3BA610023152C /* Box.swift */; };
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; };
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; };
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; };
@ -196,6 +198,7 @@
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; };
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; };
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */; };
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; };
@ -587,6 +590,8 @@
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayViewController.swift; sourceTree = "<group>"; };
D69261262BB3BA610023152C /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = "<group>"; };
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = "<group>"; };
@ -597,6 +602,7 @@
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.swift; sourceTree = "<group>"; };
D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = "<group>"; };
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = "<group>"; };
@ -829,6 +835,8 @@
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */,
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */,
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */,
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */,
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */,
);
path = Gallery;
@ -1487,6 +1495,7 @@
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
D69261262BB3BA610023152C /* Box.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
@ -1946,6 +1955,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
@ -1989,6 +1999,7 @@
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
@ -2162,6 +2173,7 @@
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */,
D69261272BB3BA610023152C /* Box.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,

18
Tusker/Box.swift Normal file
View File

@ -0,0 +1,18 @@
//
// Box.swift
// Tusker
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
@propertyWrapper
class Box<Value> {
var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}

View File

@ -0,0 +1,459 @@
//
// 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×"
}
}
}

View File

@ -16,9 +16,14 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private let item: AVPlayerItem
let player: AVPlayer
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1
private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem?
init(url: URL, caption: String?) {
self.url = url
@ -64,6 +69,20 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
})
rateObservation = player.observe(\.rate, options: .old, changeHandler: { [unowned self] player, info in
hideControlsWorkItem?.cancel()
if player.rate > 0 && info.oldValue == 0 {
hideControlsWorkItem = DispatchWorkItem { [weak self] in
guard let self,
let container = self.container,
container.galleryControlsVisible else {
return
}
container.setGalleryControlsVisible(false, animated: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: hideControlsWorkItem!)
}
})
preferredContentSize = item.presentationSize
}
@ -95,6 +114,18 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
[VideoActivityItemSource(asset: item.asset, url: url)]
}
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
var contentOverlayAccessoryViewController: UIViewController? {
overlayVC
}
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
func setControlsVisible(_ visible: Bool, animated: Bool) {
overlayVC.setVisible(visible)
hideControlsWorkItem?.cancel()
}
}
private class PlayerView: UIView {

View File

@ -0,0 +1,200 @@
//
// VideoOverlayViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import AVFoundation
class VideoOverlayViewController: UIViewController {
private static let playImage = UIImage(systemName: "play.fill")!
private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer
@Box private var playbackSpeed: Float
private var dimmingView: UIView!
private var controlsStack: UIStackView!
private var skipBackButton: VideoOverlayButton!
private var skipForwardButton: VideoOverlayButton!
private var rateObservation: NSKeyValueObservation?
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()
dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.alpha = 0.2
dimmingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dimmingView)
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
skipBackButton = VideoOverlayButton(image: UIImage(systemName: "gobackward.10")!)
skipBackButton.addTarget(self, action: #selector(skipBackPressed), for: .touchUpInside)
let playPauseButton = VideoOverlayButton(image: VideoOverlayViewController.pauseImage)
playPauseButton.addTarget(self, action: #selector(playPausePressed), for: .touchUpInside)
skipForwardButton = VideoOverlayButton(image: UIImage(systemName: "goforward.10")!)
skipForwardButton.addTarget(self, action: #selector(skipForwardPressed), for: .touchUpInside)
controlsStack = UIStackView(arrangedSubviews: [
skipBackButton,
playPauseButton,
skipForwardButton,
])
controlsStack.axis = .horizontal
controlsStack.alignment = .center
controlsStack.spacing = 24
controlsStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(controlsStack)
NSLayoutConstraint.activate([
controlsStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
controlsStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
skipBackButton.widthAnchor.constraint(equalToConstant: 50),
skipBackButton.heightAnchor.constraint(equalToConstant: 50),
playPauseButton.widthAnchor.constraint(equalToConstant: 66),
playPauseButton.heightAnchor.constraint(equalToConstant: 66),
skipForwardButton.widthAnchor.constraint(equalToConstant: 50),
skipForwardButton.heightAnchor.constraint(equalToConstant: 50),
])
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
MainActor.runUnsafely {
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
}
})
}
func setVisible(_ visible: Bool) {
loadViewIfNeeded()
view.alpha = visible ? 1 : 0
}
@objc private func playPausePressed() {
if player.rate > 0 {
player.rate = 0
} else {
player.rate = playbackSpeed
}
}
@objc private func skipBackPressed() {
player.seek(to: player.currentTime() - CMTime(value: 10, timescale: 1))
}
@objc private func skipForwardPressed() {
player.seek(to: player.currentTime() + CMTime(value: 10, timescale: 1))
}
}
private class VideoOverlayButton: UIControl {
var image: UIImage? {
get {
imageView.image
}
set {
imageView.image = newValue
}
}
private let backgroundView = UIView()
private let imageView = UIImageView()
private var animator: UIViewPropertyAnimator?
override var isEnabled: Bool {
didSet {
imageView.tintColor = isEnabled ? .white : .lightGray
}
}
init(image: UIImage) {
super.init(frame: .zero)
backgroundView.alpha = 0
backgroundView.backgroundColor = .lightGray.withAlphaComponent(0.5)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
imageView.image = image
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = .init(scale: .large)
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
imageView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
backgroundView.layer.cornerRadius = bounds.height / 2
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
bounds.contains(point) ? self : nil
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
UIView.animate(withDuration: 0.2) {
self.backgroundView.alpha = 1
self.backgroundView.transform = CGAffineTransform(scaleX: 1/0.8, y: 1/0.8)
self.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
}
return super.beginTracking(touch, with: event)
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
UIView.animate(withDuration: 0.2) {
self.backgroundView.alpha = 0
self.backgroundView.transform = .identity
self.transform = .identity
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UITapGestureRecognizer {
return false
}
return true
}
}