mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2025-02-02 18:36:44 +01:00
feat: bind the thumbnail and trigger media upload task
This commit is contained in:
parent
fc3750c377
commit
bdedd54318
@ -374,7 +374,11 @@
|
||||
"video": "video",
|
||||
"attachment_broken": "This %s is broken and can’t be\nuploaded to Mastodon.",
|
||||
"description_photo": "Describe the photo for the visually-impaired...",
|
||||
"description_video": "Describe the video for the visually-impaired..."
|
||||
"description_video": "Describe the video for the visually-impaired...",
|
||||
"load_failed": "Load Failed",
|
||||
"upload_failed": "Upload Failed",
|
||||
"can_not_recognize_this_media_attachment": "Can not regonize this media attachment",
|
||||
"attachment_too_large": "Attachment too large"
|
||||
},
|
||||
"poll": {
|
||||
"duration_time": "Duration: %s",
|
||||
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.400",
|
||||
"green" : "0.275",
|
||||
"red" : "0.275"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.400",
|
||||
"green" : "0.275",
|
||||
"red" : "0.275"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
%PDF-1.7
|
||||
|
||||
1 0 obj
|
||||
<< >>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<< /Length 3 0 R >>
|
||||
stream
|
||||
/DeviceRGB CS
|
||||
/DeviceRGB cs
|
||||
q
|
||||
1.000000 0.000000 -0.000000 1.000000 2.750000 2.750000 cm
|
||||
0.000000 0.000000 0.000000 scn
|
||||
9.250000 16.500000 m
|
||||
5.245935 16.500000 2.000000 13.254065 2.000000 9.250000 c
|
||||
2.000000 5.245935 5.245935 2.000000 9.250000 2.000000 c
|
||||
13.254065 2.000000 16.500000 5.245935 16.500000 9.250000 c
|
||||
16.500000 9.535608 16.483484 9.817360 16.451357 10.094351 c
|
||||
16.383255 10.681498 16.809317 11.250000 17.400400 11.250000 c
|
||||
17.916018 11.250000 18.369314 10.891933 18.431660 10.380100 c
|
||||
18.476776 10.009713 18.500000 9.632568 18.500000 9.250000 c
|
||||
18.500000 4.141366 14.358634 0.000000 9.250000 0.000000 c
|
||||
4.141366 0.000000 0.000000 4.141366 0.000000 9.250000 c
|
||||
0.000000 14.358634 4.141366 18.500000 9.250000 18.500000 c
|
||||
11.423139 18.500000 13.421247 17.750608 15.000000 16.496151 c
|
||||
15.000000 17.000000 l
|
||||
15.000000 17.552284 15.447716 18.000000 16.000000 18.000000 c
|
||||
16.552284 18.000000 17.000000 17.552284 17.000000 17.000000 c
|
||||
17.000000 14.301708 l
|
||||
17.011232 14.284512 17.022409 14.267276 17.033529 14.250000 c
|
||||
17.000000 14.250000 l
|
||||
17.000000 14.000000 l
|
||||
17.000000 13.447716 16.552284 13.000000 16.000000 13.000000 c
|
||||
13.000000 13.000000 l
|
||||
12.447715 13.000000 12.000000 13.447716 12.000000 14.000000 c
|
||||
12.000000 14.552284 12.447715 15.000000 13.000000 15.000000 c
|
||||
13.666476 15.000000 l
|
||||
12.443584 15.940684 10.912110 16.500000 9.250000 16.500000 c
|
||||
h
|
||||
f
|
||||
n
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
1365
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<< /Annots []
|
||||
/Type /Page
|
||||
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
|
||||
/Resources 1 0 R
|
||||
/Contents 2 0 R
|
||||
/Parent 5 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Kids [ 4 0 R ]
|
||||
/Count 1
|
||||
/Type /Pages
|
||||
>>
|
||||
endobj
|
||||
|
||||
6 0 obj
|
||||
<< /Pages 5 0 R
|
||||
/Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000034 00000 n
|
||||
0000001455 00000 n
|
||||
0000001478 00000 n
|
||||
0000001651 00000 n
|
||||
0000001725 00000 n
|
||||
trailer
|
||||
<< /ID [ (some) (id) ]
|
||||
/Root 6 0 R
|
||||
/Size 7
|
||||
>>
|
||||
startxref
|
||||
1784
|
||||
%%EOF
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Arrow Clockwise.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Dismiss.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
%PDF-1.7
|
||||
|
||||
1 0 obj
|
||||
<< >>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<< /Length 3 0 R >>
|
||||
stream
|
||||
/DeviceRGB CS
|
||||
/DeviceRGB cs
|
||||
q
|
||||
1.000000 0.000000 -0.000000 1.000000 4.000000 3.804749 cm
|
||||
0.000000 0.000000 0.000000 scn
|
||||
0.209704 15.808150 m
|
||||
0.292893 15.902358 l
|
||||
0.653377 16.262842 1.220608 16.290571 1.612899 15.985547 c
|
||||
1.707107 15.902358 l
|
||||
8.000000 9.610251 l
|
||||
14.292892 15.902358 l
|
||||
14.683416 16.292883 15.316584 16.292883 15.707108 15.902358 c
|
||||
16.097631 15.511834 16.097631 14.878669 15.707108 14.488145 c
|
||||
9.415000 8.195251 l
|
||||
15.707108 1.902359 l
|
||||
16.067591 1.541875 16.095320 0.974643 15.790295 0.582352 c
|
||||
15.707108 0.488144 l
|
||||
15.346623 0.127661 14.779391 0.099932 14.387100 0.404957 c
|
||||
14.292892 0.488144 l
|
||||
8.000000 6.780252 l
|
||||
1.707107 0.488144 l
|
||||
1.316582 0.097620 0.683418 0.097620 0.292893 0.488144 c
|
||||
-0.097631 0.878668 -0.097631 1.511835 0.292893 1.902359 c
|
||||
6.585000 8.195251 l
|
||||
0.292893 14.488145 l
|
||||
-0.067591 14.848629 -0.095320 15.415859 0.209704 15.808150 c
|
||||
0.292893 15.902358 l
|
||||
0.209704 15.808150 l
|
||||
h
|
||||
f
|
||||
n
|
||||
Q
|
||||
|
||||
endstream
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
914
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<< /Annots []
|
||||
/Type /Page
|
||||
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
|
||||
/Resources 1 0 R
|
||||
/Contents 2 0 R
|
||||
/Parent 5 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Kids [ 4 0 R ]
|
||||
/Count 1
|
||||
/Type /Pages
|
||||
>>
|
||||
endobj
|
||||
|
||||
6 0 obj
|
||||
<< /Pages 5 0 R
|
||||
/Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000034 00000 n
|
||||
0000001004 00000 n
|
||||
0000001026 00000 n
|
||||
0000001199 00000 n
|
||||
0000001273 00000 n
|
||||
trailer
|
||||
<< /ID [ (some) (id) ]
|
||||
/Root 6 0 R
|
||||
/Size 7
|
||||
>>
|
||||
startxref
|
||||
1332
|
||||
%%EOF
|
@ -130,6 +130,11 @@ public enum Asset {
|
||||
}
|
||||
public enum Scene {
|
||||
public enum Compose {
|
||||
public enum Attachment {
|
||||
public static let indicatorButtonBackground = ColorAsset(name: "Scene/Compose/Attachment/indicator.button.background")
|
||||
public static let retry = ImageAsset(name: "Scene/Compose/Attachment/retry")
|
||||
public static let stop = ImageAsset(name: "Scene/Compose/Attachment/stop")
|
||||
}
|
||||
public static let earth = ImageAsset(name: "Scene/Compose/Earth")
|
||||
public static let mention = ImageAsset(name: "Scene/Compose/Mention")
|
||||
public static let more = ImageAsset(name: "Scene/Compose/More")
|
||||
|
@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media {
|
||||
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||
let serialStream = query.serialStream
|
||||
request.httpBodyStream = serialStream.boundStreams.input
|
||||
|
||||
// total unit count in bytes count
|
||||
// will small than actally count due to multipart protocol meta
|
||||
serialStream.progress.totalUnitCount = {
|
||||
var size = 0
|
||||
size += query.file?.sizeInByte ?? 0
|
||||
size += query.thumbnail?.sizeInByte ?? 0
|
||||
return Int64(size)
|
||||
}()
|
||||
query.progress.addChild(
|
||||
serialStream.progress,
|
||||
withPendingUnitCount: query.progress.totalUnitCount
|
||||
)
|
||||
|
||||
return session.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||
|
@ -54,7 +54,7 @@ extension Mastodon.Query.MediaAttachment {
|
||||
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
|
||||
}
|
||||
|
||||
var sizeInByte: Int? {
|
||||
public var sizeInByte: Int? {
|
||||
switch self {
|
||||
case .jpeg(let data), .gif(let data), .png(let data):
|
||||
return data?.count
|
||||
|
@ -10,12 +10,17 @@ import UIKit
|
||||
import SwiftUI
|
||||
import Introspect
|
||||
import AVKit
|
||||
import MastodonAsset
|
||||
|
||||
public struct AttachmentView: View {
|
||||
|
||||
@ObservedObject var viewModel: AttachmentViewModel
|
||||
|
||||
let action: (Action) -> Void
|
||||
|
||||
var blurEffect: UIBlurEffect {
|
||||
UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
@ -23,223 +28,81 @@ public struct AttachmentView: View {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
|
||||
// loading…
|
||||
if viewModel.output == nil, viewModel.error == nil {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
|
||||
// load failed
|
||||
// cannot re-entry
|
||||
if viewModel.output == nil, let error = viewModel.error {
|
||||
VisualEffectView(effect: blurEffect)
|
||||
VStack {
|
||||
Text("Load Failed") // TODO: i18n
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(error.localizedDescription)
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
}
|
||||
}
|
||||
|
||||
// loaded
|
||||
// uploading… or upload failed
|
||||
// could retry upload when error emit
|
||||
if viewModel.output != nil {
|
||||
VisualEffectView(effect: blurEffect)
|
||||
VStack {
|
||||
let image: UIImage = {
|
||||
if let _ = viewModel.error {
|
||||
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
|
||||
} else {
|
||||
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
}()
|
||||
Image(uiImage: image)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
|
||||
.clipShape(Circle())
|
||||
.padding()
|
||||
let title: String = {
|
||||
if let _ = viewModel.error {
|
||||
return "Upload Failed" // TODO: i18n
|
||||
} else {
|
||||
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory)
|
||||
return "…/\(total)"
|
||||
}
|
||||
}()
|
||||
let subtitle: String = {
|
||||
if let error = viewModel.error {
|
||||
return error.localizedDescription
|
||||
} else {
|
||||
return "… remaining"
|
||||
}
|
||||
}()
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
} // end ZStack
|
||||
.onChange(of: viewModel.progress) { progress in
|
||||
// not works…
|
||||
print(progress.completedUnitCount)
|
||||
}
|
||||
// Menu {
|
||||
// menu
|
||||
// } label: {
|
||||
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
|
||||
// Image(uiImage: image)
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height)
|
||||
// .overlay {
|
||||
// ZStack {
|
||||
// // spinner
|
||||
// if viewModel.output == nil {
|
||||
// Color.clear
|
||||
// .background(.ultraThinMaterial)
|
||||
// ProgressView()
|
||||
// .progressViewStyle(CircularProgressViewStyle())
|
||||
// .foregroundStyle(.regularMaterial)
|
||||
// }
|
||||
// // border
|
||||
// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius)
|
||||
// .stroke(Color.black.opacity(0.05))
|
||||
// }
|
||||
// .transition(.opacity)
|
||||
// }
|
||||
// .overlay(alignment: .bottom) {
|
||||
// HStack(alignment: .bottom) {
|
||||
// // alt
|
||||
// VStack(spacing: 2) {
|
||||
// switch viewModel.output {
|
||||
// case .video:
|
||||
// Image(uiImage: Asset.Media.playerRectangle.image)
|
||||
// .resizable()
|
||||
// .frame(width: 16, height: 12)
|
||||
// default:
|
||||
// EmptyView()
|
||||
// }
|
||||
// if !viewModel.caption.isEmpty {
|
||||
// Image(uiImage: Asset.Media.altRectangle.image)
|
||||
// .resizable()
|
||||
// .frame(width: 16, height: 12)
|
||||
// }
|
||||
// }
|
||||
// Spacer()
|
||||
// // option
|
||||
// Image(systemName: "ellipsis")
|
||||
// .resizable()
|
||||
// .frame(width: 12, height: 12)
|
||||
// .symbolVariant(.circle)
|
||||
// .symbolVariant(.fill)
|
||||
// .symbolRenderingMode(.palette)
|
||||
// .foregroundStyle(.white, .black)
|
||||
// }
|
||||
// .padding(6)
|
||||
// }
|
||||
// .cornerRadius(AttachmentView.cornerRadius)
|
||||
// } // end Menu
|
||||
// .sheet(isPresented: $isCaptionEditorPresented) {
|
||||
// captionSheet
|
||||
// } // end caption sheet
|
||||
// .sheet(isPresented: $viewModel.isPreviewPresented) {
|
||||
// previewSheet
|
||||
// } // end preview sheet
|
||||
|
||||
} // end body
|
||||
|
||||
// var menu: some View {
|
||||
// Group {
|
||||
// Button(
|
||||
// action: {
|
||||
// action(.preview)
|
||||
// },
|
||||
// label: {
|
||||
// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo")
|
||||
// }
|
||||
// )
|
||||
// // caption
|
||||
// let canAddCaption: Bool = {
|
||||
// switch viewModel.output {
|
||||
// case .image: return true
|
||||
// case .video: return false
|
||||
// case .none: return false
|
||||
// }
|
||||
// }()
|
||||
// if canAddCaption {
|
||||
// Button(
|
||||
// action: {
|
||||
// action(.caption)
|
||||
// caption = viewModel.caption
|
||||
// isCaptionEditorPresented.toggle()
|
||||
// },
|
||||
// label: {
|
||||
// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update
|
||||
// Label(title, systemImage: "text.bubble")
|
||||
// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu
|
||||
// // add caption subtitle
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// Divider()
|
||||
// // remove
|
||||
// Button(
|
||||
// role: .destructive,
|
||||
// action: {
|
||||
// action(.remove)
|
||||
// },
|
||||
// label: {
|
||||
// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle")
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// var captionSheet: some View {
|
||||
// NavigationView {
|
||||
// ScrollView(.vertical) {
|
||||
// VStack {
|
||||
// // preview
|
||||
// switch viewModel.output {
|
||||
// case .image:
|
||||
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
|
||||
// Image(uiImage: image)
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// case .video(let url, _):
|
||||
// let player = AVPlayer(url: url)
|
||||
// VideoPlayer(player: player)
|
||||
// .frame(height: 300)
|
||||
// case .none:
|
||||
// EmptyView()
|
||||
// }
|
||||
// // caption textField
|
||||
// TextField(
|
||||
// text: $caption,
|
||||
// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage)
|
||||
// ) {
|
||||
// Text(L10n.Scene.Compose.Media.Caption.update)
|
||||
// }
|
||||
// .padding()
|
||||
// .introspectTextField { textField in
|
||||
// textField.becomeFirstResponder()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .navigationTitle(L10n.Scene.Compose.Media.Caption.update)
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
// .toolbar {
|
||||
// ToolbarItem(placement: .navigationBarLeading) {
|
||||
// Button {
|
||||
// isCaptionEditorPresented.toggle()
|
||||
// } label: {
|
||||
// Image(systemName: "xmark.circle.fill")
|
||||
// .resizable()
|
||||
// .frame(width: 30, height: 30, alignment: .center)
|
||||
// .symbolRenderingMode(.hierarchical)
|
||||
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
|
||||
// }
|
||||
// }
|
||||
// ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// Button {
|
||||
// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// isCaptionEditorPresented.toggle()
|
||||
// } label: {
|
||||
// Text(L10n.Common.Controls.Actions.save)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } // end NavigationView
|
||||
// }
|
||||
|
||||
// design for share extension
|
||||
// preferred UIKit preview in app
|
||||
// var previewSheet: some View {
|
||||
// NavigationView {
|
||||
// ScrollView(.vertical) {
|
||||
// VStack {
|
||||
// // preview
|
||||
// switch viewModel.output {
|
||||
// case .image:
|
||||
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
|
||||
// Image(uiImage: image)
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// case .video(let url, _):
|
||||
// let player = AVPlayer(url: url)
|
||||
// VideoPlayer(player: player)
|
||||
// .frame(height: 300)
|
||||
// case .none:
|
||||
// EmptyView()
|
||||
// }
|
||||
// Spacer()
|
||||
// }
|
||||
// }
|
||||
// .navigationTitle(L10n.Scene.Compose.Media.preview)
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
// .toolbar {
|
||||
// ToolbarItem(placement: .navigationBarLeading) {
|
||||
// Button {
|
||||
// viewModel.isPreviewPresented.toggle()
|
||||
// } label: {
|
||||
// Image(systemName: "xmark.circle.fill")
|
||||
// .resizable()
|
||||
// .frame(width: 30, height: 30, alignment: .center)
|
||||
// .symbolRenderingMode(.hierarchical)
|
||||
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } // end NavigationView
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentView {
|
||||
public enum Action: Hashable {
|
||||
case preview
|
||||
case caption
|
||||
case remove
|
||||
case retry
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,144 @@
|
||||
//
|
||||
// AttachmentViewModel+DragAndDrop.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022/11/8.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - TypeIdentifiedItemProvider
|
||||
extension AttachmentViewModel: TypeIdentifiedItemProvider {
|
||||
public static var typeIdentifier: String {
|
||||
// must in UTI format
|
||||
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
|
||||
return "org.joinmastodon.app.AttachmentViewModel"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSItemProviderWriting
|
||||
extension AttachmentViewModel: NSItemProviderWriting {
|
||||
|
||||
|
||||
/// Attachment uniform type idendifiers
|
||||
///
|
||||
/// The latest one for in-app drag and drop.
|
||||
/// And use generic `image` and `movie` type to
|
||||
/// allows transformable media in different formats
|
||||
public static var writableTypeIdentifiersForItemProvider: [String] {
|
||||
return [
|
||||
UTType.image.identifier,
|
||||
UTType.movie.identifier,
|
||||
AttachmentViewModel.typeIdentifier,
|
||||
]
|
||||
}
|
||||
|
||||
public var writableTypeIdentifiersForItemProvider: [String] {
|
||||
// should append elements in priority order from high to low
|
||||
var typeIdentifiers: [String] = []
|
||||
|
||||
// FIXME: check jpg or png
|
||||
switch input {
|
||||
case .image:
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
case .url(let url):
|
||||
let _uti = UTType(filenameExtension: url.pathExtension)
|
||||
if let uti = _uti {
|
||||
if uti.conforms(to: .image) {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
}
|
||||
case .pickerResult(let item):
|
||||
if item.itemProvider.isImage() {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if item.itemProvider.isMovie() {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
case .itemProvider(let itemProvider):
|
||||
if itemProvider.isImage() {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if itemProvider.isMovie() {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
typeIdentifiers.append(AttachmentViewModel.typeIdentifier)
|
||||
|
||||
return typeIdentifiers
|
||||
}
|
||||
|
||||
public func loadData(
|
||||
withTypeIdentifier typeIdentifier: String,
|
||||
forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
|
||||
) -> Progress? {
|
||||
switch typeIdentifier {
|
||||
case AttachmentViewModel.typeIdentifier:
|
||||
do {
|
||||
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
||||
try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey)
|
||||
archiver.finishEncoding()
|
||||
let data = archiver.encodedData
|
||||
completionHandler(data, nil)
|
||||
} catch {
|
||||
assertionFailure()
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let loadingProgress = Progress(totalUnitCount: 100)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
$output,
|
||||
$error
|
||||
)
|
||||
.sink { [weak self] output, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
// continue when load completed
|
||||
guard output != nil || error != nil else { return }
|
||||
|
||||
switch output {
|
||||
case .image(let data, _):
|
||||
switch typeIdentifier {
|
||||
case UTType.png.identifier:
|
||||
loadingProgress.completedUnitCount = 100
|
||||
completionHandler(data, nil)
|
||||
default:
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
case .video(let url, _):
|
||||
switch typeIdentifier {
|
||||
case UTType.png.identifier:
|
||||
let _image = AttachmentViewModel.createThumbnailForVideo(url: url)
|
||||
let _data = _image?.pngData()
|
||||
loadingProgress.completedUnitCount = 100
|
||||
completionHandler(_data, nil)
|
||||
case UTType.mpeg4Movie.identifier:
|
||||
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
completionHandler(data, error)
|
||||
}
|
||||
task.progress.observe(\.fractionCompleted) { progress, change in
|
||||
loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted)
|
||||
}
|
||||
.store(in: &self.observations)
|
||||
task.resume()
|
||||
default:
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
case nil:
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
return loadingProgress
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
//
|
||||
// AttachmentViewModel+Load.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022/11/8.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension AttachmentViewModel {
|
||||
|
||||
@MainActor
|
||||
func load(input: Input) async throws -> Output {
|
||||
switch input {
|
||||
case .image(let image):
|
||||
guard let data = image.pngData() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
return .image(data, imageKind: .png)
|
||||
case .url(let url):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(url: url)
|
||||
return output
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
case .pickerResult(let pickerResult):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
|
||||
return output
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
case .itemProvider(let itemProvider):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
|
||||
return output
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func load(url: URL) async throws -> Output {
|
||||
guard let uti = UTType(filenameExtension: url.pathExtension) else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
|
||||
if uti.conforms(to: .image) {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
} else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
}
|
||||
|
||||
private static func load(itemProvider: NSItemProvider) async throws -> Output {
|
||||
if itemProvider.isImage() {
|
||||
guard let result = try await itemProvider.loadImageData() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
let imageKind: Output.ImageKind = {
|
||||
if let type = result.type {
|
||||
if type == UTType.png {
|
||||
return .png
|
||||
}
|
||||
if type == UTType.jpeg {
|
||||
return .jpg
|
||||
}
|
||||
}
|
||||
|
||||
let imageData = result.data
|
||||
|
||||
if imageData.kf.imageFormat == .PNG {
|
||||
return .png
|
||||
}
|
||||
if imageData.kf.imageFormat == .JPEG {
|
||||
return .jpg
|
||||
}
|
||||
|
||||
assertionFailure("unknown image kind")
|
||||
return .jpg
|
||||
}()
|
||||
return .image(result.data, imageKind: imageKind)
|
||||
} else if itemProvider.isMovie() {
|
||||
guard let result = try await itemProvider.loadVideoData() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
return .video(result.url, mimeType: "video/mp4")
|
||||
} else {
|
||||
assertionFailure()
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentViewModel {
|
||||
static func createThumbnailForVideo(url: URL) -> UIImage? {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let asset = AVURLAsset(url: url)
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
||||
do {
|
||||
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
} catch {
|
||||
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSItemProvider {
|
||||
func isImage() -> Bool {
|
||||
return hasRepresentationConforming(
|
||||
toTypeIdentifier: UTType.image.identifier,
|
||||
fileOptions: []
|
||||
)
|
||||
}
|
||||
|
||||
func isMovie() -> Bool {
|
||||
return hasRepresentationConforming(
|
||||
toTypeIdentifier: UTType.movie.identifier,
|
||||
fileOptions: []
|
||||
)
|
||||
}
|
||||
}
|
@ -52,153 +52,25 @@ extension Data {
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter Only
|
||||
//extension AttachmentViewModel {
|
||||
// class SliceResult {
|
||||
//
|
||||
// let fileURL: URL
|
||||
// let chunks: Chunked<FileHandle.AsyncBytes>
|
||||
// let chunkCount: Int
|
||||
// let type: UTType
|
||||
// let sizeInBytes: UInt64
|
||||
//
|
||||
// public init?(
|
||||
// url: URL,
|
||||
// type: UTType
|
||||
// ) {
|
||||
// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil }
|
||||
// let _sizeInBytes: UInt64? = {
|
||||
// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
// return attribute?[.size] as? UInt64
|
||||
// }()
|
||||
// guard let sizeInBytes = _sizeInBytes else { return nil }
|
||||
//
|
||||
// self.fileURL = url
|
||||
// self.chunks = chunks
|
||||
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
|
||||
// self.type = type
|
||||
// self.sizeInBytes = sizeInBytes
|
||||
// }
|
||||
//
|
||||
// public init?(
|
||||
// imageData: Data,
|
||||
// type: UTType
|
||||
// ) {
|
||||
// let _fileURL = try? FileManager.default.createTemporaryFileURL(
|
||||
// filename: UUID().uuidString,
|
||||
// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg"
|
||||
// )
|
||||
// guard let fileURL = _fileURL else { return nil }
|
||||
//
|
||||
// do {
|
||||
// try imageData.write(to: fileURL)
|
||||
// } catch {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else {
|
||||
// return nil
|
||||
// }
|
||||
// let sizeInBytes = UInt64(imageData.count)
|
||||
//
|
||||
// self.fileURL = fileURL
|
||||
// self.chunks = chunks
|
||||
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
|
||||
// self.type = type
|
||||
// self.sizeInBytes = sizeInBytes
|
||||
// }
|
||||
//
|
||||
// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int {
|
||||
// guard sizeInBytes > 0 else { return 0 }
|
||||
// let count = sizeInBytes / chunkSize
|
||||
// let remains = sizeInBytes % chunkSize
|
||||
// let result = remains > 0 ? count + 1 : count
|
||||
// return Int(result)
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? {
|
||||
// // needs execute in background
|
||||
// assert(!Thread.isMainThread)
|
||||
//
|
||||
// // try png then use JPEG compress with Q=0.8
|
||||
// // then slice into 1MiB chunks
|
||||
// switch output {
|
||||
// case .image(let data, _):
|
||||
// let maxPayloadSizeInBytes = sizeLimit.image
|
||||
//
|
||||
// // use processed imageData to remove EXIF
|
||||
// guard let image = UIImage(data: data),
|
||||
// var imageData = image.pngData()
|
||||
// else { return nil }
|
||||
//
|
||||
// var didRemoveEXIF = false
|
||||
// repeat {
|
||||
// guard let image = KFCrossPlatformImage(data: imageData) else { return nil }
|
||||
// if imageData.kf.imageFormat == .PNG {
|
||||
// // A. png image
|
||||
// guard let pngData = image.pngData() else { return nil }
|
||||
// didRemoveEXIF = true
|
||||
// if pngData.count > maxPayloadSizeInBytes {
|
||||
// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
|
||||
// imageData = compressedJpegData
|
||||
// } else {
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024)
|
||||
// imageData = pngData
|
||||
// }
|
||||
// } else {
|
||||
// // B. other image
|
||||
// if !didRemoveEXIF {
|
||||
// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024)
|
||||
// imageData = jpegData
|
||||
// didRemoveEXIF = true
|
||||
// } else {
|
||||
// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
|
||||
// let scaledImage = image.af.imageScaled(to: targetSize)
|
||||
// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil }
|
||||
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
|
||||
// imageData = compressedJpegData
|
||||
// }
|
||||
// }
|
||||
// } while (imageData.count > maxPayloadSizeInBytes)
|
||||
//
|
||||
// return SliceResult(
|
||||
// imageData: imageData,
|
||||
// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg
|
||||
// )
|
||||
//
|
||||
//// case .gif(let url):
|
||||
//// fatalError()
|
||||
// case .video(let url, _):
|
||||
// return SliceResult(
|
||||
// url: url,
|
||||
// type: .movie
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
extension AttachmentViewModel {
|
||||
struct UploadContext {
|
||||
let apiService: APIService
|
||||
let authContext: AuthContext
|
||||
}
|
||||
|
||||
enum UploadResult {
|
||||
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Attachment>)
|
||||
}
|
||||
public typealias UploadResult = Mastodon.Entity.Attachment
|
||||
}
|
||||
|
||||
extension AttachmentViewModel {
|
||||
func upload(context: UploadContext) async throws -> UploadResult {
|
||||
func upload(context: UploadContext) async throws -> UploadResult {
|
||||
return try await uploadMastodonMedia(
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MainActor is required here to trigger stream upload task
|
||||
@MainActor
|
||||
private func uploadMastodonMedia(
|
||||
context: UploadContext
|
||||
) async throws -> UploadResult {
|
||||
@ -283,7 +155,7 @@ extension AttachmentViewModel {
|
||||
|
||||
// escape here
|
||||
progress.completedUnitCount = progress.totalUnitCount
|
||||
return .mastodon(attachmentStatusResponse)
|
||||
return attachmentStatusResponse.value
|
||||
|
||||
} else {
|
||||
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)")
|
||||
@ -296,7 +168,7 @@ extension AttachmentViewModel {
|
||||
} else {
|
||||
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "<nil>")")
|
||||
|
||||
return .mastodon(attachmentUploadResponse)
|
||||
return attachmentUploadResponse.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import MastodonCore
|
||||
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
|
||||
|
||||
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
||||
let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
||||
|
||||
public let id = UUID()
|
||||
|
||||
@ -22,32 +23,52 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
// input
|
||||
public let api: APIService
|
||||
public let authContext: AuthContext
|
||||
public let input: Input
|
||||
@Published var caption = ""
|
||||
@Published var sizeLimit = SizeLimit()
|
||||
@Published public var isPreviewPresented = false
|
||||
|
||||
// output
|
||||
@Published public private(set) var output: Output?
|
||||
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
||||
@Published public private(set) var outputSizeInByte: Int = 0
|
||||
|
||||
@Published public var uploadResult: UploadResult?
|
||||
@Published var error: Error?
|
||||
|
||||
let progress = Progress() // upload progress
|
||||
|
||||
public init(
|
||||
api: APIService,
|
||||
authContext: AuthContext,
|
||||
input: Input
|
||||
) {
|
||||
self.api = api
|
||||
self.authContext = authContext
|
||||
self.input = input
|
||||
super.init()
|
||||
// end init
|
||||
|
||||
defer {
|
||||
Task {
|
||||
await load(input: input)
|
||||
|
||||
progress
|
||||
.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)")
|
||||
DispatchQueue.main.async {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &observations)
|
||||
|
||||
progress
|
||||
.observe(\.isFinished, 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)")
|
||||
DispatchQueue.main.async {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
.store(in: &observations)
|
||||
|
||||
$output
|
||||
.map { output -> UIImage? in
|
||||
@ -62,6 +83,23 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$thumbnail)
|
||||
|
||||
defer {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let output = try await load(input: input)
|
||||
self.output = output
|
||||
self.outputSizeInByte = output.asAttachment.sizeInByte ?? 0
|
||||
let uploadResult = try await self.upload(context: .init(
|
||||
apiService: self.api,
|
||||
authContext: self.authContext
|
||||
))
|
||||
self.uploadResult = uploadResult
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
} // end Task
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -112,280 +150,23 @@ extension AttachmentViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
public enum AttachmentError: Error {
|
||||
public enum AttachmentError: Error, LocalizedError {
|
||||
case invalidAttachmentType
|
||||
case attachmentTooLarge
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentViewModel {
|
||||
|
||||
@MainActor
|
||||
private func load(input: Input) async {
|
||||
switch input {
|
||||
case .image(let image):
|
||||
guard let data = image.pngData() else {
|
||||
error = AttachmentError.invalidAttachmentType
|
||||
return
|
||||
}
|
||||
output = .image(data, imageKind: .png)
|
||||
case .url(let url):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(url: url)
|
||||
self.output = output
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
case .pickerResult(let pickerResult):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
|
||||
self.output = output
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
case .itemProvider(let itemProvider):
|
||||
do {
|
||||
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
|
||||
self.output = output
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func load(url: URL) async throws -> Output {
|
||||
guard let uti = UTType(filenameExtension: url.pathExtension) else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
|
||||
if uti.conforms(to: .image) {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAttachmentType:
|
||||
return "Can not regonize this media attachment" // TODO: i18n
|
||||
case .attachmentTooLarge:
|
||||
return "Attachment too large"
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
let imageData = try Data(contentsOf: url)
|
||||
return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
let fileName = UUID().uuidString
|
||||
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
||||
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||
return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
||||
} else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
}
|
||||
|
||||
private static func load(itemProvider: NSItemProvider) async throws -> Output {
|
||||
if itemProvider.isImage() {
|
||||
guard let result = try await itemProvider.loadImageData() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
let imageKind: Output.ImageKind = {
|
||||
if let type = result.type {
|
||||
if type == UTType.png {
|
||||
return .png
|
||||
}
|
||||
if type == UTType.jpeg {
|
||||
return .jpg
|
||||
}
|
||||
}
|
||||
|
||||
let imageData = result.data
|
||||
|
||||
if imageData.kf.imageFormat == .PNG {
|
||||
return .png
|
||||
}
|
||||
if imageData.kf.imageFormat == .JPEG {
|
||||
return .jpg
|
||||
}
|
||||
|
||||
assertionFailure("unknown image kind")
|
||||
return .jpg
|
||||
}()
|
||||
return .image(result.data, imageKind: imageKind)
|
||||
} else if itemProvider.isMovie() {
|
||||
guard let result = try await itemProvider.loadVideoData() else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
return .video(result.url, mimeType: "video/mp4")
|
||||
} else {
|
||||
assertionFailure()
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AttachmentViewModel {
|
||||
static func createThumbnailForVideo(url: URL) -> UIImage? {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let asset = AVURLAsset(url: url)
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
||||
do {
|
||||
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
} catch {
|
||||
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TypeIdentifiedItemProvider
|
||||
extension AttachmentViewModel: TypeIdentifiedItemProvider {
|
||||
public static var typeIdentifier: String {
|
||||
// must in UTI format
|
||||
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
|
||||
return "com.twidere.AttachmentViewModel"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSItemProviderWriting
|
||||
extension AttachmentViewModel: NSItemProviderWriting {
|
||||
|
||||
|
||||
/// Attachment uniform type idendifiers
|
||||
///
|
||||
/// The latest one for in-app drag and drop.
|
||||
/// And use generic `image` and `movie` type to
|
||||
/// allows transformable media in different formats
|
||||
public static var writableTypeIdentifiersForItemProvider: [String] {
|
||||
return [
|
||||
UTType.image.identifier,
|
||||
UTType.movie.identifier,
|
||||
AttachmentViewModel.typeIdentifier,
|
||||
]
|
||||
}
|
||||
|
||||
public var writableTypeIdentifiersForItemProvider: [String] {
|
||||
// should append elements in priority order from high to low
|
||||
var typeIdentifiers: [String] = []
|
||||
|
||||
// FIXME: check jpg or png
|
||||
switch input {
|
||||
case .image:
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
case .url(let url):
|
||||
let _uti = UTType(filenameExtension: url.pathExtension)
|
||||
if let uti = _uti {
|
||||
if uti.conforms(to: .image) {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if uti.conforms(to: .movie) {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
}
|
||||
case .pickerResult(let item):
|
||||
if item.itemProvider.isImage() {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if item.itemProvider.isMovie() {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
case .itemProvider(let itemProvider):
|
||||
if itemProvider.isImage() {
|
||||
typeIdentifiers.append(UTType.png.identifier)
|
||||
} else if itemProvider.isMovie() {
|
||||
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
typeIdentifiers.append(AttachmentViewModel.typeIdentifier)
|
||||
|
||||
return typeIdentifiers
|
||||
}
|
||||
|
||||
public func loadData(
|
||||
withTypeIdentifier typeIdentifier: String,
|
||||
forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
|
||||
) -> Progress? {
|
||||
switch typeIdentifier {
|
||||
case AttachmentViewModel.typeIdentifier:
|
||||
do {
|
||||
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
||||
try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey)
|
||||
archiver.finishEncoding()
|
||||
let data = archiver.encodedData
|
||||
completionHandler(data, nil)
|
||||
} catch {
|
||||
assertionFailure()
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let loadingProgress = Progress(totalUnitCount: 100)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
$output,
|
||||
$error
|
||||
)
|
||||
.sink { [weak self] output, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
// continue when load completed
|
||||
guard output != nil || error != nil else { return }
|
||||
|
||||
switch output {
|
||||
case .image(let data, _):
|
||||
switch typeIdentifier {
|
||||
case UTType.png.identifier:
|
||||
loadingProgress.completedUnitCount = 100
|
||||
completionHandler(data, nil)
|
||||
default:
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
case .video(let url, _):
|
||||
switch typeIdentifier {
|
||||
case UTType.png.identifier:
|
||||
let _image = AttachmentViewModel.createThumbnailForVideo(url: url)
|
||||
let _data = _image?.pngData()
|
||||
loadingProgress.completedUnitCount = 100
|
||||
completionHandler(_data, nil)
|
||||
case UTType.mpeg4Movie.identifier:
|
||||
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
completionHandler(data, error)
|
||||
}
|
||||
task.progress.observe(\.fractionCompleted) { progress, change in
|
||||
loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted)
|
||||
}
|
||||
.store(in: &self.observations)
|
||||
task.resume()
|
||||
default:
|
||||
completionHandler(nil, nil)
|
||||
}
|
||||
case nil:
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
return loadingProgress
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NSItemProvider {
|
||||
fileprivate func isImage() -> Bool {
|
||||
return hasRepresentationConforming(
|
||||
toTypeIdentifier: UTType.image.identifier,
|
||||
fileOptions: []
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func isMovie() -> Bool {
|
||||
return hasRepresentationConforming(
|
||||
toTypeIdentifier: UTType.movie.identifier,
|
||||
fileOptions: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -326,7 +326,11 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate {
|
||||
picker.dismiss(animated: true, completion: nil)
|
||||
|
||||
let attachmentViewModels: [AttachmentViewModel] = results.map { result in
|
||||
AttachmentViewModel(authContext: viewModel.authContext, input: .pickerResult(result))
|
||||
AttachmentViewModel(
|
||||
api: viewModel.context.apiService,
|
||||
authContext: viewModel.authContext,
|
||||
input: .pickerResult(result)
|
||||
)
|
||||
}
|
||||
viewModel.attachmentViewModels += attachmentViewModels
|
||||
}
|
||||
|
@ -119,13 +119,16 @@ extension MastodonStatusPublisher: StatusPublisher {
|
||||
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
|
||||
// upload media
|
||||
do {
|
||||
let result = try await attachmentViewModel.upload(context: uploadContext)
|
||||
guard case let .mastodon(response) = result else {
|
||||
assertionFailure()
|
||||
continue
|
||||
guard let attachment = attachmentViewModel.uploadResult else {
|
||||
// precondition: all media uploaded
|
||||
throw AppError.badRequest
|
||||
}
|
||||
let attachmentID = response.value.id
|
||||
attachmentIDs.append(attachmentID)
|
||||
attachmentIDs.append(attachment.id)
|
||||
|
||||
// TODO: allow background upload
|
||||
// let attachment = try await attachmentViewModel.upload(context: uploadContext)
|
||||
// let attachmentID = attachment.id
|
||||
// attachmentIDs.append(attachmentID)
|
||||
} catch {
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
|
||||
_state = .failure(error)
|
||||
|
15
MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift
vendored
Normal file
15
MastodonSDK/Sources/MastodonUI/Vendor/VisualEffectView.swift
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// VisualEffectView.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022/11/8.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// ref: https://stackoverflow.com/a/59111492/3797903
|
||||
public struct VisualEffectView: UIViewRepresentable {
|
||||
public var effect: UIVisualEffect?
|
||||
public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView { UIVisualEffectView() }
|
||||
public func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) { uiView.effect = effect }
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user