Signal-iOS/SignalUI/Views/VideoPlayerView.swift
Evan Hahn 370ff654e7
Change license to AGPL
Change license to AGPL

This commit:

- Updates the `LICENSE` file

- Start every file with something like:

      // Copyright YEAR_FIRST_PUBLISHED Signal Messenger, LLC
      // SPDX-License-Identifier: AGPL-3.0-only

---

First, I removed existing license headers with this Ruby 3.1.2 script:

    require 'set'

    EXTENSIONS_TO_CHECK = Set['.h', '.hpp', '.cpp', '.m', '.mm', '.pch', '.swift']

    same = 0
    different = 0

    all_files = `git ls-files`.lines.map { |line| line.strip }
    all_files.each do |relative_path|
      if relative_path == 'Pods'
        next
      end

      unless EXTENSIONS_TO_CHECK.include? File.extname(relative_path)
        next
      end

      path = File.expand_path(relative_path)

      contents = File.read(path)
      new_contents = contents.sub(/\/\/\n\/\/  Copyright .*\n\/\/\n\n/, '')

      if contents == new_contents
        same += 1
      else
        different += 1
      end

      File.write(path, new_contents)
    end

    puts "updated #{different} file(s), left #{same} untouched"

I'm sure this script could be improved, but it worked well enough.

Then, I created `Scripts/lint/lint-license-headers` and ran it to auto-
fix a lot of files. This changed the mode of some files, but I think
that's actually desirable. For example,
`SignalServiceKit/src/Util/AppContext.m` previously had a mode of
`0755/-rwxr-xr-x`, and it's now `0644/-rw-r--r--`.

Then I fixed some stragglers and updated the precommit script.

See [a similar change in the Desktop app][0].

[0]: 8bfaf598af
2022-10-13 08:25:37 -05:00

402 lines
12 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import AVFoundation
@objc
public protocol VideoPlayerViewDelegate {
func videoPlayerViewStatusDidChange(_ view: VideoPlayerView)
func videoPlayerViewPlaybackTimeDidChange(_ view: VideoPlayerView)
}
// MARK: -
@objc
public class VideoPlayerView: UIView {
// MARK: - Properties
@objc
public weak var delegate: VideoPlayerViewDelegate?
@objc
public var videoPlayer: OWSVideoPlayer? {
didSet {
player = videoPlayer?.avPlayer
}
}
@objc
override public var contentMode: UIView.ContentMode {
didSet {
switch contentMode {
case .scaleAspectFill: playerLayer.videoGravity = .resizeAspectFill
case .scaleToFill: playerLayer.videoGravity = .resize
case .scaleAspectFit: playerLayer.videoGravity = .resizeAspect
default: playerLayer.videoGravity = .resizeAspect
}
}
}
@objc
public var player: AVPlayer? {
get {
AssertIsOnMainThread()
return playerLayer.player
}
set {
AssertIsOnMainThread()
removeKVO(player: playerLayer.player)
playerLayer.player = newValue
addKVO(player: playerLayer.player)
invalidateIntrinsicContentSize()
}
}
var playerLayer: AVPlayerLayer {
return layer as! AVPlayerLayer
}
// Override UIView property
override public static var layerClass: AnyClass {
return AVPlayerLayer.self
}
public var isPlaying: Bool {
guard let player = player else {
return false
}
return player.timeControlStatus == .playing
}
@objc
public var currentTimeSeconds: Double {
guard let videoPlayer = videoPlayer else {
return 0
}
return videoPlayer.currentTimeSeconds
}
// MARK: - Initializers
public init() {
super.init(frame: .zero)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
removeKVO(player: player)
}
// MARK: -
override public var intrinsicContentSize: CGSize {
guard let player = self.player,
let playerItem = player.currentItem else {
return CGSize(square: UIView.noIntrinsicMetric)
}
return playerItem.asset.tracks(withMediaType: .video)
.map { (assetTrack: AVAssetTrack) -> CGSize in
assetTrack.naturalSize.applying(assetTrack.preferredTransform).abs
}.reduce(.zero) {
CGSizeMax($0, $1)
}
}
// MARK: - KVO
private var playerObservers = [NSKeyValueObservation]()
private var periodicTimeObserver: Any?
private func addKVO(player: AVPlayer?) {
guard let player = player else {
return
}
// Observe status changes: anything that might affect "isPlaying".
let changeHandler = { [weak self] (_: AVPlayer, _: Any) in
guard let self = self else { return }
self.delegate?.videoPlayerViewStatusDidChange(self)
}
playerObservers = [
player.observe(\AVPlayer.status, options: [.new, .initial], changeHandler: changeHandler),
player.observe(\AVPlayer.timeControlStatus, options: [.new, .initial], changeHandler: changeHandler),
player.observe(\AVPlayer.rate, options: [.new, .initial], changeHandler: changeHandler)
]
// Observe playback progress.
let interval = CMTime(seconds: 0.01, preferredTimescale: 1000)
periodicTimeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main) { [weak self] _ in
guard let self = self else { return }
self.delegate?.videoPlayerViewPlaybackTimeDidChange(self)
}
}
private func removeKVO(player: AVPlayer?) {
for playerObserver in playerObservers {
playerObserver.invalidate()
}
playerObservers = []
guard let player = player else {
return
}
if let periodicTimeObserver = periodicTimeObserver {
player.removeTimeObserver(periodicTimeObserver)
}
periodicTimeObserver = nil
}
// MARK: - Playback
@objc
public func pause() {
guard let videoPlayer = videoPlayer else {
owsFailDebug("Missing videoPlayer.")
return
}
videoPlayer.pause()
}
@objc
public func play() {
guard let videoPlayer = videoPlayer else {
owsFailDebug("Missing videoPlayer.")
return
}
videoPlayer.play()
}
@objc
public func stop() {
guard let videoPlayer = videoPlayer else {
owsFailDebug("Missing videoPlayer.")
return
}
videoPlayer.stop()
}
@objc(seekToTime:)
public func seek(to time: CMTime) {
guard let videoPlayer = videoPlayer else {
owsFailDebug("Missing videoPlayer.")
return
}
videoPlayer.seek(to: time)
}
}
// MARK: -
@objc
public protocol PlayerProgressBarDelegate {
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime)
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool)
}
// Allows the user to tap anywhere on the slider to set it's position,
// without first having to grab the thumb.
class TrackingSlider: UISlider {
override init(frame: CGRect) {
super.init(frame: frame)
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
return true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@objc
public class PlayerProgressBar: UIView {
@objc
public weak var delegate: PlayerProgressBarDelegate?
private lazy var formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.minute, .second ]
formatter.zeroFormattingBehavior = [ .pad ]
return formatter
}()
// MARK: Subviews
private let positionLabel = UILabel()
private let remainingLabel = UILabel()
private let slider = TrackingSlider()
private let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
weak private var progressObserver: AnyObject?
private let kPreferredTimeScale: CMTimeScale = 100
@objc
public var player: AVPlayer? {
didSet {
guard let item = player?.currentItem else {
owsFailDebug("No player item")
return
}
slider.minimumValue = 0
let duration: CMTime = item.asset.duration
slider.maximumValue = Float(CMTimeGetSeconds(duration))
// OPTIMIZE We need a high frequency observer for smooth slider updates,
// but could use a much less frequent observer for label updates
progressObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: kPreferredTimeScale), queue: nil, using: { [weak self] (_) in
self?.updateState()
}) as AnyObject
updateState()
}
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public init(frame: CGRect) {
super.init(frame: frame)
// Background
backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
if !UIAccessibility.isReduceTransparencyEnabled {
addSubview(blurEffectView)
blurEffectView.autoPinEdgesToSuperviewEdges()
}
// Configure controls
let kLabelFont = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: UIFont.Weight.regular)
positionLabel.font = kLabelFont
remainingLabel.font = kLabelFont
slider.semanticContentAttribute = .playback
// We use a smaller thumb for the progress slider.
slider.setThumbImage(#imageLiteral(resourceName: "sliderProgressThumb"), for: .normal)
slider.maximumTrackTintColor = UIColor.ows_black
slider.minimumTrackTintColor = UIColor.ows_black
slider.addTarget(self, action: #selector(handleSliderTouchDown), for: .touchDown)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpInside)
slider.addTarget(self, action: #selector(handleSliderTouchUp), for: .touchUpOutside)
slider.addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged)
// Panning is a no-op. We just absorb pan gesture's originating in the video controls
// from propagating so we don't inadvertently change pages while trying to scrub in
// the MediaPageView.
let panAbsorber = UIPanGestureRecognizer(target: self, action: nil)
self.addGestureRecognizer(panAbsorber)
// Layout Subviews
addSubview(positionLabel)
addSubview(remainingLabel)
addSubview(slider)
positionLabel.autoPinEdge(toSuperviewMargin: .left)
positionLabel.autoVCenterInSuperview()
let kSliderMargin: CGFloat = 8
slider.autoPinEdge(.left, to: .right, of: positionLabel, withOffset: kSliderMargin)
slider.autoVCenterInSuperview()
remainingLabel.autoPinEdge(.left, to: .right, of: slider, withOffset: kSliderMargin)
remainingLabel.autoPinEdge(toSuperviewMargin: .right)
remainingLabel.autoVCenterInSuperview()
}
// MARK: Gesture handling
var wasPlayingWhenScrubbingStarted: Bool = false
@objc
private func handleSliderTouchDown(_ slider: UISlider) {
guard let player = self.player else {
owsFailDebug("player was nil")
return
}
self.wasPlayingWhenScrubbingStarted = (player.rate != 0) && (player.error == nil)
self.delegate?.playerProgressBarDidStartScrubbing(self)
}
@objc
private func handleSliderTouchUp(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, didFinishScrubbingAtTime: sliderTime, shouldResumePlayback: wasPlayingWhenScrubbingStarted)
}
@objc
private func handleSliderValueChanged(_ slider: UISlider) {
let sliderTime = time(slider: slider)
self.delegate?.playerProgressBar(self, scrubbedToTime: sliderTime)
}
// MARK: Render cycle
private func updateState() {
guard let player = player else {
owsFailDebug("player isn't set.")
return
}
guard let item = player.currentItem else {
owsFailDebug("player has no item.")
return
}
let position = player.currentTime()
let positionSeconds: Float64 = CMTimeGetSeconds(position)
positionLabel.text = formatter.string(from: positionSeconds)
let duration: CMTime = item.asset.duration
let remainingTime = duration - position
let remainingSeconds = CMTimeGetSeconds(remainingTime)
guard let remainingString = formatter.string(from: remainingSeconds) else {
owsFailDebug("unable to format time remaining")
remainingLabel.text = "0:00"
return
}
// show remaining time as negative
remainingLabel.text = "-\(remainingString)"
slider.setValue(Float(positionSeconds), animated: false)
}
// MARK: Util
private func time(slider: UISlider) -> CMTime {
let seconds: Double = Double(slider.value)
return CMTime(seconds: seconds, preferredTimescale: kPreferredTimeScale)
}
}