feat: upload media in queue
This commit is contained in:
parent
d6b90f40bd
commit
088e6f05ec
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in New Issue