mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2024-12-14 18:05:50 +01:00
6153839157
* 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>
352 lines
14 KiB
Swift
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),
|
|
])
|
|
}
|
|
}
|
|
}
|