TM-SGNL-iOS/SignalUI/VideoEditor/VideoTimelineView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

670 lines
24 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
protocol VideoTimelineViewDataSource: VideoEditorDataSource, VideoPlaybackState {
var videoThumbnails: [UIImage]? { get }
var videoAspectRatio: CGSize { get }
}
protocol VideoTimelineViewDelegate: AnyObject {
func videoTimelineViewDidBeginTrimming(_ view: VideoTimelineView)
func videoTimelineView(_ view: VideoTimelineView, didTrimBeginningTo seconds: TimeInterval)
func videoTimelineView(_ view: VideoTimelineView, didTrimEndTo seconds: TimeInterval)
func videoTimelineViewDidEndTrimming(_ view: VideoTimelineView)
func videoTimelineViewWillBeginScrubbing(_ view: VideoTimelineView)
func videoTimelineView(_ view: VideoTimelineView, didScrubTo seconds: TimeInterval)
func videoTimelineViewDidEndScrubbing(_ view: VideoTimelineView)
}
class VideoTimelineView: UIView {
weak var dataSource: VideoTimelineViewDataSource?
weak var delegate: VideoTimelineViewDelegate?
private let thumbnailLayerView = OWSLayerView()
private let thumbnailOverlayLayer = CAShapeLayer()
private let trimLayerView = OWSLayerView()
private let trimHandleLeft = TrimHandleView(position: .left)
private let trimHandleRight = TrimHandleView(position: .right)
private var trimGestureLocationOffset: CGFloat = 0
private let cursorView = TimelineCursorView(frame: CGRect(origin: .zero, size: Constants.cursorSize))
private var isCursorHidden: Bool {
get {
cursorView.alpha == 0
}
set {
cursorView.alpha = newValue ? 0 : 1
}
}
private enum Mode {
case none
case trimmingStart
case trimmingEnd
case scrubbing
}
private var mode: Mode = .none
fileprivate enum Constants {
static let timelineHeight: CGFloat = 40
static let extraHotArea: CGFloat = 10
static let cursorSize = CGSize(width: 4, height: timelineHeight + 4)
static let cornerRadius: CGFloat = 4
}
static let preferredHeight = Constants.timelineHeight
private lazy var timeBubbleTextLabel: UILabel = {
let label = UILabel()
label.textColor = .white
label.font = .dynamicTypeCaption1.medium()
return label
}()
private lazy var timeBubbleView: UIView = {
let view = OWSLayerView()
view.alpha = 0
view.backgroundColor = .ows_blackAlpha60
view.isUserInteractionEnabled = false
view.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 4)
view.addSubview(timeBubbleTextLabel)
timeBubbleTextLabel.autoPinEdgesToSuperviewMargins()
view.layoutCallback = { view in
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 6).cgPath
view.layer.mask = maskLayer
}
return view
}()
private var timeBubbleViewPositionConstraint: NSLayoutConstraint?
init() {
super.init(frame: .zero)
createContents()
}
@available(*, unavailable, message: "use other init() instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func createContents() {
// Thumbnail strip.
addSubview(thumbnailLayerView)
thumbnailLayerView.backgroundColor = .ows_gray65 // This value matches color of a trim handle in default state.
thumbnailLayerView.clipsToBounds = true
thumbnailLayerView.autoPinEdgesToSuperviewEdges()
// This layer dims thumbnail strip outside of trimmed area.
// TODO: check if it is possible to change opacity on thumbnailLayerView instead.
thumbnailOverlayLayer.fillColor = UIColor.ows_blackAlpha50.cgColor
thumbnailOverlayLayer.fillRule = .evenOdd
thumbnailOverlayLayer.zPosition = 10000
thumbnailLayerView.layer.addSublayer(thumbnailOverlayLayer)
thumbnailLayerView.layoutCallback = { [weak self] view in
guard let self = self else { return }
// Rounded corners for thumbnailLayerView.
// Even though actual thumbnail area is inset on both ends
// it is necessary to apply rounded corners because thumbnailLayerView's background
// becomes exposed when either trim handle is moved from their default position.
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: Constants.cornerRadius).cgPath
view.layer.mask = maskLayer
// Dimming overlays.
let overlayPath = UIBezierPath()
overlayPath.append(UIBezierPath(rect: self.thumbnailStripOverlayRectLeft))
overlayPath.append(UIBezierPath(rect: self.thumbnailStripOverlayRectRight))
self.thumbnailOverlayLayer.path = overlayPath.cgPath
self.thumbnailOverlayLayer.frame = view.bounds
self.updateThumbnailView()
}
// View that contains trim handles and playback cursor.
trimLayerView.shouldAnimate = false
addSubview(trimLayerView)
trimLayerView.autoPinEdgesToSuperviewEdges()
trimLayerView.addSubview(trimHandleLeft)
trimLayerView.addSubview(trimHandleRight)
trimLayerView.addSubview(cursorView)
trimLayerView.layoutCallback = { [weak self] view in
guard let self = self else {
return
}
self.trimHandleLeft.center = self.leftTrimHandleCenter
self.trimHandleRight.center = self.rightTrimHandleCenter
// Repurpose `UIImageView.isHighlighted` to show different image (yellow handles) when video is trimmed.
let shouldHighlightHandles = self.isTrimmedOrBeingTrimmed
self.trimHandleLeft.isHighlighted = shouldHighlightHandles
self.trimHandleRight.isHighlighted = shouldHighlightHandles
self.updateCursorPosition()
}
addGestureRecognizer(PermissiveGestureRecognizer(target: self, action: #selector(gestureDidChange)))
}
func updateThumbnailView() {
if let sublayers = thumbnailLayerView.layer.sublayers {
for sublayer in sublayers {
if sublayer != thumbnailOverlayLayer {
sublayer.removeFromSuperlayer()
}
}
}
guard let dataSource = dataSource,
let videoThumbnails = dataSource.videoThumbnails else {
return
}
// Lengthwise thumbnails fill the entire space within trim handles in their default state.
let thumbnailStripRect = thumbnailStripRect
let thumbnailHeight = thumbnailStripRect.height
guard thumbnailHeight > 0 else {
return
}
// We want thumbnails to have the same aspect ratio as the video,
// but also fill the entire thumbnail strip with a whole number of thumbnails.
// Therefore the number of thumbnails of preferred width is rounded ("schoolbook rounding")
// to minimize the difference between video and thumbnail aspect ratios.
let videoAspectRatio = dataSource.videoAspectRatio
let preferredThumbnailWidth = floor(thumbnailHeight * videoAspectRatio.width / videoAspectRatio.height)
let thumbnailCount = UInt(round(thumbnailStripRect.width / preferredThumbnailWidth))
let thumbnailWidth = thumbnailStripRect.width / CGFloat(thumbnailCount)
for index in 0..<thumbnailCount {
// The timeline shows a series of thumbnails reflecting the video
// content at the point. It's ambiguous whether each thumbnail
// should reflect the content at the thumbnail's left edge or
// center. I've chosen to use the center.
let thumbnailAlpha = (Double(index) + 0.5) / Double(thumbnailCount - 1)
let thumbnailIndex = Int(round(thumbnailAlpha * Double(videoThumbnails.count))).clamp(0, videoThumbnails.count - 1)
let thumbnail: UIImage = videoThumbnails[thumbnailIndex]
let imageLayer = CALayer()
imageLayer.contents = thumbnail.cgImage
imageLayer.frame = CGRect(x: thumbnailStripRect.minX + CGFloat(index) * thumbnailWidth,
y: thumbnailStripRect.minY,
width: thumbnailWidth,
height: thumbnailHeight)
thumbnailLayerView.layer.addSublayer(imageLayer)
}
}
// There's a few reasons we use this approach to extending the hot area
// for this control.
//
// * It allows the frame/bounds of this view to coincide with its visible bounds.
// * It allows our layout to honor the root view's margins in a simple way.
// * It simplifies much of the geometry math done in this class.
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// Extend the hot area for this control.
let extendedBounds = bounds.inset(by: UIEdgeInsets(margin: -Constants.extraHotArea))
return extendedBounds.contains(point)
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: Constants.timelineHeight)
}
// We need to ensure that "trim rect" always reflects the
// trim state in a coherent way.
//
// * When the video is untrimmed, the trim rect should fully
// fully occupy the timeline.
// * When the video is trimmed down to the shortest valid
// snippet, the trim rect should be proportionally "small".
//
// Therefore we scale in _inner_ trim rect to reflect the
// ratio of the trimmed video length to the original,
// untrimmed video length.
private var innerTrimRect: CGRect {
guard let dataSource = dataSource else {
return bounds
}
let untrimmedDurationSeconds = CGFloat(dataSource.untrimmedDurationSeconds)
let startSeconds = CGFloat(dataSource.trimmedStartSeconds)
let endSeconds = CGFloat(dataSource.trimmedEndSeconds)
let maxTrimRect = convert(thumbnailStripRect, from: thumbnailLayerView)
var result = maxTrimRect
result.origin.x += startSeconds / untrimmedDurationSeconds * maxTrimRect.width
result.size.width *= (endSeconds - startSeconds) / untrimmedDurationSeconds
return result
}
private var cursorPosition: CGPoint {
guard let dataSource = dataSource else {
return bounds.center
}
let startSeconds = CGFloat(dataSource.trimmedStartSeconds)
let endSeconds = CGFloat(dataSource.trimmedEndSeconds)
let currentTimeSeconds = CGFloat(dataSource.currentTimeSeconds)
// alpha = 0 when playback is at start of trimmed clip.
// alpha = 1 when playback is at end of trimmed clip.
let playbackAlpha = currentTimeSeconds.inverseLerp(startSeconds, endSeconds, shouldClamp: true)
let innerTrimRect = innerTrimRect
let cursorPositionX = playbackAlpha.lerp(innerTrimRect.minX, innerTrimRect.maxX)
let cursorPositionXInTrimLayerView = trimLayerView.convert(CGPoint(x: cursorPositionX, y: 0), from: self).x
return CGPoint(x: cursorPositionXInTrimLayerView, y: trimLayerView.bounds.midY)
}
/**
* Returns the area within `thumbnailLayerView` that should be filled with video thumbnails.
*/
private var thumbnailStripRect: CGRect {
let insets = UIEdgeInsets(top: 0, left: trimHandleLeft.width, bottom: 0, right: trimHandleRight.width)
return thumbnailLayerView.bounds.inset(by: insets)
}
/**
* Returns left part of `thumbnailLayerView` that should be dimmed because it is outside of trim handles.
*/
private var thumbnailStripOverlayRectLeft: CGRect {
let adjustedInnerTrimRect = thumbnailLayerView.convert(innerTrimRect, from: self)
var result = thumbnailStripRect
result.size.width = adjustedInnerTrimRect.minX - result.minX
return result
}
/**
* Returns right part of `thumbnailLayerView` that should be dimmed because it is outside of trim handles.
*/
private var thumbnailStripOverlayRectRight: CGRect {
let adjustedInnerTrimRect = thumbnailLayerView.convert(innerTrimRect, from: self)
var result = thumbnailStripRect
result.size.width = result.maxX - adjustedInnerTrimRect.maxX
result.origin.x = adjustedInnerTrimRect.maxX
return result
}
private var leftTrimHandleCenter: CGPoint {
let point = CGPoint(x: innerTrimRect.minX - 0.5 * trimHandleLeft.width, y: bounds.midY)
return trimLayerView.convert(point, from: self)
}
private var rightTrimHandleCenter: CGPoint {
let point = CGPoint(x: innerTrimRect.maxX + 0.5 * trimHandleRight.width, y: bounds.midY)
return trimLayerView.convert(point, from: self)
}
private var isTrimmedOrBeingTrimmed: Bool {
switch mode {
case .trimmingStart, .trimmingEnd:
return true
default:
break
}
if let dataSource = dataSource {
return dataSource.isTrimmed
}
return false
}
func updateContents() {
thumbnailLayerView.updateContent()
trimLayerView.updateContent()
updateCursorPosition()
updateTimeBubble()
}
func updateCursorPosition() {
cursorView.center = cursorPosition
}
}
// MARK: - Gestures
extension VideoTimelineView {
private var outerTrimRect: CGRect {
let trimRectInset = UIEdgeInsets(top: 0, leading: -trimHandleLeft.width, bottom: 0, trailing: -trimHandleRight.width)
return innerTrimRect.inset(by: trimRectInset)
}
@objc
private func gestureDidChange(gesture: UIGestureRecognizer) {
if gesture.state == .began {
mode = modeForNewGesture(gesture)
}
guard mode != .none else {
return
}
switch gesture.state {
case .began:
switch mode {
case .trimmingStart, .trimmingEnd:
beginTrimming(withGesture: gesture)
case .scrubbing:
delegate?.videoTimelineViewWillBeginScrubbing(self)
applyGestureInProgress(gesture)
default:
break
}
updateContents()
case .changed:
applyGestureInProgress(gesture)
case .ended:
applyGestureInProgress(gesture)
completeGestureProcessing()
default:
completeGestureProcessing()
return
}
}
private func modeForNewGesture(_ gesture: UIGestureRecognizer) -> Mode {
guard let dataSource = dataSource else {
return .none
}
let location = gesture.location(in: self)
let outerTrimRect = outerTrimRect
let innerTrimRect = innerTrimRect
// Our gesture handling is permissive, trim gestures can start
// a little bit outside the visible "trim handles".
let couldBeTrimStart = (dataSource.canBeTrimmed &&
location.x >= (outerTrimRect.minX - Constants.extraHotArea) &&
location.x <= (innerTrimRect.minX + Constants.extraHotArea))
let couldBeTrimEnd = (dataSource.canBeTrimmed &&
location.x >= (innerTrimRect.maxX - Constants.extraHotArea) &&
location.x <= (outerTrimRect.maxX + Constants.extraHotArea))
let couldBeScrub = (location.x >= innerTrimRect.minX &&
location.x <= innerTrimRect.maxX)
// Prefer trimming to scrubbing.
if couldBeTrimStart && couldBeTrimEnd {
// Because our gesture handling is permissive,
// we need to disambiguate.
let startDistance = abs(location.x - outerTrimRect.minX)
let endDistance = abs(location.x - outerTrimRect.maxX)
if startDistance < endDistance {
return .trimmingStart
} else {
return .trimmingEnd
}
} else if couldBeTrimStart {
return .trimmingStart
} else if couldBeTrimEnd {
return .trimmingEnd
} else if couldBeScrub {
return .scrubbing
} else {
return .none
}
}
private func applyGestureInProgress(_ gesture: UIGestureRecognizer) {
guard let dataSource = dataSource, let delegate = delegate else {
return
}
let adjustedHorizontalPosition = gesture.location(in: trimLayerView).x - trimGestureLocationOffset
let thumbnailStripRect = thumbnailStripRect
// alpha = 0 when gesture is at start of untrimmed clip.
// alpha = 1 when gesture is at end of untrimmed clip.
let untrimmedAlpha = Double(adjustedHorizontalPosition.inverseLerp(thumbnailStripRect.minX, thumbnailStripRect.maxX, shouldClamp: true))
let startSeconds = dataSource.trimmedStartSeconds
let endSeconds = dataSource.trimmedEndSeconds
let untrimmedDurationSeconds = dataSource.untrimmedDurationSeconds
let untrimmedSeconds = untrimmedDurationSeconds * untrimmedAlpha
switch mode {
case .trimmingStart:
// Don't let users trim clip to less than the minimum duration.
let maxValue = max(0, endSeconds - VideoEditorModel.minimumDurationSeconds)
let seconds = min(maxValue, untrimmedSeconds)
delegate.videoTimelineView(self, didTrimBeginningTo: seconds)
case .trimmingEnd:
// Don't let users trim clip to less than the minimum duration.
let minValue = min(untrimmedDurationSeconds, startSeconds + VideoEditorModel.minimumDurationSeconds)
let seconds = max(minValue, untrimmedSeconds)
delegate.videoTimelineView(self, didTrimEndTo: seconds)
case .scrubbing:
// Clamp to the trimmed clip.
let seconds = untrimmedSeconds.clamp(startSeconds, endSeconds)
delegate.videoTimelineView(self, didScrubTo: seconds)
case .none:
owsFailDebug("Unexpected mode.")
}
}
private func completeGestureProcessing() {
let previousMode = mode
mode = .none
switch previousMode {
case .trimmingStart, .trimmingEnd:
endTrimming()
case .scrubbing:
delegate?.videoTimelineViewDidEndScrubbing(self)
default:
break
}
updateContents()
}
private func beginTrimming(withGesture gesture: UIGestureRecognizer) {
UIView.animate(withDuration: 0.2) {
self.isCursorHidden = true
}
let location = gesture.location(in: trimLayerView)
let thumbnailStripRect = thumbnailStripRect
if !thumbnailStripRect.contains(location) {
switch mode {
case .trimmingStart:
trimGestureLocationOffset = min(0, location.x - thumbnailStripRect.minX)
case .trimmingEnd:
trimGestureLocationOffset = max(0, location.x - thumbnailStripRect.maxX)
default:
owsFailDebug("Invalid mode. [\(mode)]")
}
}
delegate?.videoTimelineViewDidBeginTrimming(self)
}
private func endTrimming() {
UIView.animate(withDuration: 0.2) {
self.isCursorHidden = false
}
trimGestureLocationOffset = 0
delegate?.videoTimelineViewDidEndTrimming(self)
}
}
// MARK: - Time Bubble
extension VideoTimelineView {
private enum TimeBubbleAlignment {
case left
case center
case right
}
func updateTimeBubble() {
guard let dataSource = dataSource else {
hideTimeBubble(animated: false)
return
}
switch mode {
case .none:
hideTimeBubble(animated: true)
case .trimmingStart:
showTimeBubble(time: dataSource.trimmedStartSeconds, alignment: .left)
case .trimmingEnd:
showTimeBubble(time: dataSource.trimmedEndSeconds, alignment: .right)
case .scrubbing:
showTimeBubble(time: dataSource.currentTimeSeconds, alignment: .center)
}
}
private func showTimeBubble(time: TimeInterval, alignment: TimeBubbleAlignment) {
if timeBubbleView.superview == nil {
addSubview(timeBubbleView)
timeBubbleView.autoPinEdge(.bottom, to: .top, of: self, withOffset: -24)
}
var timeBubbleViewPositionConstraint: NSLayoutConstraint
if let existingConstraint = self.timeBubbleViewPositionConstraint {
timeBubbleViewPositionConstraint = existingConstraint
} else {
timeBubbleViewPositionConstraint =
NSLayoutConstraint(item: timeBubbleView, attribute: .centerX, relatedBy: .equal,
toItem: self, attribute: .left, multiplier: 1, constant: 0)
addConstraint(timeBubbleViewPositionConstraint)
self.timeBubbleViewPositionConstraint = timeBubbleViewPositionConstraint
}
timeBubbleViewPositionConstraint.constant = {
switch alignment {
case .left:
// Position strictly above left trim handle.
return convert(trimHandleLeft.center, from: trimLayerView).x
case .right:
// Position strictly above right trim handle.
return convert(trimHandleRight.center, from: trimLayerView).x
case .center:
// Position where current video playback is.
return convert(cursorView.center, from: trimLayerView).x
}}()
timeBubbleTextLabel.text = OWSFormat.localizedDurationString(from: round(time))
if timeBubbleView.alpha < 1 {
UIView.performWithoutAnimation {
self.setNeedsLayout()
self.layoutIfNeeded()
}
UIView.animate(withDuration: 0.2) {
self.timeBubbleView.alpha = 1
}
}
}
private func hideTimeBubble(animated: Bool = false) {
guard animated else {
timeBubbleView.alpha = 0
return
}
UIView.animate(withDuration: 0.2) {
self.timeBubbleView.alpha = 0
}
}
}
private class TrimHandleView: UIImageView {
enum Position {
case left
case right
}
let position: Position
private static func handleImage(forPosition position: Position, isHighlighted: Bool) -> UIImage? {
let imageName = isHighlighted ? "media-editor-video-trim-yellow" : "media-editor-video-trim-gray"
let image = UIImage(imageLiteralResourceName: imageName)
if position == .left {
return image.withHorizontallyFlippedOrientation()
}
return image
}
override var isHighlighted: Bool {
willSet {
if newValue && highlightedImage == nil {
highlightedImage = TrimHandleView.handleImage(forPosition: position, isHighlighted: true)
}
}
}
init(position: Position) {
self.position = position
super.init(image: TrimHandleView.handleImage(forPosition: position, isHighlighted: false))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private class TimelineCursorView: UIView {
override static var layerClass: AnyClass {
CAShapeLayer.self
}
private var shapeLayer: CAShapeLayer? {
return layer as? CAShapeLayer
}
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
shapeLayer?.shadowColor = UIColor.black.cgColor
shapeLayer?.shadowOffset = .zero
shapeLayer?.shadowRadius = 4
shapeLayer?.shadowOpacity = 0.25
shapeLayer?.fillColor = UIColor.white.cgColor
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
VideoTimelineView.Constants.cursorSize
}
override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
private func updatePath() {
shapeLayer?.path = UIBezierPath(roundedRect: bounds, cornerRadius: width * 0.5).cgPath
}
}