mastodon-app-ufficiale-ipho.../MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift

334 lines
11 KiB
Swift

//
// AttachmentViewModel.swift
//
//
// Created by MainasuK on 2021/11/19.
//
import UIKit
import Combine
import PhotosUI
import MastodonSDK
import MastodonCore
import MastodonLocalization
import func QuartzCore.CACurrentMediaTime
public protocol AttachmentViewModelDelegate: AnyObject {
func attachmentViewModel(_ viewModel: AttachmentViewModel, uploadStateValueDidChange state: AttachmentViewModel.UploadState)
func attachmentViewModel(_ viewModel: AttachmentViewModel, actionButtonDidPressed action: AttachmentViewModel.Action)
}
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
public let id = UUID()
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
weak var delegate: AttachmentViewModelDelegate?
let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.allowsNonnumericFormatting = true
formatter.countStyle = .memory
return formatter
}()
let percentageFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .percent
return formatter
}()
// input
public let api: APIService
public let authContext: AuthContext
public let input: Input
public let sizeLimit: SizeLimit
@Published var caption = ""
@Published public private(set) var isCaptionEditable = true
let isEditing: Bool
// output
@Published public private(set) var output: Output?
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
@Published public private(set) var outputSizeInByte: Int64 = 0
@Published public private(set) var uploadState: UploadState = .none
@Published public private(set) var uploadResult: UploadResult?
@Published var error: Error?
var uploadTask: Task<(), Never>?
@Published var videoCompressProgress: Double = 0
let progress = Progress() // upload progress
@Published var fractionCompleted: Double = 0
private var lastTimestamp: TimeInterval?
private var lastUploadSizeInByte: Int64 = 0
private var averageUploadSpeedInByte: Int64 = 0
private var remainTimeInterval: Double?
@Published var remainTimeLocalizedString: String?
public init(
api: APIService,
authContext: AuthContext,
input: Input,
sizeLimit: SizeLimit,
delegate: AttachmentViewModelDelegate,
isEditing: Bool = false,
caption: String? = nil
) {
self.api = api
self.authContext = authContext
self.input = input
self.sizeLimit = sizeLimit
self.delegate = delegate
self.isEditing = isEditing
self.caption = caption ?? ""
super.init()
Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS
.autoconnect()
.share()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.step()
}
.store(in: &disposeBag)
progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
DispatchQueue.main.async {
self.fractionCompleted = progress.fractionCompleted
}
}
.store(in: &observations)
// Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress
// progress
// .observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
// guard let self = self else { return }
// DispatchQueue.main.async {
// self.objectWillChange.send()
// }
// }
// .store(in: &observations)
$output
.map { output -> UIImage? in
switch output {
case .image(let data, _):
return UIImage(data: data)
case .video(let url, _):
return AttachmentViewModel.createThumbnailForVideo(url: url)
case .none:
return nil
}
}
.receive(on: DispatchQueue.main)
.assign(to: &$thumbnail)
let uploadTask = Task { @MainActor in
do {
var output = try await load(input: input)
switch input {
case .mastodonAssetUrl:
if self.isEditing == false {
self.isCaptionEditable = false
}
self.uploadState = .finish
self.output = output
self.uploadResult = .exists
return
default:
break
}
switch output {
case .image(let data, _):
self.output = output
self.update(uploadState: .compressing)
let compressedOutput = try await compressImage(data: data, sizeLimit: sizeLimit)
output = compressedOutput
case .video(let fileURL, let mimeType):
self.output = output
self.update(uploadState: .compressing)
guard let compressedFileURL = try await compressVideo(url: fileURL) else {
assertionFailure("Unable to compress video")
return
}
output = .video(compressedFileURL, mimeType: mimeType)
try? FileManager.default.removeItem(at: fileURL) // remove old file
}
self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
self.output = output
self.update(uploadState: .ready)
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
} catch {
self.error = error
}
} // end Task
self.uploadTask = uploadTask
Task {
await uploadTask.value
}
}
deinit {
uploadTask?.cancel()
switch output {
case .image:
// FIXME:
break
case .video(let url, _):
try? FileManager.default.removeItem(at: url)
case nil:
break
}
}
}
// calculate the upload speed
// ref: https://stackoverflow.com/a/3841706/3797903
extension AttachmentViewModel {
static var SpeedSmoothingFactor = 0.4
static let remainsTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter
}()
@objc private func step() {
let uploadProgress = min(progress.fractionCompleted + 0.1, 1) // the progress split into 9:1 blocks (download : waiting)
guard let lastTimestamp = self.lastTimestamp else {
self.lastTimestamp = CACurrentMediaTime()
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
return
}
let duration = CACurrentMediaTime() - lastTimestamp
guard duration >= 1.0 else { return } // update every 1 sec
let old = self.lastUploadSizeInByte
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
let newSpeed = self.lastUploadSizeInByte - old
let lastAverageSpeed = self.averageUploadSpeedInByte
let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed))
let remainSizeInByte = Double(outputSizeInByte) * (1 - uploadProgress)
let speed = Double(newAverageSpeed)
if speed != .zero {
// estimate by speed
let uploadRemainTimeInSecond = remainSizeInByte / speed
// estimate by progress 1s for 10%
let remainPercentage = 1 - uploadProgress
let estimateRemainTimeByProgress = remainPercentage / 0.1
// max estimate
var remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
// do not increate timer when < 5 sec
if let remainTimeInterval = self.remainTimeInterval, remainTimeInSecond < 5 {
remainTimeInSecond = min(remainTimeInterval, remainTimeInSecond)
self.remainTimeInterval = remainTimeInSecond
} else {
self.remainTimeInterval = remainTimeInSecond
}
let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond)
remainTimeLocalizedString = string
// print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)")
} else {
remainTimeLocalizedString = nil
}
self.lastTimestamp = CACurrentMediaTime()
self.averageUploadSpeedInByte = newAverageSpeed
}
}
extension AttachmentViewModel {
public enum Input: Hashable {
case image(UIImage)
case url(URL)
case mastodonAssetUrl(url: URL, attachmentId: String)
case pickerResult(PHPickerResult)
case itemProvider(NSItemProvider)
}
public enum Output {
case image(Data, imageKind: ImageKind)
// case gif(Data)
case video(URL, mimeType: String) // assert use file for video only
public enum ImageKind {
case png
case jpg
}
}
public struct SizeLimit {
public let image: Int?
public let video: Int?
public init(
image: Int?,
video: Int?
) {
self.image = image
self.video = video
}
}
public enum AttachmentError: Error, LocalizedError {
case invalidAttachmentType
case attachmentTooLarge
public var errorDescription: String? {
switch self {
case .invalidAttachmentType:
return L10n.Scene.Compose.Attachment.canNotRecognizeThisMediaAttachment
case .attachmentTooLarge:
return L10n.Scene.Compose.Attachment.attachmentTooLarge
}
}
}
}
extension AttachmentViewModel {
public enum Action: Hashable {
case remove
case retry
}
}
extension AttachmentViewModel {
@MainActor
func update(uploadState: UploadState) {
self.uploadState = uploadState
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
}
@MainActor
func update(uploadResult: UploadResult) {
self.uploadResult = uploadResult
}
}