From 088e6f05ec14eb9a09a9c305fd9833b21be88a5e Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 11 Nov 2022 18:10:13 +0800 Subject: [PATCH] feat: upload media in queue --- .../Attachment/AttachmentView.swift | 31 +++------ .../AttachmentViewModel+Upload.swift | 49 +++++++++++-- .../Attachment/AttachmentViewModel.swift | 68 +++++++++++++++---- .../ComposeContentViewController.swift | 3 +- .../ComposeContentViewModel.swift | 62 +++++++++++++++++ .../View/ComposeContentView.swift | 4 +- 6 files changed, 173 insertions(+), 44 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift index 854b35f41..3fbccbbc7 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -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 - } -} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift index fcb30d954..67f0e71ed 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index 276e19de7..9409e7380 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -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() var observations = Set() + + 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 + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index b9fbab856..7dde9c8c0 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index c758a397b..03db3b010 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -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() + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift index ffc92c01e..e1954af04 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentView.swift @@ -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(