feat: upload media in queue
This commit is contained in:
parent
d6b90f40bd
commit
088e6f05ec
|
@ -16,8 +16,6 @@ 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
@ -22,6 +27,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
|
||||
public let authContext: AuthContext
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue