308 lines
11 KiB
Swift
308 lines
11 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import CoreMedia
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
import YYImage
|
|
|
|
class ViewOnceMessageViewController: OWSViewController {
|
|
|
|
typealias Content = TSViewOnceContent
|
|
|
|
// MARK: - Properties
|
|
|
|
private let content: Content
|
|
|
|
// MARK: - Initializers
|
|
|
|
init(content: Content) {
|
|
self.content = content
|
|
|
|
super.init()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class func tryToPresent(interaction: TSInteraction,
|
|
from fromViewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
ModalActivityIndicatorViewController.present(fromViewController: fromViewController,
|
|
canCancel: false) { (modal) in
|
|
DispatchQueue.main.async {
|
|
let content: Content? = loadContentForPresentation(interaction: interaction)
|
|
|
|
modal.dismiss(completion: {
|
|
guard let content = content else {
|
|
owsFailDebug("Could not present interaction")
|
|
// TODO: Show an alert.
|
|
return
|
|
}
|
|
|
|
let view = ViewOnceMessageViewController(content: content)
|
|
fromViewController.presentFullScreen(view, animated: true)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private class func loadContentForPresentation(interaction: TSInteraction) -> Content? {
|
|
guard let message = interaction as? TSMessage else {
|
|
return nil
|
|
}
|
|
return DependenciesBridge.shared.tsResourceViewOnceManager.prepareViewOnceContentForDisplay(message)
|
|
}
|
|
|
|
// MARK: - View Lifecycle
|
|
|
|
override public func loadView() {
|
|
self.view = UIView()
|
|
view.backgroundColor = UIColor.ows_black
|
|
|
|
let contentView = UIView()
|
|
view.addSubview(contentView)
|
|
contentView.autoPinWidthToSuperview()
|
|
contentView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
|
|
contentView.autoPin(toBottomLayoutGuideOf: self, withInset: 0)
|
|
|
|
let defaultMediaView = UIView()
|
|
defaultMediaView.backgroundColor = Theme.darkThemeWashColor
|
|
let mediaView = buildMediaView() ?? defaultMediaView
|
|
|
|
contentView.addSubview(mediaView)
|
|
mediaView.autoPinEdgesToSuperviewEdges()
|
|
|
|
let hMargin: CGFloat = 16
|
|
let vMargin: CGFloat = 20
|
|
let controlSize: CGFloat = 24
|
|
let controlSpacing: CGFloat = 20
|
|
let controlsWidth = controlSize * 2 + controlSpacing + hMargin * 2
|
|
let controlsHeight = controlSize + vMargin * 2
|
|
mediaView.autoSetDimension(.width, toSize: controlsWidth, relation: .greaterThanOrEqual)
|
|
mediaView.autoSetDimension(.height, toSize: controlsHeight, relation: .greaterThanOrEqual)
|
|
|
|
let dismissButton = OWSButton(imageName: Theme.iconName(.buttonX), tintColor: Theme.darkThemePrimaryColor) { [weak self] in
|
|
self?.dismissButtonPressed()
|
|
}
|
|
dismissButton.layer.shadowColor = Theme.darkThemeBackgroundColor.cgColor
|
|
dismissButton.layer.shadowOffset = .zero
|
|
dismissButton.layer.shadowOpacity = 0.7
|
|
dismissButton.layer.shadowRadius = 3.0
|
|
|
|
dismissButton.contentEdgeInsets = UIEdgeInsets(top: vMargin, leading: hMargin, bottom: vMargin, trailing: hMargin)
|
|
view.addSubview(dismissButton)
|
|
dismissButton.autoPinEdge(.leading, to: .leading, of: mediaView)
|
|
dismissButton.autoPinEdge(.top, to: .top, of: mediaView)
|
|
dismissButton.setShadow(opacity: 0.66)
|
|
|
|
setupDatabaseObservation()
|
|
}
|
|
|
|
private func buildMediaView() -> UIView? {
|
|
switch content.type {
|
|
case .loopingVideo:
|
|
guard let asset = try? content.loadAVAsset() else {
|
|
owsFailDebug("Could not load attachment.")
|
|
return nil
|
|
}
|
|
let video = LoopingVideo(asset: asset)
|
|
let view = LoopingVideoView()
|
|
view.contentMode = .scaleAspectFit
|
|
view.video = video
|
|
return view
|
|
case .animatedImage:
|
|
guard let image = try? content.loadYYImage() else {
|
|
owsFailDebug("Could not load attachment.")
|
|
return nil
|
|
}
|
|
guard image.size.width > 0,
|
|
image.size.height > 0 else {
|
|
owsFailDebug("Attachment has invalid size.")
|
|
return nil
|
|
}
|
|
let animatedImageView = YYAnimatedImageView()
|
|
// We need to specify a contentMode since the size of the image
|
|
// might not match the aspect ratio of the view.
|
|
animatedImageView.contentMode = .scaleAspectFit
|
|
// Use trilinear filters for better scaling quality at
|
|
// some performance cost.
|
|
animatedImageView.layer.minificationFilter = .trilinear
|
|
animatedImageView.layer.magnificationFilter = .trilinear
|
|
animatedImageView.layer.allowsEdgeAntialiasing = true
|
|
animatedImageView.image = image
|
|
return animatedImageView
|
|
case .stillImage:
|
|
guard let image = try? content.loadImage() else {
|
|
owsFailDebug("Could not load attachment.")
|
|
return nil
|
|
}
|
|
guard image.size.width > 0,
|
|
image.size.height > 0 else {
|
|
owsFailDebug("Attachment has invalid size.")
|
|
return nil
|
|
}
|
|
|
|
let imageView = UIImageView()
|
|
// We need to specify a contentMode since the size of the image
|
|
// might not match the aspect ratio of the view.
|
|
imageView.contentMode = .scaleAspectFit
|
|
// Use trilinear filters for better scaling quality at
|
|
// some performance cost.
|
|
imageView.layer.minificationFilter = .trilinear
|
|
imageView.layer.magnificationFilter = .trilinear
|
|
imageView.layer.allowsEdgeAntialiasing = true
|
|
imageView.image = image
|
|
return imageView
|
|
case .video:
|
|
let videoContainer = UIView()
|
|
|
|
guard let asset = try? content.loadAVAsset() else {
|
|
owsFailDebug("Could not load attachment.")
|
|
return nil
|
|
}
|
|
let player = VideoPlayer(avPlayer: .init(playerItem: .init(asset: asset)), shouldLoop: true)
|
|
self.videoPlayer = player
|
|
player.delegate = self
|
|
|
|
let playerView = VideoPlayerView()
|
|
playerView.player = player.avPlayer
|
|
|
|
videoContainer.addSubview(playerView)
|
|
playerView.autoPinEdgesToSuperviewEdges()
|
|
|
|
let label = UILabel()
|
|
label.textColor = Theme.darkThemePrimaryColor
|
|
label.font = UIFont.dynamicTypeBody.monospaced()
|
|
label.setShadow()
|
|
|
|
videoContainer.addSubview(label)
|
|
label.autoPinEdge(toSuperviewMargin: .top, withInset: 16)
|
|
label.autoPinEdge(toSuperviewMargin: .trailing, withInset: 16)
|
|
|
|
let formatter = DateComponentsFormatter()
|
|
formatter.unitsStyle = .positional
|
|
formatter.allowedUnits = [.minute, .second ]
|
|
formatter.zeroFormattingBehavior = [ .pad ]
|
|
|
|
let avPlayer = player.avPlayer
|
|
self.videoPlayerProgressObserver = avPlayer.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.1, preferredTimescale: 100), queue: nil) { _ in
|
|
|
|
guard let item = avPlayer.currentItem else {
|
|
owsFailDebug("item was unexpectedly nil")
|
|
label.text = "0:00"
|
|
return
|
|
}
|
|
|
|
let position = avPlayer.currentTime()
|
|
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")
|
|
label.text = "0:00"
|
|
return
|
|
}
|
|
|
|
label.text = remainingString
|
|
}
|
|
|
|
return videoContainer
|
|
}
|
|
}
|
|
|
|
// MARK: Video
|
|
|
|
var videoPlayerProgressObserver: Any?
|
|
var videoPlayer: VideoPlayer?
|
|
|
|
func setupDatabaseObservation() {
|
|
databaseStorage.appendDatabaseChangeDelegate(self)
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(applicationWillEnterForeground),
|
|
name: .OWSApplicationWillEnterForeground,
|
|
object: nil)
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
self.videoPlayer?.play()
|
|
}
|
|
|
|
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
return .lightContent
|
|
}
|
|
|
|
// MARK: - Video
|
|
|
|
// Once open, this view only dismisses if the message is deleted
|
|
// (e.g. by per-conversation expiration).
|
|
private func dismissIfRemoved() {
|
|
AssertIsOnMainThread()
|
|
|
|
let shouldDismiss: Bool = databaseStorage.read { transaction in
|
|
let uniqueId = self.content.messageId
|
|
guard TSInteraction.anyFetch(uniqueId: uniqueId, transaction: transaction) != nil else {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
if shouldDismiss {
|
|
self.dismiss(animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@objc
|
|
private func applicationWillEnterForeground() throws {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
dismissIfRemoved()
|
|
}
|
|
|
|
@objc
|
|
private func dismissButtonPressed() {
|
|
AssertIsOnMainThread()
|
|
|
|
dismiss(animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ViewOnceMessageViewController: DatabaseChangeDelegate {
|
|
|
|
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
|
|
AssertIsOnMainThread()
|
|
|
|
dismissIfRemoved()
|
|
}
|
|
|
|
func databaseChangesDidUpdateExternally() {
|
|
AssertIsOnMainThread()
|
|
|
|
dismissIfRemoved()
|
|
}
|
|
|
|
func databaseChangesDidReset() {
|
|
AssertIsOnMainThread()
|
|
|
|
dismissIfRemoved()
|
|
}
|
|
}
|
|
|
|
extension ViewOnceMessageViewController: VideoPlayerDelegate {
|
|
func videoPlayerDidPlayToCompletion(_ videoPlayer: VideoPlayer) {
|
|
// no-op
|
|
}
|
|
}
|