feat: upload media in queue

This commit is contained in:
CMK 2022-11-11 18:10:13 +08:00
parent d6b90f40bd
commit 088e6f05ec
6 changed files with 173 additions and 44 deletions

View File

@ -15,9 +15,7 @@ import MastodonAsset
public struct AttachmentView: View {
@ObservedObject var viewModel: AttachmentViewModel
let action: (Action) -> Void
var blurEffect: UIBlurEffect {
UIBlurEffect(style: .systemUltraThinMaterialDark)
}
@ -53,7 +51,7 @@ public struct AttachmentView: View {
if viewModel.output != nil {
VisualEffectView(effect: blurEffect)
VStack {
let actionType: AttachmentView.Action = {
let action: AttachmentViewModel.Action = {
if let _ = viewModel.error {
return .retry
} else {
@ -61,10 +59,10 @@ public struct AttachmentView: View {
}
}()
Button {
action(actionType)
viewModel.delegate?.attachmentViewModel(viewModel, actionButtonDidPressed: action)
} label: {
let image: UIImage = {
switch actionType {
switch action {
case .remove:
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
case .retry:
@ -84,21 +82,21 @@ public struct AttachmentView: View {
}
let title: String = {
switch actionType {
switch action {
case .remove:
let totalSizeInByte = viewModel.outputSizeInByte
let uploadSizeInByte = Double(totalSizeInByte) * viewModel.progress.fractionCompleted
let total = ByteCountFormatter.string(fromByteCount: Int64(totalSizeInByte), countStyle: .memory)
let upload = ByteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte), countStyle: .memory)
return "\(upload)/\(total)"
let uploadSizeInByte = Double(totalSizeInByte) * min(1.0, viewModel.fractionCompleted)
let total = viewModel.byteCountFormatter.string(fromByteCount: Int64(totalSizeInByte))
let upload = viewModel.byteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte))
return "\(upload) / \(total)"
case .retry:
return "Upload Failed" // TODO: i18n
}
}()
let subtitle: String = {
switch actionType {
switch action {
case .remove:
if viewModel.progress.fractionCompleted < 1 {
if viewModel.progress.fractionCompleted < 1, viewModel.uploadState == .uploading {
return viewModel.remainTimeLocalizedString ?? ""
} else {
return ""
@ -121,10 +119,3 @@ public struct AttachmentView: View {
} // end body
}
extension AttachmentView {
public enum Action: Hashable {
case remove
case retry
}
}

View File

@ -53,6 +53,14 @@ extension Data {
}
extension AttachmentViewModel {
public enum UploadState {
case none
case ready
case uploading
case fail
case finish
}
struct UploadContext {
let apiService: APIService
let authContext: AuthContext
@ -62,12 +70,43 @@ extension AttachmentViewModel {
}
extension AttachmentViewModel {
func upload(context: UploadContext) async throws -> UploadResult {
return try await uploadMastodonMedia(
context: context
)
@MainActor
func upload(isRetry: Bool = false) async throws {
do {
let result = try await upload(
context: .init(
apiService: self.api,
authContext: self.authContext
),
isRetry: isRetry
)
update(uploadResult: result)
} catch {
self.error = error
}
}
@MainActor
private func upload(context: UploadContext, isRetry: Bool) async throws -> UploadResult {
if isRetry {
guard uploadState == .fail else { throw AppError.badRequest }
self.error = nil
self.fractionCompleted = 0
} else {
guard uploadState == .ready else { throw AppError.badRequest }
}
do {
update(uploadState: .uploading)
let result = try await uploadMastodonMedia(
context: context
)
update(uploadState: .finish)
return result
} catch {
update(uploadState: .fail)
throw error
}
}
// MainActor is required here to trigger stream upload task
@MainActor
@ -132,7 +171,7 @@ extension AttachmentViewModel {
if attachmentUploadResponse.statusCode == 202 {
// note:
// the Mastodon server append the attachments in order by upload time
// can not upload concurrency
// can not upload parallels
let waitProcessRetryLimit = checkUploadTaskRetryLimit
var waitProcessRetryCount: Int64 = 0

View File

@ -12,6 +12,11 @@ import PhotosUI
import Kingfisher
import MastodonCore
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 {
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
@ -21,6 +26,15 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
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
}()
// input
public let api: APIService
@ -34,7 +48,9 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
@Published public private(set) var outputSizeInByte: Int64 = 0
@Published public var uploadResult: UploadResult?
@MainActor
@Published public private(set) var uploadState: UploadState = .none
@Published public private(set) var uploadResult: UploadResult?
@Published var error: Error?
let progress = Progress() // upload progress
@ -44,16 +60,19 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
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
input: Input,
delegate: AttachmentViewModelDelegate
) {
self.api = api
self.authContext = authContext
self.input = input
self.delegate = delegate
super.init()
// end init
@ -67,9 +86,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
self.fractionCompleted = progress.fractionCompleted
DispatchQueue.main.async {
self.objectWillChange.send()
self.fractionCompleted = progress.fractionCompleted
}
}
.store(in: &observations)
@ -105,11 +123,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
let output = try await load(input: input)
self.output = output
self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
let uploadResult = try await self.upload(context: .init(
apiService: self.api,
authContext: self.authContext
))
self.uploadResult = uploadResult
self.update(uploadState: .ready)
self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
} catch {
self.error = error
}
@ -127,7 +142,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
break
case .video(let url, _):
try? FileManager.default.removeItem(at: url)
case nil :
case nil:
break
}
}
@ -140,7 +155,7 @@ extension AttachmentViewModel {
static var SpeedSmoothingFactor = 0.4
static let remainsTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
formatter.unitsStyle = .full
return formatter
}()
@ -171,7 +186,15 @@ extension AttachmentViewModel {
let remainPercentage = 1 - progress.fractionCompleted
let estimateRemainTimeByProgress = remainPercentage / 0.1
// max estimate
let remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
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
@ -236,7 +259,22 @@ extension AttachmentViewModel {
}
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
}
}

View File

@ -329,7 +329,8 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate {
AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .pickerResult(result)
input: .pickerResult(result),
delegate: viewModel
)
}
viewModel.attachmentViewModels += attachmentViewModels

View File

@ -177,6 +177,17 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
)
.map { $0 + $1 <= $2 }
.assign(to: &$isContentValid)
// bind attachment
$attachmentViewModels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
Task {
try await self.uploadMediaInQueue()
}
}
.store(in: &disposeBag)
}
deinit {
@ -397,3 +408,54 @@ extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate
}
}
// MARK: - AttachmentViewModelDelegate
extension ComposeContentViewModel: AttachmentViewModelDelegate {
public func attachmentViewModel(
_ viewModel: AttachmentViewModel,
uploadStateValueDidChange state: AttachmentViewModel.UploadState
) {
Task {
try await uploadMediaInQueue()
}
}
@MainActor
func uploadMediaInQueue() async throws {
for (i, attachmentViewModel) in attachmentViewModels.enumerated() {
switch attachmentViewModel.uploadState {
case .none:
return
case .ready:
let count = self.attachmentViewModels.count
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload \(i)/\(count) attachment")
try await attachmentViewModel.upload()
return
case .uploading:
return
case .fail:
return
case .finish:
continue
}
}
}
public func attachmentViewModel(
_ viewModel: AttachmentViewModel,
actionButtonDidPressed action: AttachmentViewModel.Action
) {
switch action {
case .retry:
Task {
try await viewModel.upload(isRetry: true)
}
case .remove:
attachmentViewModels.removeAll(where: { $0 === viewModel })
Task {
try await uploadMediaInQueue()
}
}
}
}

View File

@ -205,9 +205,7 @@ extension ComposeContentView {
ForEach(viewModel.attachmentViewModels, id: \.self) { attachmentViewModel in
Color.clear.aspectRatio(358.0/232.0, contentMode: .fill)
.overlay(
AttachmentView(viewModel: attachmentViewModel) { action in
}
AttachmentView(viewModel: attachmentViewModel)
)
.clipShape(Rectangle())
.badgeView(