From eec5637c1c29717d12300518061a015a5a00555d Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 22 Jan 2023 09:09:35 +0100 Subject: [PATCH] Composer / Share sheet: add video upload support close #154 --- IceCubesShareExtension/Info.plist | 2 + .../Models/Sources/Models/ServerError.swift | 2 +- Packages/Network/Sources/Network/Client.swift | 9 +- .../StatusEditorAccessoryView.swift | 2 +- .../StatusEditorMediaContainer.swift | 13 ++ .../StatusEditorMediaEditView.swift | 2 +- .../Components/StatusEditorMediaView.swift | 62 ++++++-- .../StatusEditorUTTypeSupported.swift | 76 +++++++++- .../Status/Editor/StatusEditorView.swift | 7 + .../Status/Editor/StatusEditorViewModel.swift | 139 ++++++++++++++---- 10 files changed, 264 insertions(+), 50 deletions(-) create mode 100644 Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift diff --git a/IceCubesShareExtension/Info.plist b/IceCubesShareExtension/Info.plist index e6477ee3..d4bc10d3 100644 --- a/IceCubesShareExtension/Info.plist +++ b/IceCubesShareExtension/Info.plist @@ -12,6 +12,8 @@ 4 NSExtensionActivationSupportsText + NSExtensionActivationSupportsMovieWithMaxCount + 1 NSExtensionActivationSupportsWebPageWithMaxCount 1 NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/Packages/Models/Sources/Models/ServerError.swift b/Packages/Models/Sources/Models/ServerError.swift index a8c13fdb..0a4db626 100644 --- a/Packages/Models/Sources/Models/ServerError.swift +++ b/Packages/Models/Sources/Models/ServerError.swift @@ -1,5 +1,5 @@ import Foundation -public struct ServerError: Decodable { +public struct ServerError: Decodable, Error { public let error: String? } diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 64c77a1b..88dc8d0b 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -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 { diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift index 94d4e938..ce57989c 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift @@ -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) diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift new file mode 100644 index 00000000..b24ac904 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift @@ -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? +} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift index acb11f1a..4ccf512f 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift @@ -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 = "" diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift index ce99a5dc..b0ace485 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift @@ -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) + } } diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift index 845990d7..8efb59d4 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift @@ -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" + } + } } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 2a50e3c0..31ef9b84 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -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 diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 847a063b..f253ac82 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -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) }