Skip to content

Add a dedicated screen for uploading media #24656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: media-upload-blocking-ui
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions WordPress/Classes/Services/MediaUploadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ import WordPressData
import WordPressCore
import WordPressAPI
import WordPressAPIInternal
import UniformTypeIdentifiers

protocol MediaUploadServiceEventObserver: Sendable {
func handle(event: MediaUploadService.Event, asset: ExportableAsset, service: MediaUploadService)
}

actor MediaUploadService {
enum Event: Sendable {
case overallProgress(Progress)
case thumbnail(URL)
}

nonisolated static let progressUserInfoKeyThumbnail = ProgressUserInfoKey(rawValue: "MediaUploadService.thumbnail")

private let coreDataStack: CoreDataStackSwift
private let blog: TaggedManagedObjectID<Blog>
private let client: WordPressClient
Expand All @@ -21,21 +33,38 @@ actor MediaUploadService {
/// - asset: The asset to upload.
/// - progress: A progress object to track the upload progress.
/// - Returns: The saved Media instance.
func uploadToMediaLibrary(asset: ExportableAsset, fulfilling progress: Progress? = nil) async throws -> TaggedManagedObjectID<Media> {
precondition(progress == nil || progress!.totalUnitCount > 0)

let overallProgress = progress ?? Progress.discreteProgress(totalUnitCount: 100)
func uploadToMediaLibrary(asset: ExportableAsset, observer: MediaUploadServiceEventObserver) async throws -> TaggedManagedObjectID<Media> {
let overallProgress = Progress.discreteProgress(totalUnitCount: 100)
overallProgress.completedUnitCount = 0

let export = try await exportAsset(asset, parentProgress: overallProgress)
await MainActor.run {
observer.handle(event: .overallProgress(overallProgress), asset: asset, service: self)
}

let export = try await exportAsset(asset, parentProgress: overallProgress, withPendingUnitCount: 10)

try Task.checkCancellation()

if let thumbnail = await thumbnail(of: export) {
await MainActor.run {
observer.handle(event: .thumbnail(thumbnail), asset: asset, service: self)
}
}

let uploadingProgress = Progress.discreteProgress(totalUnitCount: 100)
overallProgress.addChild(uploadingProgress, withPendingUnitCount: Int64((1.0 - overallProgress.fractionCompleted) * Double(overallProgress.totalUnitCount)))
let uploaded = try await client.api.uploadMedia(
params: MediaCreateParams(from: export),
fromLocalFileURL: export.url,
fulfilling: uploadingProgress
).data
overallProgress.addChild(uploadingProgress, withPendingUnitCount: 90)

let uploaded = try await withTaskCancellationHandler {
try await client.api.uploadMedia(
params: MediaCreateParams(from: export),
fromLocalFileURL: export.url,
fulfilling: uploadingProgress
).data
} onCancel: {
uploadingProgress.cancel()
}

try Task.checkCancellation()

let media = try await coreDataStack.performAndSave { [blogID = blog] context in
let blog = try context.existingObject(with: blogID)
Expand All @@ -57,7 +86,7 @@ actor MediaUploadService {

private extension MediaUploadService {

func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress) async throws -> MediaExport {
func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress, withPendingUnitCount unitCount: Int64) async throws -> MediaExport {
let options = try await coreDataStack.performQuery { [blogID = blog] context in
let blog = try context.existingObject(with: blogID)
let allowableFileExtensions = blog.allowedFileTypes as? Set<String> ?? []
Expand All @@ -79,7 +108,7 @@ private extension MediaUploadService {
}
)
// The "export" part covers the initial 10% of the overall progress.
parentProgress.addChild(progress, withPendingUnitCount: progress.totalUnitCount / 10)
parentProgress.addChild(progress, withPendingUnitCount: unitCount)
}
}

Expand Down Expand Up @@ -113,7 +142,7 @@ private extension MediaUploadService {

func configureMedia(_ media: Media, withExport export: MediaExport) {
media.absoluteLocalURL = export.url
media.filename = export.url.lastPathComponent
media.filename = export.filename ?? export.url.lastPathComponent
media.mediaType = (export.url as NSURL).assetMediaType

if let fileSize = export.fileSize {
Expand Down Expand Up @@ -187,6 +216,25 @@ private extension MediaUploadService {
return nil
}

func thumbnail(of exported: MediaExport) async -> URL? {
let exporter = MediaThumbnailExporter()
exporter.mediaDirectoryType = .cache
exporter.options.preferredSize = await MediaImageService.getThumbnailSize(for: exported.size ?? CGSizeMake(100, 100), size: .small)
exporter.options.scale = 1 // In pixels

if exported.url.isGif {
exporter.options.thumbnailImageType = UTType.gif.identifier
}

guard exporter.supportsThumbnailExport(forFile: exported.url),
let (_, thumbnail) = try? await exporter.exportThumbnail(forFileURL: exported.url)
else {
return nil
}

return thumbnail.url
}

func updateMedia(_ media: Media, with remote: MediaWithEditContext) {
media.mediaID = NSNumber(value: remote.id)
media.remoteURL = remote.sourceUrl
Expand Down Expand Up @@ -245,7 +293,7 @@ private extension MediaCreateParams {
dateGmt: nil,
slug: nil,
status: nil,
title: export.url.lastPathComponent, // TODO: Add a `filename` property to `MediaExport`.
title: export.filename ?? export.url.lastPathComponent,
author: nil,
commentStatus: nil,
pingStatus: nil,
Expand Down
4 changes: 4 additions & 0 deletions WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public enum FeatureFlag: Int, CaseIterable {
case pluginManagementOverhaul
case nativeJetpackConnection
case newsletterSubscribers
case newUploadMedia

/// Returns a boolean indicating if the feature is enabled.
///
Expand Down Expand Up @@ -82,6 +83,8 @@ public enum FeatureFlag: Int, CaseIterable {
return BuildConfiguration.current == .debug
case .newsletterSubscribers:
return true
case .newUploadMedia:
return false
}
}

Expand Down Expand Up @@ -125,6 +128,7 @@ extension FeatureFlag {
case .readerGutenbergCommentComposer: "Gutenberg Comment Composer"
case .nativeJetpackConnection: "Native Jetpack Connection"
case .newsletterSubscribers: "Newsletter Subscribers"
case .newUploadMedia: "New Upload Media UI"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ final class ItemProviderMediaExporter: MediaExporter {
try FileManager.default.copyItem(at: original, to: url)

let pixelSize = url.pixelSize
let media = MediaExport(url: url, fileSize: url.fileSize, width: pixelSize.width, height: pixelSize.height, duration: nil)
let media = MediaExport(url: url, filename: provider.suggestedName, fileSize: url.fileSize, width: pixelSize.width, height: pixelSize.height, duration: nil)
let exportProgress = Progress(totalUnitCount: 1)
exportProgress.completedUnitCount = 1
progress.addChild(exportProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone)
Expand Down
13 changes: 11 additions & 2 deletions WordPress/Classes/Utility/Media/MediaExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ enum MediaExportProgressUnits {
///
class MediaExport {
/// The resulting file URL of an export.
///
let url: URL
/// The name of the original file.
let filename: String?
/// The resulting file size in bytes of the export.
let fileSize: Int64?
/// The pixel width of the media exported.
Expand All @@ -24,14 +25,22 @@ class MediaExport {
/// A caption to be added to the media item.
let caption: String?

init(url: URL, fileSize: Int64?, width: CGFloat?, height: CGFloat?, duration: TimeInterval?, caption: String? = nil) {
init(url: URL, filename: String?, fileSize: Int64?, width: CGFloat?, height: CGFloat?, duration: TimeInterval?, caption: String? = nil) {
self.url = url
self.filename = filename
self.fileSize = fileSize
self.height = height
self.width = width
self.duration = duration
self.caption = caption
}

var size: CGSize? {
if let width, let height {
return CGSizeMake(width, height)
}
return nil
}
}

/// Completion block with an AssetExport.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class MediaExternalExporter: MediaExporter {
fileExtension: "gif")
try data.write(to: mediaURL)
onCompletion(MediaExport(url: mediaURL,
filename: asset.name,
fileSize: mediaURL.fileSize,
width: mediaURL.pixelSize.width,
height: mediaURL.pixelSize.height,
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Utility/Media/MediaImageExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class MediaImageExporter: MediaExporter {
writer.nullifyGPSData = options.stripsGeoLocationIfNeeded
let result = try writer.writeImageSource(source)
onCompletion(MediaExport(url: url,
filename: filename,
fileSize: url.fileSize,
width: result.width,
height: result.height,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ class MediaThumbnailExporter: MediaExporter {
try fileManager.moveItem(at: export.url, to: thumbnail)
// Configure with the new URL
let thumbnailExport = MediaExport(url: thumbnail,
filename: nil,
fileSize: export.fileSize,
width: export.width,
height: export.height,
Expand Down
2 changes: 2 additions & 0 deletions WordPress/Classes/Utility/Media/MediaURLExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ class MediaURLExporter: MediaExporter {
fileExtension: "gif")
try fileManager.copyItem(at: url, to: mediaURL)
onCompletion(MediaExport(url: mediaURL,
filename: nil,
fileSize: mediaURL.fileSize,
width: mediaURL.pixelSize.width,
height: mediaURL.pixelSize.height,
Expand All @@ -172,6 +173,7 @@ class MediaURLExporter: MediaExporter {
fileExtension: fileExtension)
try fileManager.copyItem(at: url, to: mediaURL)
onCompletion(MediaExport(url: mediaURL,
filename: nil,
fileSize: mediaURL.fileSize,
width: nil,
height: nil,
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Utility/Media/MediaVideoExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class MediaVideoExporter: MediaExporter {
}
progress.completedUnitCount = MediaExportProgressUnits.done
onCompletion(MediaExport(url: mediaURL,
filename: filename,
fileSize: mediaURL.fileSize,
width: mediaURL.pixelSize.width,
height: mediaURL.pixelSize.height,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import UIKit
import Photos
import PhotosUI
import WordPressCore
import WordPressData
import WordPressShared
import SwiftUI

final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDelegate, ImagePickerControllerDelegate, ExternalMediaPickerViewDelegate, UIDocumentPickerDelegate, ImagePlaygroundPickerDelegate {
let blog: Blog
let coordinator: MediaCoordinator
weak var viewController: UIViewController?

init(blog: Blog, coordinator: MediaCoordinator) {
private var mediaUploadService: MediaUploadService?

init(blog: Blog, coordinator: MediaCoordinator, viewController: UIViewController) {
self.blog = blog
self.coordinator = coordinator
self.viewController = viewController

if FeatureFlag.newUploadMedia.enabled, let client = try? WordPressClient(site: WordPressSite(blog: blog)) {
self.mediaUploadService = MediaUploadService(
coreDataStack: ContextManager.shared,
blog: TaggedManagedObjectID(blog),
client: client
)
}
}

func makeMenu(for viewController: UIViewController) -> UIMenu {
Expand Down Expand Up @@ -47,6 +61,18 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel
.showPhotosPicker(delegate: self)
}

private func addMedia(_ assets: [ExportableAsset], analytics: MediaAnalyticsInfo) {
if let mediaUploadService, let viewController {
let mediaUploadingView = MediaUploadingView(mediaUploadService: mediaUploadService, assets: assets)
let hostingController = UIHostingController(rootView: mediaUploadingView)
viewController.present(hostingController, animated: true)
} else {
for asset in assets {
coordinator.addMedia(from: asset, to: blog, analyticsInfo: analytics)
}
}
}

// MARK: - PHPickerViewControllerDelegate

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
Expand All @@ -56,58 +82,55 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel
return
}

for result in results {
let info = MediaAnalyticsInfo(origin: .mediaLibrary(.deviceLibrary), selectionMethod: .fullScreenPicker)
coordinator.addMedia(from: result.itemProvider, to: blog, analyticsInfo: info)
}
let assets = results.map { $0.itemProvider }
addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.deviceLibrary), selectionMethod: .fullScreenPicker))
}

// MARK: - ImagePlaygroundPickerDelegate

func imagePlaygroundViewController(_ viewController: UIViewController, didCreateImageAt imageURL: URL) {
viewController.presentingViewController?.dismiss(animated: true)

coordinator.addMedia(
from: MediaPickerMenu.makeItemProvider(with: imageURL),
to: blog,
analyticsInfo: MediaAnalyticsInfo(origin: .mediaLibrary(.imagePlayground), selectionMethod: .fullScreenPicker)
)
let asset = MediaPickerMenu.makeItemProvider(with: imageURL)
addMedia([asset], analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.imagePlayground), selectionMethod: .fullScreenPicker))
}

// MARK: - ImagePickerControllerDelegate

func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.presentingViewController?.dismiss(animated: true)

func addAsset(from asset: ExportableAsset) {
let info = MediaAnalyticsInfo(origin: .mediaLibrary(.camera), selectionMethod: .fullScreenPicker)
coordinator.addMedia(from: asset, to: blog, analyticsInfo: info)
}
var assets = [ExportableAsset]()

guard let mediaType = info[.mediaType] as? String else {
return
}
switch mediaType {
case UTType.image.identifier:
if let image = info[.originalImage] as? UIImage {
addAsset(from: image)
assets.append(image)
}
case UTType.movie.identifier:
if let videoURL = info[.mediaURL] as? URL {
addAsset(from: videoURL as NSURL)
assets.append(videoURL as NSURL)
}
default:
break
}

guard !assets.isEmpty else { return }

addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.camera), selectionMethod: .fullScreenPicker))
}

// MARK: - ExternalMediaPickerViewDelegate

func externalMediaPickerViewController(_ viewController: ExternalMediaPickerViewController, didFinishWithSelection assets: [ExternalMediaAsset]) {
viewController.presentingViewController?.dismiss(animated: true)
for asset in assets {
let info = MediaAnalyticsInfo(origin: .mediaLibrary(viewController.source), selectionMethod: .fullScreenPicker)
coordinator.addMedia(from: asset, to: blog, analyticsInfo: info)

addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(viewController.source), selectionMethod: .fullScreenPicker))

for _ in assets {
switch viewController.source {
case .stockPhotos:
WPAnalytics.track(.stockMediaUploaded)
Expand Down Expand Up @@ -137,10 +160,7 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel
}

func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
for documentURL in urls as [NSURL] {
let info = MediaAnalyticsInfo(origin: .mediaLibrary(.otherApps), selectionMethod: .documentPicker)
coordinator.addMedia(from: documentURL, to: blog, analyticsInfo: info)
}
addMedia(urls.map { $0 as NSURL }, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.otherApps), selectionMethod: .documentPicker))
}

func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
Expand Down
Loading