// // MediaView.swift // MediaView // // Created by Cirno MainasuK on 2021-8-23. // Copyright © 2021 Twidere. All rights reserved. // import AVKit import UIKit import Combine public final class MediaView: UIView { var _disposeBag = Set() public static let cornerRadius: CGFloat = 0 public static let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.zeroFormattingBehavior = .pad formatter.allowedUnits = [.minute, .second] return formatter }() public let container = TouchBlockingView() public private(set) var configuration: Configuration? private(set) lazy var blurhashImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.isUserInteractionEnabled = false imageView.layer.masksToBounds = true // clip overflow return imageView }() private(set) lazy var imageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.isUserInteractionEnabled = false imageView.layer.masksToBounds = true // clip overflow return imageView }() private(set) lazy var playerViewController: AVPlayerViewController = { let playerViewController = AVPlayerViewController() playerViewController.view.layer.masksToBounds = true playerViewController.view.isUserInteractionEnabled = false return playerViewController }() private var playerLooper: AVPlayerLooper? private(set) lazy var indicatorBlurEffectView: UIVisualEffectView = { let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) effectView.layer.masksToBounds = true effectView.layer.cornerCurve = .continuous effectView.layer.cornerRadius = 4 return effectView }() private(set) lazy var indicatorVibrancyEffectView = UIVisualEffectView( effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)) ) // private(set) lazy var playerIndicatorLabel: UILabel = { // let label = UILabel() // label.font = .preferredFont(forTextStyle: .caption1) // label.textColor = .secondaryLabel // return label // }() public override init(frame: CGRect) { super.init(frame: frame) _init() } public required init?(coder: NSCoder) { super.init(coder: coder) _init() } } extension MediaView { @MainActor public func thumbnail() async -> UIImage? { return imageView.image } public func thumbnail() -> UIImage? { return imageView.image } } extension MediaView { private func _init() { // lazy load content later } public func setup(configuration: Configuration) { self.configuration = configuration setupContainerViewHierarchy() switch configuration.info { case .image(let info): configure(image: info) case .gif(let info): configure(gif: info) case .video(let info): configure(video: info) } if let blurhash = configuration.blurhash { configure(blurhash: blurhash) configuration.$blurhashImage .receive(on: DispatchQueue.main) .assign(to: \.image, on: blurhashImageView) .store(in: &_disposeBag) blurhashImageView.alpha = configuration.isReveal ? 0 : 1 } configuration.$isReveal .dropFirst() .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] isReveal in guard let self = self else { return } let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) animator.addAnimations { self.blurhashImageView.alpha = isReveal ? 0 : 1 } animator.startAnimation() } .store(in: &_disposeBag) } private func configure(image info: Configuration.ImageInfo) { imageView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(imageView) NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: container.topAnchor), imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) let placeholder = UIImage.placeholder(color: .systemGray6) guard let urlString = info.assetURL, let url = URL(string: urlString) else { imageView.image = placeholder return } imageView.af.setImage( withURL: url, placeholderImage: placeholder ) } private func configure(gif info: Configuration.VideoInfo) { // use view controller as View here playerViewController.view.translatesAutoresizingMaskIntoConstraints = false container.addSubview(playerViewController.view) NSLayoutConstraint.activate([ playerViewController.view.topAnchor.constraint(equalTo: container.topAnchor), playerViewController.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), playerViewController.view.trailingAnchor.constraint(equalTo: container.trailingAnchor), playerViewController.view.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) assert(playerViewController.contentOverlayView != nil) if let contentOverlayView = playerViewController.contentOverlayView { indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false contentOverlayView.addSubview(indicatorBlurEffectView) NSLayoutConstraint.activate([ contentOverlayView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), contentOverlayView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), ]) setupIndicatorViewHierarchy() } // playerIndicatorLabel.attributedText = NSAttributedString(AttributedString("GIF")) guard let player = setupGIFPlayer(info: info) else { return } setupPlayerLooper(player: player) playerViewController.player = player playerViewController.showsPlaybackControls = false // auto play for GIF player.play() } private func configure(video info: Configuration.VideoInfo) { let imageInfo = Configuration.ImageInfo( aspectRadio: info.aspectRadio, assetURL: info.previewURL ) configure(image: imageInfo) indicatorBlurEffectView.translatesAutoresizingMaskIntoConstraints = false imageView.addSubview(indicatorBlurEffectView) NSLayoutConstraint.activate([ imageView.trailingAnchor.constraint(equalTo: indicatorBlurEffectView.trailingAnchor, constant: 11), imageView.bottomAnchor.constraint(equalTo: indicatorBlurEffectView.bottomAnchor, constant: 8), ]) setupIndicatorViewHierarchy() // playerIndicatorLabel.attributedText = { // let imageAttachment = NSTextAttachment(image: UIImage(systemName: "play.fill")!) // let imageAttributedString = AttributedString(NSAttributedString(attachment: imageAttachment)) // let duration: String = { // guard let durationMS = info.durationMS else { return "" } // let timeInterval = TimeInterval(durationMS / 1000) // guard timeInterval > 0 else { return "" } // guard let text = MediaView.durationFormatter.string(from: timeInterval) else { return "" } // return " \(text)" // }() // let textAttributedString = AttributedString("\(duration)") // var attributedString = imageAttributedString + textAttributedString // attributedString.foregroundColor = .secondaryLabel // return NSAttributedString(attributedString) // }() } private func configure(blurhash: String) { blurhashImageView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(blurhashImageView) NSLayoutConstraint.activate([ blurhashImageView.topAnchor.constraint(equalTo: container.topAnchor), blurhashImageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), blurhashImageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), blurhashImageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) blurhashImageView.backgroundColor = .systemGray } public func prepareForReuse() { _disposeBag.removeAll() // reset appearance alpha = 1 // reset image imageView.removeFromSuperview() imageView.removeConstraints(imageView.constraints) imageView.af.cancelImageRequest() imageView.image = nil // reset player playerViewController.view.removeFromSuperview() playerViewController.contentOverlayView.flatMap { view in view.removeConstraints(view.constraints) } playerViewController.player?.pause() playerViewController.player = nil playerLooper = nil // blurhash blurhashImageView.removeFromSuperview() blurhashImageView.removeConstraints(blurhashImageView.constraints) blurhashImageView.image = nil // reset indicator indicatorBlurEffectView.removeFromSuperview() // reset container container.removeFromSuperview() container.removeConstraints(container.constraints) // reset configuration configuration = nil } } extension MediaView { private func setupGIFPlayer(info: Configuration.VideoInfo) -> AVPlayer? { guard let urlString = info.assetURL, let url = URL(string: urlString) else { return nil } let playerItem = AVPlayerItem(url: url) let player = AVQueuePlayer(playerItem: playerItem) player.isMuted = true return player } private func setupPlayerLooper(player: AVPlayer) { guard let queuePlayer = player as? AVQueuePlayer else { return } guard let templateItem = queuePlayer.items().first else { return } playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) } private func setupContainerViewHierarchy() { guard container.superview == nil else { return } container.translatesAutoresizingMaskIntoConstraints = false addSubview(container) NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), container.leadingAnchor.constraint(equalTo: leadingAnchor), container.trailingAnchor.constraint(equalTo: trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } private func setupIndicatorViewHierarchy() { // let blurEffectView = indicatorBlurEffectView // let vibrancyEffectView = indicatorVibrancyEffectView // // if vibrancyEffectView.superview == nil { // vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false // blurEffectView.contentView.addSubview(vibrancyEffectView) // NSLayoutConstraint.activate([ // vibrancyEffectView.topAnchor.constraint(equalTo: blurEffectView.contentView.topAnchor), // vibrancyEffectView.leadingAnchor.constraint(equalTo: blurEffectView.contentView.leadingAnchor), // vibrancyEffectView.trailingAnchor.constraint(equalTo: blurEffectView.contentView.trailingAnchor), // vibrancyEffectView.bottomAnchor.constraint(equalTo: blurEffectView.contentView.bottomAnchor), // ]) // } // // if playerIndicatorLabel.superview == nil { // playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false // vibrancyEffectView.contentView.addSubview(playerIndicatorLabel) // NSLayoutConstraint.activate([ // playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor), // playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3), // vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3), // playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor), // ]) // } } }