Composer / Share sheet: add video upload support close #154
This commit is contained in:
parent
fd28864063
commit
eec5637c1c
|
@ -12,6 +12,8 @@
|
|||
<integer>4</integer>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
public struct ServerError: Decodable {
|
||||
public struct ServerError: Decodable, Error {
|
||||
public let error: String?
|
||||
}
|
||||
|
|
|
@ -144,7 +144,14 @@ public class Client: ObservableObject, Equatable {
|
|||
let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method)
|
||||
let (data, httpResponse) = try await urlSession.data(for: request)
|
||||
logResponseOnError(httpResponse: httpResponse, data: data)
|
||||
return try decoder.decode(Entity.self, from: data)
|
||||
do {
|
||||
return try decoder.decode(Entity.self, from: data)
|
||||
} catch let error {
|
||||
if let serverError = try? decoder.decode(ServerError.self, from: data) {
|
||||
throw serverError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func oauthURL() async throws -> URL {
|
||||
|
|
|
@ -23,7 +23,7 @@ struct StatusEditorAccessoryView: View {
|
|||
Divider()
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
PhotosPicker(selection: $viewModel.selectedMedias,
|
||||
matching: .images) {
|
||||
matching: .any(of: [.images, .videos])) {
|
||||
Image(systemName: "photo.fill.on.rectangle.fill")
|
||||
}
|
||||
.disabled(viewModel.showPoll)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import Models
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct StatusEditorMediaContainer: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let image: UIImage?
|
||||
let movieTransferable: MovieFileTranseferable?
|
||||
let mediaAttachment: MediaAttachment?
|
||||
let error: Error?
|
||||
}
|
|
@ -7,7 +7,7 @@ struct StatusEditorMediaEditView: View {
|
|||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var theme: Theme
|
||||
@ObservedObject var viewModel: StatusEditorViewModel
|
||||
let container: StatusEditorViewModel.ImageContainer
|
||||
let container: StatusEditorMediaContainer
|
||||
|
||||
@State private var imageDescription: String = ""
|
||||
|
||||
|
|
|
@ -3,11 +3,12 @@ import Env
|
|||
import Models
|
||||
import NukeUI
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
struct StatusEditorMediaView: View {
|
||||
@EnvironmentObject private var theme: Theme
|
||||
@ObservedObject var viewModel: StatusEditorViewModel
|
||||
@State private var editingContainer: StatusEditorViewModel.ImageContainer?
|
||||
@State private var editingContainer: StatusEditorMediaContainer?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
|
@ -17,10 +18,12 @@ struct StatusEditorMediaView: View {
|
|||
makeImageMenu(container: container)
|
||||
} label: {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if container.image != nil {
|
||||
if let attachement = container.mediaAttachment {
|
||||
makeLazyImage(mediaAttachement: attachement)
|
||||
} else if container.image != nil {
|
||||
makeLocalImage(container: container)
|
||||
} else if let url = container.mediaAttachment?.url ?? container.mediaAttachment?.previewUrl {
|
||||
makeLazyImage(url: url)
|
||||
} else if container.movieTransferable != nil {
|
||||
makeVideoAttachement(container: container)
|
||||
}
|
||||
if container.mediaAttachment?.description?.isEmpty == false {
|
||||
altMarker
|
||||
|
@ -36,8 +39,19 @@ struct StatusEditorMediaView: View {
|
|||
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeVideoAttachement(container: StatusEditorMediaContainer) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
placeholderView
|
||||
if container.mediaAttachment == nil {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.cornerRadius(8)
|
||||
.frame(width: 150, height: 150)
|
||||
}
|
||||
|
||||
private func makeLocalImage(container: StatusEditorViewModel.ImageContainer) -> some View {
|
||||
private func makeLocalImage(container: StatusEditorMediaContainer) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(uiImage: container.image!)
|
||||
.resizable()
|
||||
|
@ -75,15 +89,29 @@ struct StatusEditorMediaView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func makeLazyImage(url: URL?) -> some View {
|
||||
LazyImage(url: url) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizingMode(.aspectFill)
|
||||
.frame(width: 150, height: 150)
|
||||
private func makeLazyImage(mediaAttachement: MediaAttachment) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
|
||||
LazyImage(url: url) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizingMode(.aspectFill)
|
||||
.frame(width: 150, height: 150)
|
||||
} else {
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Rectangle()
|
||||
.frame(width: 150, height: 150)
|
||||
placeholderView
|
||||
}
|
||||
if mediaAttachement.url == nil {
|
||||
ProgressView()
|
||||
}
|
||||
if mediaAttachement.url != nil,
|
||||
mediaAttachement.supportedType == .video || mediaAttachement.supportedType == .gifv {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.headline)
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 150, height: 150)
|
||||
|
@ -91,7 +119,7 @@ struct StatusEditorMediaView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeImageMenu(container: StatusEditorViewModel.ImageContainer) -> some View {
|
||||
private func makeImageMenu(container: StatusEditorMediaContainer) -> some View {
|
||||
if !viewModel.mode.isEditing {
|
||||
Button {
|
||||
editingContainer = container
|
||||
|
@ -119,4 +147,10 @@ struct StatusEditorMediaView: View {
|
|||
.background(.thinMaterial)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var placeholderView: some View {
|
||||
Rectangle()
|
||||
.foregroundColor(theme.secondaryBackgroundColor)
|
||||
.frame(width: 150, height: 150)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
@MainActor
|
||||
enum StatusEditorUTTypeSupported: String, CaseIterable {
|
||||
|
@ -10,13 +12,30 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
|
|||
case image = "public.image"
|
||||
case jpeg = "public.jpeg"
|
||||
case png = "public.png"
|
||||
|
||||
case video = "public.video"
|
||||
case movie = "public.movie"
|
||||
case mp4 = "public.mpeg-4"
|
||||
case gif = "public.gif"
|
||||
|
||||
static func types() -> [UTType] {
|
||||
[.url, .text, .plainText, .image, .jpeg, .png]
|
||||
[.url, .text, .plainText, .image, .jpeg, .png, .video, .mpeg4Movie, .gif, .movie]
|
||||
}
|
||||
|
||||
var isVideo: Bool {
|
||||
switch self {
|
||||
case .video, .movie, .mp4, .gif:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadItemContent(item: NSItemProvider) async throws -> Any? {
|
||||
let result = try await item.loadItem(forTypeIdentifier: rawValue)
|
||||
if isVideo, let transferable = await getVideoTransferable(item: item) {
|
||||
return transferable
|
||||
}
|
||||
if self == .jpeg || self == .png,
|
||||
let imageURL = result as? URL,
|
||||
let data = try? Data(contentsOf: imageURL),
|
||||
|
@ -34,4 +53,59 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
_ = item.loadTransferable(type: MovieFileTranseferable.self) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
continuation.resume(with: .success(success))
|
||||
case .failure:
|
||||
continuation.resume(with: .success(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MovieFileTranseferable: Transferable {
|
||||
let url: URL
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .movie) { movie in
|
||||
SentTransferredFile(movie.url)
|
||||
} importing: { received in
|
||||
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
|
||||
try FileManager.default.copyItem(at: received.file, to: copy)
|
||||
return Self.init(url: copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageFileTranseferable: Transferable {
|
||||
let url: URL
|
||||
|
||||
lazy var data: Data? = try? Data(contentsOf: url)
|
||||
lazy var compressedData: Data? = image?.jpegData(compressionQuality: 0.90)
|
||||
lazy var image: UIImage? = UIImage(data: data ?? Data())
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .image) { image in
|
||||
SentTransferredFile(image.url)
|
||||
} importing: { received in
|
||||
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
|
||||
try FileManager.default.copyItem(at: received.file, to: copy)
|
||||
return Self.init(url: copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
public func mimeType() -> String {
|
||||
if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType {
|
||||
return mimeType
|
||||
} else {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,6 +91,13 @@ public struct StatusEditorView: View {
|
|||
.background(theme.primaryBackgroundColor)
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Error while posting",
|
||||
isPresented: $viewModel.showPostingErrorAlert,
|
||||
actions: {
|
||||
Button("Ok") { }
|
||||
}, message: {
|
||||
Text(viewModel.postingError ?? "")
|
||||
})
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
AIMenu
|
||||
|
|
|
@ -7,13 +7,6 @@ import SwiftUI
|
|||
|
||||
@MainActor
|
||||
public class StatusEditorViewModel: ObservableObject {
|
||||
struct ImageContainer: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let image: UIImage?
|
||||
let mediaAttachment: MediaAttachment?
|
||||
let error: Error?
|
||||
}
|
||||
|
||||
var mode: Mode
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
|
||||
|
@ -50,11 +43,14 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@Published var mediasImages: [ImageContainer] = []
|
||||
@Published var mediasImages: [StatusEditorMediaContainer] = []
|
||||
@Published var replyToStatus: Status?
|
||||
@Published var embeddedStatus: Status?
|
||||
|
||||
@Published var customEmojis: [Emoji] = []
|
||||
|
||||
@Published var postingError: String?
|
||||
@Published var showPostingErrorAlert: Bool = false
|
||||
|
||||
var canPost: Bool {
|
||||
statusText.length > 0 || !mediasImages.isEmpty
|
||||
|
@ -119,7 +115,11 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
generator.notificationOccurred(.success)
|
||||
isPosting = false
|
||||
return postStatus
|
||||
} catch {
|
||||
} catch let error {
|
||||
if let error = error as? Models.ServerError {
|
||||
postingError = error.error
|
||||
showPostingErrorAlert = true
|
||||
}
|
||||
isPosting = false
|
||||
generator.notificationOccurred(.error)
|
||||
return nil
|
||||
|
@ -185,7 +185,10 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
||||
spoilerText = status.spoilerText.asRawText
|
||||
visibility = status.visibility
|
||||
mediasImages = status.mediaAttachments.map { .init(image: nil, mediaAttachment: $0, error: nil) }
|
||||
mediasImages = status.mediaAttachments.map { .init(image: nil,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: $0,
|
||||
error: nil) }
|
||||
case let .quote(status):
|
||||
embeddedStatus = status
|
||||
if let url = embeddedStatusURL {
|
||||
|
@ -247,7 +250,10 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
var mediaAdded = false
|
||||
statusText.enumerateAttribute(.attachment, in: range) { attachment, range, _ in
|
||||
if let attachment = attachment as? NSTextAttachment, let image = attachment.image {
|
||||
mediasImages.append(.init(image: image, mediaAttachment: nil, error: nil))
|
||||
mediasImages.append(.init(image: image,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: nil,
|
||||
error: nil))
|
||||
statusText.removeAttribute(.attachment, range: range)
|
||||
statusText.mutableString.deleteCharacters(in: range)
|
||||
mediaAdded = true
|
||||
|
@ -274,7 +280,15 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
if let text = content as? String {
|
||||
initialText += "\(text) "
|
||||
} else if let image = content as? UIImage {
|
||||
mediasImages.append(.init(image: image, mediaAttachment: nil, error: nil))
|
||||
mediasImages.append(.init(image: image,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: nil,
|
||||
error: nil))
|
||||
} else if let video = content as? MovieFileTranseferable {
|
||||
mediasImages.append(.init(image: nil,
|
||||
movieTransferable: video,
|
||||
mediaAttachment: nil,
|
||||
error: nil))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
@ -380,7 +394,7 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
|
||||
// MARK: - Media related function
|
||||
|
||||
private func indexOf(container: ImageContainer) -> Int? {
|
||||
private func indexOf(container: StatusEditorMediaContainer) -> Int? {
|
||||
mediasImages.firstIndex(where: { $0.id == container.id })
|
||||
}
|
||||
|
||||
|
@ -388,18 +402,35 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
mediasImages = []
|
||||
|
||||
Task {
|
||||
var medias: [ImageContainer] = []
|
||||
var medias: [StatusEditorMediaContainer] = []
|
||||
for media in selectedMedias {
|
||||
var file: (any Transferable)?
|
||||
do {
|
||||
if let data = try await media.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data)
|
||||
{
|
||||
medias.append(.init(image: image, mediaAttachment: nil, error: nil))
|
||||
file = try await media.loadTransferable(type: ImageFileTranseferable.self)
|
||||
if file == nil {
|
||||
file = try await media.loadTransferable(type: MovieFileTranseferable.self)
|
||||
}
|
||||
} catch {
|
||||
medias.append(.init(image: nil, mediaAttachment: nil, error: error))
|
||||
medias.append(.init(image: nil,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: nil,
|
||||
error: error))
|
||||
}
|
||||
|
||||
if var imageFile = file as? ImageFileTranseferable,
|
||||
let image = imageFile.image {
|
||||
medias.append(.init(image: image,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: nil,
|
||||
error: nil))
|
||||
} else if let videoFile = file as? MovieFileTranseferable {
|
||||
medias.append(.init(image: nil,
|
||||
movieTransferable: videoFile,
|
||||
mediaAttachment: nil,
|
||||
error: nil))
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.mediasImages = medias
|
||||
self?.processMediasToUpload()
|
||||
|
@ -419,45 +450,91 @@ public class StatusEditorViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func upload(container: ImageContainer) async {
|
||||
func upload(container: StatusEditorMediaContainer) async {
|
||||
if let index = indexOf(container: container) {
|
||||
let originalContainer = mediasImages[index]
|
||||
let newContainer = ImageContainer(image: originalContainer.image, mediaAttachment: nil, error: nil)
|
||||
let newContainer = StatusEditorMediaContainer(image: originalContainer.image,
|
||||
movieTransferable: originalContainer.movieTransferable,
|
||||
mediaAttachment: nil,
|
||||
error: nil)
|
||||
mediasImages[index] = newContainer
|
||||
do {
|
||||
if let data = originalContainer.image?.jpegData(compressionQuality: 0.90) {
|
||||
let uploadedMedia = try await uploadMedia(data: data)
|
||||
if let index = indexOf(container: newContainer) {
|
||||
if let index = indexOf(container: newContainer) {
|
||||
if let image = originalContainer.image,
|
||||
let data = image.jpegData(compressionQuality: 0.90) {
|
||||
let uploadedMedia = try await uploadMedia(data: data, mimeType: "image/jpeg")
|
||||
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: uploadedMedia,
|
||||
error: nil)
|
||||
} else if let videoURL = originalContainer.movieTransferable?.url,
|
||||
let data = try? Data(contentsOf: videoURL) {
|
||||
let uploadedMedia = try await uploadMedia(data: data, mimeType: videoURL.mimeType())
|
||||
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
|
||||
mediaAttachment: uploadedMedia,
|
||||
error: nil)
|
||||
movieTransferable: originalContainer.movieTransferable,
|
||||
mediaAttachment: uploadedMedia,
|
||||
error: nil)
|
||||
if let uploadedMedia, uploadedMedia.url == nil {
|
||||
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if let index = indexOf(container: newContainer) {
|
||||
mediasImages[index] = .init(image: originalContainer.image, mediaAttachment: nil, error: error)
|
||||
mediasImages[index] = .init(image: originalContainer.image,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: nil,
|
||||
error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
|
||||
Task {
|
||||
repeat {
|
||||
if let client,
|
||||
let index = mediasImages.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id }) {
|
||||
guard mediasImages[index].mediaAttachment?.url == nil else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
|
||||
description: nil))
|
||||
if newAttachement.url != nil {
|
||||
let oldContainer = mediasImages[index]
|
||||
mediasImages[index] = .init(image: oldContainer.image,
|
||||
movieTransferable: oldContainer.movieTransferable,
|
||||
mediaAttachment: newAttachement,
|
||||
error: nil)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
} while (!Task.isCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
func addDescription(container: ImageContainer, description: String) async {
|
||||
func addDescription(container: StatusEditorMediaContainer, description: String) async {
|
||||
guard let client, let attachment = container.mediaAttachment else { return }
|
||||
if let index = indexOf(container: container) {
|
||||
do {
|
||||
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
|
||||
description: description))
|
||||
mediasImages[index] = .init(image: nil, mediaAttachment: media, error: nil)
|
||||
mediasImages[index] = .init(image: nil,
|
||||
movieTransferable: nil,
|
||||
mediaAttachment: media,
|
||||
error: nil)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadMedia(data: Data) async throws -> MediaAttachment? {
|
||||
private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
|
||||
guard let client else { return nil }
|
||||
return try await client.mediaUpload(endpoint: Media.medias,
|
||||
version: .v2,
|
||||
method: "POST",
|
||||
mimeType: "image/jpeg",
|
||||
mimeType: mimeType,
|
||||
filename: "file",
|
||||
data: data)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue