1
0
mirror of https://github.com/mastodon/mastodon-ios.git synced 2024-12-14 18:05:50 +01:00
mastodon-app-ufficiale-ipho.../MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift
CMK 6153839157
Release v1.3.0 (#347)
* New translations app.json (Thai)

* New translations app.json (Spanish)

* New translations Localizable.stringsdict (Spanish)

* New translations app.json (Thai)

* New translations app.json (Thai)

* feat: adapt the app to async & await. Update timeline UI

* fix: update the Xcode version to fix the CI failure

* fix: remove unavailable framework import

* fix: project dependency issue

* feat: add content warning for post spoiler

* feat: add content warning for post media

* chore: update version to 1.3.0 (92)

* New translations app.json (French)

* New translations Intents.strings (French)

* New translations app.json (Thai)

* feat: update report flow

* feat: update setting scene UI

* feat: update status content warning UI

* feat: add notification gap fetcher

* chore: update version to 1.3.0 (93)

* feat: add video player for audio/video kind media

* chore: update version to 1.3.0 (94)

* fix: text strip wrong color in the Dark Mode issue

* chore: remove spoiler toggle animation for table cell

* fix: add missing shadow for compose publish button

* fix: add missing margin for timeline with horizontal regular size class

* fix: profile segmented controls missing margin issue

* fix: the profile segmented control use wrong selection tint color under force light UI style issue

* fix: add notification count clear logic back

* fix: add missing home timeline bottom fetcher

* fix: [WIP] add suggestion account scene back

* New translations app.json (Kabyle)

* New translations ios-infoPlist.json (Kabyle)

* New translations Localizable.stringsdict (Kabyle)

* New translations Intents.strings (Kabyle)

* New translations Intents.stringsdict (Kabyle)

* feat: make the home timeline readable for VoiceOver

* chore: update version to 1.3.0 (95)

* New translations app.json (French)

* New translations Intents.strings (French)

* New translations app.json (Kabyle)

* New translations ios-infoPlist.json (Kabyle)

* New translations Localizable.stringsdict (Kabyle)

* New translations Intents.strings (Kabyle)

* New translations Intents.stringsdict (Kabyle)

* New translations Localizable.stringsdict (French)

* New translations app.json (Kabyle)

* New translations app.json (French)

* chore: update action toolbar icons

* fix: instal state missing issue

* fix: follow push notification deep-link not works issue

* fix: foreground notification not trigger tab bell icon update issue

* feat: add notification timeline fetcher

* feat: add content warning toggle button

* chore: update version to 1.3.0 (96)

* New translations app.json (Thai)

* New translations app.json (Russian)

* New translations app.json (Kurmanji (Kurdish))

* New translations app.json (Scottish Gaelic)

* New translations app.json (Welsh)

* New translations app.json (Hindi)

* New translations app.json (Spanish, Argentina)

* New translations app.json (Indonesian)

* New translations app.json (Portuguese, Brazilian)

* New translations app.json (English)

* New translations app.json (Chinese Traditional)

* New translations app.json (Chinese Simplified)

* New translations app.json (Swedish)

* New translations app.json (Portuguese)

* New translations app.json (Dutch)

* New translations app.json (Korean)

* New translations app.json (Japanese)

* New translations app.json (Basque)

* New translations app.json (German)

* New translations app.json (Danish)

* New translations app.json (Catalan)

* New translations app.json (Arabic)

* New translations app.json (Spanish)

* New translations app.json (Romanian)

* New translations app.json (Kabyle)

* New translations app.json (French)

* New translations app.json (Swedish, Finland)

* New translations app.json (Spanish, Argentina)

* New translations app.json (Kurmanji (Kurdish))

* fix: notification i18n word typo

* New translations app.json (Thai)

* New translations app.json (Swedish)

* New translations Localizable.stringsdict (Swedish)

* New translations app.json (Swedish, Finland)

* New translations app.json (Kurmanji (Kurdish))

* New translations app.json (Scottish Gaelic)

* New translations app.json (Welsh)

* New translations app.json (Hindi)

* New translations app.json (Indonesian)

* New translations app.json (Portuguese, Brazilian)

* New translations app.json (English)

* New translations app.json (Chinese Traditional)

* New translations app.json (Chinese Simplified)

* New translations app.json (Russian)

* New translations app.json (Portuguese)

* New translations app.json (Dutch)

* New translations app.json (Korean)

* New translations app.json (Japanese)

* New translations app.json (Basque)

* New translations app.json (German)

* New translations app.json (Danish)

* New translations app.json (Catalan)

* New translations app.json (Arabic)

* New translations app.json (Spanish)

* New translations app.json (Romanian)

* New translations app.json (Kabyle)

* New translations app.json (French)

* New translations Intents.strings (Swedish)

* New translations app.json (Swedish)

* New translations Localizable.stringsdict (Japanese)

* New translations app.json (Thai)

* New translations app.json (Thai)

* New translations Localizable.stringsdict (Swedish)

* New translations app.json (Kabyle)

* New translations ios-infoPlist.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (French)

* New translations app.json (French)

* feat: restore scroll-to-top tap gesture for TabBar

* feat: add cell height cache for user timeline

* feat: display no results when profile field empty

* New translations app.json (Chinese Traditional)

* New translations app.json (Chinese Traditional)

* New translations Intents.strings (Japanese)

* feat: make status detail accessible

* chore: restore the appearance settings

* chore: update version to 1.3.0 (97)

* New translations app.json (Kabyle)

* New translations Intents.strings (Japanese)

* New translations app.json (Swedish)

* New translations app.json (Basque)

* New translations app.json (Basque)

* chore: add a11y hint for profile dashboard

* feat: add media interaction for notification timeline

* New translations app.json (Chinese Simplified)

* New translations app.json (Chinese Simplified)

* chore: update i18n strings

* fix: setting switch use wrong tint color issue

* chore: restore RTL layout for post content

* chore: update profile relationship button UI

* chore: update color panel

* fix: post reblog header may display empty reblogger name issue

* fix: wrong reply header redirect logic issue

* feat: restore post filter supports

* chore: update version to 1.3.0 (98)

* chore: update post content sensitive style

* fix: blurhash image not display during image loading issue

* chore: update version to 1.3.0 (99)

* feat: restore user recommend scene

* chore: update badge tint color

* feat: restore keyboard shortcut supports

* chore: update version to 1.3.0 (100)

* fix: relationship background use wrong color when force dark style

* fix: player button icon not reset issue

* chore: update version to 1.3.0 (101)

* fix: profile relationship button fill the width on iPad issue

* fix: inputAssistantItem duplicate setup issue

* chore: update textView minimum height from 88 to 64

* chore: update version to 1.3.0 (102)

* chore: update status timeline margin

* chore: update sidebar background color

* fix: split view column state after size class transition not stable issue

* chore: update notification timeline margin

* chore: update profile header and segmented bar margin

* fix: profile segmented bar use wrong tint color when force Dark Mode issue

* chore: update horizontal compact mode notification timeline margin looks like

* chore: update version to 1.3.0 (103)

* feat: dismiss image preview when tap empty area

* chore: update version to 1.3.0 (104)

* New translations app.json (Italian)

* New translations ios-infoPlist.json (Italian)

* New translations Localizable.stringsdict (Italian)

* New translations Intents.strings (Italian)

* New translations Intents.stringsdict (Italian)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Japanese)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Spanish)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Kabyle)

* New translations ios-infoPlist.json (Kabyle)

* New translations Localizable.stringsdict (Kabyle)

* New translations Localizable.stringsdict (Kabyle)

* New translations Intents.strings (Kabyle)

* New translations app.json (Kabyle)

* New translations Intents.strings (Kabyle)

* New translations Intents.stringsdict (Kabyle)

* New translations app.json (Kabyle)

* New translations app.json (Scottish Gaelic)

* New translations app.json (Scottish Gaelic)

* New translations app.json (Thai)

* New translations app.json (Thai)

* feat: add UITests for snapshots

* feat: add snapshot UITest and document

* New translations app.json (Thai)

* feat: add notification snapshot

* chore: add domain and update guide for the snapshot UITest

* chore: use the first photo for compose snapshot

* New translations app.json (Thai)

* New translations app.json (German)

* New translations app.json (German)

* chore: update settings scene UI

* chore: update i18n for open link words

* chore: update i18n resources

* fix: share extension not accept plaintext content issue. resolve #335

* chore: update version to 1.3.0 (105)

* New translations app.json (Japanese)

* New translations app.json (Japanese)

* New translations app.json (Japanese)

* feat: add onion domain ATS exception rule. resolve #338

* chore: update app version footer and i18n strings

* chore: update version to 1.3.0 (106)

* chore: update version to 1.3.0 (108)

* Handle onboarding authentication errors in /api/v1/instance

* New translations app.json (Kurmanji (Kurdish))

* New translations app.json (Kurmanji (Kurdish))

* chore: update Xcode schemes index

* chore: update the snapshot documents and UITests

* chore: update i18n resources. resolve #343

* chore: retain the API model semantic

* fix: force LTR for some text fields. #318

* fix: textView break IME input issue. resolve #342

* chore: update version to 1.3.0 (109)

* chore: update README

* chore: fix typo

* chore: add bug report template and contributing document

Co-authored-by: Eugen Rochko <eugen@zeonfederated.com>
Co-authored-by: Zac West <zacwest@gmail.com>
2022-03-29 11:51:14 +02:00

352 lines
14 KiB
Swift

//
// MediaView.swift
// MediaView
//
// Created by Cirno MainasuK on 2021-8-23.
// Copyright © 2021 Twidere. All rights reserved.
//
import AVKit
import UIKit
import Combine
import AlamofireImage
public final class MediaView: UIView {
var _disposeBag = Set<AnyCancellable>()
public static let cornerRadius: CGFloat = 0
public static let durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .pad
formatter.allowedUnits = [.minute, .second]
return formatter
}()
public static let placeholderImage = UIImage.placeholder(color: .systemGray6)
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
playerViewController.videoGravity = .resizeAspectFill
playerViewController.updatesNowPlayingInfoCenter = false
return playerViewController
}()
private var playerLooper: AVPlayerLooper?
private(set) lazy var playbackImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(systemName: "play.circle.fill")
imageView.tintColor = .white
return imageView
}()
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 ?? configuration?.previewImage
}
public func thumbnail() -> UIImage? {
return imageView.image ?? configuration?.previewImage
}
}
extension MediaView {
private func _init() {
// lazy load content later
isAccessibilityElement = true
}
public func setup(configuration: Configuration) {
self.configuration = configuration
setupContainerViewHierarchy()
switch configuration.info {
case .image(let info):
layoutImage()
bindImage(configuration: configuration, info: info)
accessibilityLabel = "Show image" // TODO: i18n
case .gif(let info):
layoutGIF()
bindGIF(configuration: configuration, info: info)
accessibilityLabel = "Show GIF" // TODO: i18n
case .video(let info):
layoutVideo()
bindVideo(configuration: configuration, info: info)
accessibilityLabel = "Show video player" // TODO: i18n
}
accessibilityHint = "Tap then hold to show menu" // TODO: i18n
layoutBlurhash()
bindBlurhash(configuration: configuration)
}
private func layoutImage() {
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),
])
}
private func bindImage(configuration: Configuration, info: Configuration.ImageInfo) {
Publishers.CombineLatest3(
configuration.$isReveal,
configuration.$previewImage,
configuration.$blurhashImage
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isReveal, previewImage, blurhashImage in
guard let self = self else { return }
let image = isReveal ?
(previewImage ?? blurhashImage ?? MediaView.placeholderImage) :
(blurhashImage ?? MediaView.placeholderImage)
self.imageView.image = image
}
.store(in: &configuration.disposeBag)
}
private func layoutGIF() {
// 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),
])
setupIndicatorViewHierarchy()
playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF")
}
private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) {
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 layoutVideo() {
layoutImage()
playbackImageView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(playbackImageView)
NSLayoutConstraint.activate([
playbackImageView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
playbackImageView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
playbackImageView.widthAnchor.constraint(equalToConstant: 88).priority(.required - 1),
playbackImageView.heightAnchor.constraint(equalToConstant: 88).priority(.required - 1),
])
}
private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) {
let imageInfo = Configuration.ImageInfo(
aspectRadio: info.aspectRadio,
assetURL: info.previewURL
)
bindImage(configuration: configuration, info: imageInfo)
}
private func layoutBlurhash() {
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),
])
}
private func bindBlurhash(configuration: Configuration) {
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)
}
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
playbackImageView.removeFromSuperview()
// 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
assert(playerViewController.contentOverlayView != nil)
if let contentOverlayView = playerViewController.contentOverlayView {
blurEffectView.translatesAutoresizingMaskIntoConstraints = false
contentOverlayView.addSubview(indicatorBlurEffectView)
NSLayoutConstraint.activate([
contentOverlayView.trailingAnchor.constraint(equalTo: blurEffectView.trailingAnchor, constant: 16),
contentOverlayView.bottomAnchor.constraint(equalTo: blurEffectView.bottomAnchor, constant: 8),
])
}
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),
])
}
}
}