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

View File

@ -53,6 +53,14 @@ extension Data {
} }
extension AttachmentViewModel { extension AttachmentViewModel {
public enum UploadState {
case none
case ready
case uploading
case fail
case finish
}
struct UploadContext { struct UploadContext {
let apiService: APIService let apiService: APIService
let authContext: AuthContext let authContext: AuthContext
@ -62,12 +70,43 @@ extension AttachmentViewModel {
} }
extension AttachmentViewModel { extension AttachmentViewModel {
func upload(context: UploadContext) async throws -> UploadResult { @MainActor
return try await uploadMastodonMedia( func upload(isRetry: Bool = false) async throws {
context: context 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 is required here to trigger stream upload task
@MainActor @MainActor
@ -132,7 +171,7 @@ extension AttachmentViewModel {
if attachmentUploadResponse.statusCode == 202 { if attachmentUploadResponse.statusCode == 202 {
// note: // note:
// the Mastodon server append the attachments in order by upload time // the Mastodon server append the attachments in order by upload time
// can not upload concurrency // can not upload parallels
let waitProcessRetryLimit = checkUploadTaskRetryLimit let waitProcessRetryLimit = checkUploadTaskRetryLimit
var waitProcessRetryCount: Int64 = 0 var waitProcessRetryCount: Int64 = 0

View File

@ -12,6 +12,11 @@ import PhotosUI
import Kingfisher import Kingfisher
import MastodonCore 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 { final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
@ -21,6 +26,15 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>() var observations = Set<NSKeyValueObservation>()
weak var delegate: AttachmentViewModelDelegate?
let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.allowsNonnumericFormatting = true
formatter.countStyle = .memory
return formatter
}()
// input // input
public let api: APIService 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 thumbnail: UIImage? // original size image thumbnail
@Published public private(set) var outputSizeInByte: Int64 = 0 @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? @Published var error: Error?
let progress = Progress() // upload progress let progress = Progress() // upload progress
@ -44,16 +60,19 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
private var lastTimestamp: TimeInterval? private var lastTimestamp: TimeInterval?
private var lastUploadSizeInByte: Int64 = 0 private var lastUploadSizeInByte: Int64 = 0
private var averageUploadSpeedInByte: Int64 = 0 private var averageUploadSpeedInByte: Int64 = 0
private var remainTimeInterval: Double?
@Published var remainTimeLocalizedString: String? @Published var remainTimeLocalizedString: String?
public init( public init(
api: APIService, api: APIService,
authContext: AuthContext, authContext: AuthContext,
input: Input input: Input,
delegate: AttachmentViewModelDelegate
) { ) {
self.api = api self.api = api
self.authContext = authContext self.authContext = authContext
self.input = input self.input = input
self.delegate = delegate
super.init() super.init()
// end init // end init
@ -67,9 +86,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in .observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return } 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.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 { DispatchQueue.main.async {
self.objectWillChange.send() self.fractionCompleted = progress.fractionCompleted
} }
} }
.store(in: &observations) .store(in: &observations)
@ -105,11 +123,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
let output = try await load(input: input) let output = try await load(input: input)
self.output = output self.output = output
self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0 self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0
let uploadResult = try await self.upload(context: .init( self.update(uploadState: .ready)
apiService: self.api, self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState)
authContext: self.authContext
))
self.uploadResult = uploadResult
} catch { } catch {
self.error = error self.error = error
} }
@ -127,7 +142,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
break break
case .video(let url, _): case .video(let url, _):
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
case nil : case nil:
break break
} }
} }
@ -140,7 +155,7 @@ extension AttachmentViewModel {
static var SpeedSmoothingFactor = 0.4 static var SpeedSmoothingFactor = 0.4
static let remainsTimeFormatter: RelativeDateTimeFormatter = { static let remainsTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter() let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short formatter.unitsStyle = .full
return formatter return formatter
}() }()
@ -171,7 +186,15 @@ extension AttachmentViewModel {
let remainPercentage = 1 - progress.fractionCompleted let remainPercentage = 1 - progress.fractionCompleted
let estimateRemainTimeByProgress = remainPercentage / 0.1 let estimateRemainTimeByProgress = remainPercentage / 0.1
// max estimate // 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) let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond)
remainTimeLocalizedString = string 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( AttachmentViewModel(
api: viewModel.context.apiService, api: viewModel.context.apiService,
authContext: viewModel.authContext, authContext: viewModel.authContext,
input: .pickerResult(result) input: .pickerResult(result),
delegate: viewModel
) )
} }
viewModel.attachmentViewModels += attachmentViewModels viewModel.attachmentViewModels += attachmentViewModels

View File

@ -177,6 +177,17 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
) )
.map { $0 + $1 <= $2 } .map { $0 + $1 <= $2 }
.assign(to: &$isContentValid) .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 { 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 ForEach(viewModel.attachmentViewModels, id: \.self) { attachmentViewModel in
Color.clear.aspectRatio(358.0/232.0, contentMode: .fill) Color.clear.aspectRatio(358.0/232.0, contentMode: .fill)
.overlay( .overlay(
AttachmentView(viewModel: attachmentViewModel) { action in AttachmentView(viewModel: attachmentViewModel)
}
) )
.clipShape(Rectangle()) .clipShape(Rectangle())
.badgeView( .badgeView(