Composer / Share sheet: add video upload support close #154

This commit is contained in:
Thomas Ricouard 2023-01-22 09:09:35 +01:00
parent fd28864063
commit eec5637c1c
10 changed files with 264 additions and 50 deletions

View File

@ -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>

View File

@ -1,5 +1,5 @@
import Foundation
public struct ServerError: Decodable {
public struct ServerError: Decodable, Error {
public let error: String?
}

View File

@ -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 {

View File

@ -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)

View File

@ -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?
}

View File

@ -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 = ""

View File

@ -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)
}
}

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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)
}