From 6857529d0686b663cc30955ffe8e0c6823c103c8 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 28 Mar 2024 21:32:11 -0400 Subject: [PATCH] Video gallery controls See #450 --- .../GalleryContentViewController.swift | 5 + ...alleryContentViewControllerContainer.swift | 1 + .../GalleryVC/GalleryItemViewController.swift | 56 ++- Tusker.xcodeproj/project.pbxproj | 12 + Tusker/Box.swift | 18 + .../Gallery/VideoControlsViewController.swift | 459 ++++++++++++++++++ .../VideoGalleryContentViewController.swift | 31 ++ .../Gallery/VideoOverlayViewController.swift | 200 ++++++++ 8 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 Tusker/Box.swift create mode 100644 Tusker/Screens/Gallery/VideoControlsViewController.swift create mode 100644 Tusker/Screens/Gallery/VideoOverlayViewController.swift diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift index aedd922d..d5ed9afa 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift @@ -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 } diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift index bee9a173..a812f3d5 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift @@ -14,4 +14,5 @@ public protocol GalleryContentViewControllerContainer { func setGalleryContentLoading(_ loading: Bool) func galleryContentChanged() func disableGalleryScrollAndZoom() + func setGalleryControlsVisible(_ visible: Bool, animated: Bool) } diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift index ab583b6f..4e6c9fea 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift @@ -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 + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 05db1660..cbdddb4b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = ""; }; D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = ""; }; + D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayViewController.swift; sourceTree = ""; }; + D69261262BB3BA610023152C /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = ""; }; D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = ""; }; D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = ""; }; D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = ""; }; @@ -597,6 +602,7 @@ D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = ""; }; D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.swift; sourceTree = ""; }; D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = ""; }; + D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = ""; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; }; D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; }; D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/Box.swift b/Tusker/Box.swift new file mode 100644 index 00000000..d1dce9f6 --- /dev/null +++ b/Tusker/Box.swift @@ -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 { + var wrappedValue: Value + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} diff --git a/Tusker/Screens/Gallery/VideoControlsViewController.swift b/Tusker/Screens/Gallery/VideoControlsViewController.swift new file mode 100644 index 00000000..77f340db --- /dev/null +++ b/Tusker/Screens/Gallery/VideoControlsViewController.swift @@ -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) { + 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×" + } + } +} diff --git a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift index 4d14dca3..dd584df6 100644 --- a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift +++ b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift @@ -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 { diff --git a/Tusker/Screens/Gallery/VideoOverlayViewController.swift b/Tusker/Screens/Gallery/VideoOverlayViewController.swift new file mode 100644 index 00000000..b22e78ca --- /dev/null +++ b/Tusker/Screens/Gallery/VideoOverlayViewController.swift @@ -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) { + 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 + } +}