diff --git a/Example/Media-Example/Views/BrowserSection.swift b/Example/Media-Example/Views/BrowserSection.swift index 55b1e5a..d8f3136 100644 --- a/Example/Media-Example/Views/BrowserSection.swift +++ b/Example/Media-Example/Views/BrowserSection.swift @@ -6,15 +6,38 @@ // Copyright © 2021 Christian Elies. All rights reserved. // +import AVKit +import Combine +import Foundation import MediaCore import MediaSwiftUI +import Photos import SwiftUI +extension URL: Identifiable { + public var id: String { absoluteString } +} + +extension UIImage: Identifiable { + public var id: UIImage { self } +} + +extension PHLivePhoto: Identifiable { + public var id: PHLivePhoto { self } +} + +struct Garbage { + static var cancellables: [AnyCancellable] = [] +} + struct BrowserSection: View { @State private var isLivePhotoBrowserViewVisible = false @State private var isMediaBrowserViewVisible = false @State private var isPhotoBrowserViewVisible = false @State private var isVideoBrowserViewVisible = false + @State private var playerURL: URL? + @State private var image: UIImage? + @State private var livePhoto: PHLivePhoto? var body: some View { Section(header: Label("Browser", systemImage: "photo.on.rectangle.angled")) { @@ -26,8 +49,16 @@ struct BrowserSection: View { .fullScreenCover(isPresented: $isLivePhotoBrowserViewVisible, onDismiss: { isLivePhotoBrowserViewVisible = false }) { - LivePhoto.browser(selectionLimit: 0) { _ in } + LivePhoto.browser(isPresented: $isLivePhotoBrowserViewVisible, selectionLimit: 0, handleLivePhotoBrowserResult) } + .background( + EmptyView() + .sheet(item: $livePhoto, onDismiss: { + livePhoto = nil + }) { livePhoto in + PhotosUILivePhotoView(phLivePhoto: livePhoto) + } + ) Button(action: { isMediaBrowserViewVisible = true @@ -37,7 +68,7 @@ struct BrowserSection: View { .fullScreenCover(isPresented: $isMediaBrowserViewVisible, onDismiss: { isMediaBrowserViewVisible = false }) { - Media.browser(selectionLimit: 0) { _ in } + Media.browser(isPresented: $isMediaBrowserViewVisible, selectionLimit: 0, handleMediaBrowserResult) } Button(action: { @@ -48,8 +79,18 @@ struct BrowserSection: View { .fullScreenCover(isPresented: $isPhotoBrowserViewVisible, onDismiss: { isPhotoBrowserViewVisible = false }) { - Photo.browser(selectionLimit: 0) { _ in } + Photo.browser(isPresented: $isPhotoBrowserViewVisible, selectionLimit: 0, handlePhotoBrowserResult) } + .background( + EmptyView() + .sheet(item: $image, onDismiss: { + image = nil + }) { uiImage in + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } + ) Button(action: { isVideoBrowserViewVisible = true @@ -59,8 +100,87 @@ struct BrowserSection: View { .fullScreenCover(isPresented: $isVideoBrowserViewVisible, onDismiss: { isVideoBrowserViewVisible = false }) { - Video.browser(selectionLimit: 0) { _ in } + Video.browser(isPresented: $isVideoBrowserViewVisible, selectionLimit: 0, handleVideoBrowserResult) + } + .background( + EmptyView() + .sheet(item: $playerURL, onDismiss: { + playerURL = nil + }) { url in + VideoPlayer(player: .init(url: url)) + } + ) + } + } +} + +private extension BrowserSection { + func handleVideoBrowserResult(_ result: Result<[BrowserResult], Swift.Error>) { + switch result { + case let .success(browserResult): + switch browserResult.first { + case let .data(url): + playerURL = url + default: () + } + default: () + } + } + + func handlePhotoBrowserResult(_ result: Result<[BrowserResult], Swift.Error>) { + switch result { + case let .success(browserResult): + switch browserResult.first { + case let .data(uiImage): + image = uiImage + default: () + } + default: () + } + } + + func handleLivePhotoBrowserResult(_ result: Result<[BrowserResult], Swift.Error>) { + switch result { + case let .success(browserResult): + switch browserResult.first { + case let .data(phLivePhoto): + livePhoto = phLivePhoto + default: () + } + default: () + } + } + + func handleMediaBrowserResult(_ result: Result<[BrowserResult], Swift.Error>) { + switch result { + case let .success(browserResult): + switch browserResult.first { + case let .data(itemProvider): + if itemProvider.canLoadObject(ofClass: PHLivePhoto.self) { + itemProvider.loadLivePhoto() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { phLivePhoto in + livePhoto = phLivePhoto + } + .store(in: &Garbage.cancellables) + } else if itemProvider.canLoadObject(ofClass: UIImage.self) { + itemProvider.loadImage() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { uiImage in + image = uiImage + } + .store(in: &Garbage.cancellables) + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + itemProvider.loadVideo() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { url in + playerURL = url + } + .store(in: &Garbage.cancellables) + } + default: () } + default: () } } } diff --git a/Example/Media-Example/Views/PermissionsSection.swift b/Example/Media-Example/Views/PermissionsSection.swift index cfc024a..cc19206 100644 --- a/Example/Media-Example/Views/PermissionsSection.swift +++ b/Example/Media-Example/Views/PermissionsSection.swift @@ -8,23 +8,26 @@ import AVFoundation import MediaCore +import Photos import SwiftUI struct PermissionsSection: View { @State private var isLimitedLibraryPickerPresented = false + @State private var cameraPermission: AVAuthorizationStatus = .notDetermined + @State private var mediaPermission: PHAuthorizationStatus = .notDetermined var requestedPermission: (Result) -> Void - + var body: some View { Section(header: Text("Permissions")) { Button(action: { - Media.requestCameraPermission { result in - debugPrint(result) + Media.requestCameraPermission { _ in + cameraPermission = Media.currentCameraPermission } }) { HStack { Text("Trigger camera permission request") - Toggle("", isOn: .constant(Media.currentCameraPermission == .authorized)) + Toggle("", isOn: .constant(cameraPermission == .authorized)) .disabled(true) } } @@ -38,17 +41,24 @@ struct PermissionsSection: View { }) { HStack { Text("Trigger photo library permission request") - Toggle("", isOn: .constant(Media.currentPermission == .authorized)) + Toggle("", isOn: .constant(mediaPermission == .authorized)) .disabled(true) } } .background(PHPicker(isPresented: $isLimitedLibraryPickerPresented)) } + .onAppear { + cameraPermission = Media.currentCameraPermission + mediaPermission = Media.currentPermission + } } } private extension PermissionsSection { func requestPermission() { - Media.requestPermission(requestedPermission) + Media.requestPermission { result in + mediaPermission = Media.currentPermission + requestedPermission(result) + } } } diff --git a/README.md b/README.md index b81bee3..b3c6e2b 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,14 @@ Use the `LazyVideos` wrapper if you want to fetch videos only on demand (request - **SwiftUI only**: `video.view` (*some View*) *Get a ready-to-use **SwiftUI** view for displaying the video in your UI* + +- **PHPicker**: SwiftUI port of the `PHPickerViewController` + + *Use the `PHPickerViewController` in your `SwiftUI` applications* + +- **PhotosUILivePhotoView**: SwiftUI port of the `PHLivePhotoView` + + *Use the `PHLivePhotoView` in your `SwiftUI` applications* ### 🚀 `@propertyWrapper` diff --git a/Sources/MediaCore/API/Media/Media.swift b/Sources/MediaCore/API/Media/Media.swift index 705856e..aef0f1e 100644 --- a/Sources/MediaCore/API/Media/Media.swift +++ b/Sources/MediaCore/API/Media/Media.swift @@ -38,6 +38,7 @@ public struct Media { /// Returns the current camera permission. /// + @available(tvOS, unavailable) public static var currentCameraPermission: AVAuthorizationStatus { AVCaptureDevice.authorizationStatus(for: .video) } diff --git a/Sources/MediaCore/API/PublicAliases.swift b/Sources/MediaCore/API/MediaCoreAliases.swift similarity index 74% rename from Sources/MediaCore/API/PublicAliases.swift rename to Sources/MediaCore/API/MediaCoreAliases.swift index 2c6babe..91cac82 100644 --- a/Sources/MediaCore/API/PublicAliases.swift +++ b/Sources/MediaCore/API/MediaCoreAliases.swift @@ -1,6 +1,6 @@ // -// Aliases.swift -// +// MediaCoreAliases.swift +// MediaCore // // Created by Christian Elies on 30.11.19. // @@ -18,13 +18,9 @@ public typealias MediaSubtype = PHAssetMediaSubtype public typealias ResultDataCompletion = (Result) -> Void public typealias ResultGenericCompletion = (Result) -> Void public typealias ResultLivePhotoCompletion = (Result) -> Void -public typealias ResultLivePhotosCompletion = (Result<[LivePhoto], Error>) -> Void public typealias RequestLivePhotoResultHandler = (PHLivePhoto?, [AnyHashable : Any]) -> Void public typealias ResultPHAssetCompletion = (Result) -> Void -public typealias ResultPHAssetsCompletion = (Result<[PHAsset], Swift.Error>) -> Void public typealias ResultPhotoCompletion = (Result) -> Void -public typealias ResultPhotosCompletion = (Result<[Photo], Swift.Error>) -> Void public typealias ResultURLCompletion = (Result) -> Void public typealias ResultVideoCompletion = (Result) -> Void -public typealias ResultVideosCompletion = (Result<[Video], Swift.Error>) -> Void public typealias ResultVoidCompletion = (Result) -> Void diff --git a/Sources/MediaCore/API/Video/Video.swift b/Sources/MediaCore/API/Video/Video.swift index c9b2838..993dc9a 100644 --- a/Sources/MediaCore/API/Video/Video.swift +++ b/Sources/MediaCore/API/Video/Video.swift @@ -153,7 +153,11 @@ public extension Video { let copyCGImageResult: Result = Result { let cgImage = try generator.copyCGImage(at: requestedTime, actualTime: nil) + #if os(macOS) + return UniversalImage(cgImage: cgImage, size: .init(width: cgImage.width, height: cgImage.height)) + #else return UniversalImage(cgImage: cgImage) + #endif } DispatchQueue.main.async { diff --git a/Sources/MediaSwiftUI/API/LivePhoto/LivePhoto+SwiftUI.swift b/Sources/MediaSwiftUI/API/LivePhoto/LivePhoto+SwiftUI.swift index dd02b39..314b40f 100644 --- a/Sources/MediaSwiftUI/API/LivePhoto/LivePhoto+SwiftUI.swift +++ b/Sources/MediaSwiftUI/API/LivePhoto/LivePhoto+SwiftUI.swift @@ -6,6 +6,7 @@ // #if canImport(SwiftUI) +import Combine import MediaCore import PhotosUI import SwiftUI @@ -50,41 +51,67 @@ public extension LivePhoto { /// Creates a ready-to-use `SwiftUI` view for browsing `LivePhoto`s in the photo library /// If an error occurs during initialization a `SwiftUI.Text` with the `localizedDescription` is shown. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter completion: A closure which gets the selected `LivePhoto` on `success` or `Error` on `failure`. /// /// - Returns: some View - static func browser(selectionLimit: Int = 1, _ completion: @escaping ResultLivePhotosCompletion) -> some View { - browser(selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) + static func browser(isPresented: Binding, selectionLimit: Int = 1, _ completion: @escaping ResultLivePhotosCompletion) -> some View { + browser(isPresented: isPresented, selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) } /// Creates a ready-to-use `SwiftUI` view for browsing `LivePhoto`s in the photo library /// If an error occurs during initialization the provided `errorView` closure is used to construct the view to be displayed. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter errorView: A closure that constructs an error view for the given error. /// - Parameter completion: A closure which gets the selected `LivePhoto` on `success` or `Error` on `failure`. /// /// - Returns: some View - @ViewBuilder static func browser(selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultLivePhotosCompletion) -> some View { + @ViewBuilder static func browser(isPresented: Binding, selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultLivePhotosCompletion) -> some View { if #available(iOS 14, macOS 11, *) { - PHPicker(configuration: { - var configuration = PHPickerConfiguration() + PHPicker(isPresented: isPresented, configuration: { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .livePhotos configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current return configuration }()) { result in switch result { case let .success(result): - let result = Result { - try result.compactMap { object -> LivePhoto? in - guard let assetIdentifier = object.assetIdentifier else { - return nil + if Media.currentPermission == .authorized { + let result = Result { + try result.compactMap { object -> BrowserResult? in + guard let assetIdentifier = object.assetIdentifier else { + return nil + } + guard let livePhoto = try LivePhoto.with(identifier: .init(stringLiteral: assetIdentifier)) else { + return nil + } + return .media(livePhoto) } - return try LivePhoto.with(identifier: .init(stringLiteral: assetIdentifier)) + } + completion(result) + } else { + DispatchQueue.global(qos: .userInitiated).async { + let loadLivePhotos = result.map { $0.itemProvider.loadLivePhoto() } + Publishers.MergeMany(loadLivePhotos) + .collect() + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case let .failure(error): + completion(.failure(error)) + case .finished: () + } + } receiveValue: { urls in + let browserResults = urls.map { BrowserResult.data($0) } + completion(.success(browserResults)) + } + .store(in: &Garbage.cancellables) } } - completion(result) case let .failure(error): () completion(.failure(error)) } @@ -94,7 +121,7 @@ public extension LivePhoto { try ViewCreator.browser(mediaTypes: [.image, .livePhoto]) { (result: Result) in switch result { case let .success(livePhoto): - completion(.success([livePhoto])) + completion(.success([.media(livePhoto)])) case let .failure(error): completion(.failure(error)) } diff --git a/Sources/MediaSwiftUI/API/LivePhoto/PhotosUILivePhotoView.swift b/Sources/MediaSwiftUI/API/LivePhoto/PhotosUILivePhotoView.swift new file mode 100644 index 0000000..d473998 --- /dev/null +++ b/Sources/MediaSwiftUI/API/LivePhoto/PhotosUILivePhotoView.swift @@ -0,0 +1,32 @@ +// +// PhotosUILivePhotoView.swift +// MediaSwiftUI +// +// Created by Christian Elies on 28.11.19. +// + +#if canImport(SwiftUI) && !os(macOS) && !targetEnvironment(macCatalyst) +import PhotosUI +import SwiftUI + +@available(iOS 13, tvOS 13, *) +/// `SwiftUI` port of the `PHLivePhotoView`. +public struct PhotosUILivePhotoView: UIViewRepresentable { + let phLivePhoto: PHLivePhoto + + /// Initializes the view with the given live photo. + /// + /// - Parameter phLivePhoto: The live photo which should be displayed. + public init(phLivePhoto: PHLivePhoto) { + self.phLivePhoto = phLivePhoto + } + + public func makeUIView(context: UIViewRepresentableContext) -> PHLivePhotoView { + let livePhotoView = PHLivePhotoView() + livePhotoView.livePhoto = phLivePhoto + return livePhotoView + } + + public func updateUIView(_ uiView: PHLivePhotoView, context: UIViewRepresentableContext) {} +} +#endif diff --git a/Sources/MediaSwiftUI/API/Media/Media+SwiftUI.swift b/Sources/MediaSwiftUI/API/Media/Media+SwiftUI.swift index 2f3af38..c882375 100644 --- a/Sources/MediaSwiftUI/API/Media/Media+SwiftUI.swift +++ b/Sources/MediaSwiftUI/API/Media/Media+SwiftUI.swift @@ -16,38 +16,47 @@ public extension Media { /// Creates a ready-to-use `SwiftUI` view for browsing the photo library /// If an error occurs during initialization a `SwiftUI.Text` with the `localizedDescription` is shown. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter completion: A closure which gets the selected `PHAsset` on `success` or `Error ` on `failure`. /// /// - Returns: some View - static func browser(selectionLimit: Int = 1, _ completion: @escaping ResultPHAssetsCompletion) -> some View { - browser(selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) + static func browser(isPresented: Binding, selectionLimit: Int = 1, _ completion: @escaping ResultPHAssetsCompletion) -> some View { + browser(isPresented: isPresented, selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) } /// Creates a ready-to-use `SwiftUI` view for browsing the photo library /// If an error occurs during initialization the provided `errorView` closure is used to construct the view to be displayed. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter errorView: A closure that constructs an error view for the given error. /// - Parameter completion: A closure which gets the selected `PHAsset` on `success` or `Error ` on `failure`. /// /// - Returns: some View - @ViewBuilder static func browser(selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultPHAssetsCompletion) -> some View { + @ViewBuilder static func browser(isPresented: Binding, selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultPHAssetsCompletion) -> some View { if #available(iOS 14, macOS 11, *) { - PHPicker(configuration: { - var configuration = PHPickerConfiguration() + PHPicker(isPresented: isPresented, configuration: { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current return configuration }()) { result in switch result { case let .success(result): - let identifiers = result.compactMap { $0.assetIdentifier } - let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil) - var assets: [PHAsset] = [] - fetchResult.enumerateObjects { asset, _, _ in - assets.append(asset) + if Media.currentPermission == .authorized { + let identifiers = result.compactMap { $0.assetIdentifier } + let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil) + var assets: [PHAsset] = [] + fetchResult.enumerateObjects { asset, _, _ in + assets.append(asset) + } + let browserResults = assets.map { BrowserResult.media($0) } + completion(.success(browserResults)) + } else { + let browserResults = result.map { BrowserResult.data($0.itemProvider) } + completion(.success(browserResults)) } - completion(.success(assets)) case let .failure(error): () completion(.failure(error)) } @@ -59,7 +68,7 @@ public extension Media { completion(.failure(MediaPicker.Error.unsupportedValue)) return } - completion(.success([phAsset])) + completion(.success([.media(phAsset)])) }, onFailure: { error in completion(.failure(error)) }) diff --git a/Sources/MediaSwiftUI/API/MediaSwiftUIAliases.swift b/Sources/MediaSwiftUI/API/MediaSwiftUIAliases.swift new file mode 100644 index 0000000..312a5c9 --- /dev/null +++ b/Sources/MediaSwiftUI/API/MediaSwiftUIAliases.swift @@ -0,0 +1,14 @@ +// +// MediaSwiftUIAliases.swift +// MediaSwiftUI +// +// Created by Christian Elies on 03.05.21. +// + +import MediaCore +import Photos + +public typealias ResultLivePhotosCompletion = (Result<[BrowserResult], Error>) -> Void +public typealias ResultPHAssetsCompletion = (Result<[BrowserResult], Swift.Error>) -> Void +public typealias ResultPhotosCompletion = (Result<[BrowserResult], Swift.Error>) -> Void +public typealias ResultVideosCompletion = (Result<[BrowserResult], Swift.Error>) -> Void diff --git a/Sources/MediaSwiftUI/API/Models/BrowserResult.swift b/Sources/MediaSwiftUI/API/Models/BrowserResult.swift new file mode 100644 index 0000000..a660c32 --- /dev/null +++ b/Sources/MediaSwiftUI/API/Models/BrowserResult.swift @@ -0,0 +1,14 @@ +// +// BrowserResult.swift +// MediaCore +// +// Created by Christian Elies on 03.05.21. +// + +/// Represents the result of a media browser view. +public enum BrowserResult { + /// The result is a concrete media type, like `LivePhoto`, `Photo` or `Video`. + case media(_ value: T) + /// The result is a data representation, like `URL` or `Data`. + case data(_ data: U) +} diff --git a/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+Error.swift b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+Error.swift new file mode 100644 index 0000000..0fff17b --- /dev/null +++ b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+Error.swift @@ -0,0 +1,18 @@ +// +// NSItemProvider+Error.swift +// MediaSwiftUI +// +// Created by Christian Elies on 03.05.21. +// + +import Foundation + +extension NSItemProvider { + /// Represents the errors thrown if loading data from the receiving item provider fails. + public enum Error: Swift.Error { + /// The requested object could not be loaded. + case couldNotLoadObject(underlying: Swift.Error) + /// An unknown error occurred. + case unknown + } +} diff --git a/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadImage.swift b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadImage.swift new file mode 100644 index 0000000..b9ddf91 --- /dev/null +++ b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadImage.swift @@ -0,0 +1,36 @@ +// +// NSItemProvider+loadImage.swift +// MediaSwiftUI +// +// Created by Christian Elies on 14.10.20. +// + +#if !os(macOS) || targetEnvironment(macCatalyst) +import Combine +import Foundation +import MediaCore + +extension NSItemProvider { + /// Loads an image from the receiving item provider if one is available. + /// + /// - Returns: A publisher which provides an `UniversalImage` on `success`. + public func loadImage() -> AnyPublisher { + Future { promise in + guard self.canLoadObject(ofClass: UniversalImage.self) else { + promise(.failure(Error.couldNotLoadObject(underlying: Error.unknown))) + return + } + + self.loadObject(ofClass: UniversalImage.self) { newImage, error in + if let error = error { + promise(.failure(Error.couldNotLoadObject(underlying: error))) + } else if let newImage = newImage { + promise(.success(newImage as! UniversalImage)) + } else { + promise(.failure(Error.unknown)) + } + } + }.eraseToAnyPublisher() + } +} +#endif diff --git a/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadLivePhoto.swift b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadLivePhoto.swift new file mode 100644 index 0000000..48f01f3 --- /dev/null +++ b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadLivePhoto.swift @@ -0,0 +1,34 @@ +// +// NSItemProvider+loadLivePhoto.swift +// MediaSwiftUI +// +// Created by Christian Elies on 03.05.21. +// + +import Combine +import Foundation +import Photos + +extension NSItemProvider { + /// Loads a live photo from the receiving item provider if one is available. + /// + /// - Returns: A publisher which provides a `PHLivePhoto` on `success`. + public func loadLivePhoto() -> AnyPublisher { + Future { promise in + guard self.canLoadObject(ofClass: PHLivePhoto.self) else { + promise(.failure(Error.couldNotLoadObject(underlying: Error.unknown))) + return + } + + self.loadObject(ofClass: PHLivePhoto.self) { livePhoto, error in + if let error = error { + promise(.failure(Error.couldNotLoadObject(underlying: error))) + } else if let livePhoto = livePhoto { + promise(.success(livePhoto as! PHLivePhoto)) + } else { + promise(.failure(Error.unknown)) + } + } + }.eraseToAnyPublisher() + } +} diff --git a/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadVideo.swift b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadVideo.swift new file mode 100644 index 0000000..b2256d7 --- /dev/null +++ b/Sources/MediaSwiftUI/API/PHPicker/NSItemProvider+loadVideo.swift @@ -0,0 +1,51 @@ +// +// NSItemProvider+loadVideo.swift +// MediaSwiftUI +// +// Created by Christian Elies on 03.05.21. +// + +import Combine +import Foundation +import PhotosUI + +extension NSItemProvider { + /// Loads a video from the receiving item provider if one is available. + /// + /// - Returns: A publisher which provides a `URL` of the video on `success`. + public func loadVideo() -> AnyPublisher { + Future { promise in + let typeIdentifier: String + if #available(iOS 14, macCatalyst 14, macOS 11, tvOS 14, *) { + typeIdentifier = UTType.movie.identifier + } else { + typeIdentifier = "public.movie" + } + guard self.hasItemConformingToTypeIdentifier(typeIdentifier) else { + promise(.failure(Error.unknown)) + return + } + + self.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let url = url { + let fileManager: FileManager = .default + let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + let targetLocation = cachesDirectory.appendingPathComponent(url.lastPathComponent) + let result: Result = Result { + if fileManager.fileExists(atPath: targetLocation.path) { + let newItemURL = try fileManager.replaceItemAt(targetLocation, withItemAt: url) + return newItemURL ?? targetLocation + } else { + try fileManager.copyItem(at: url, to: targetLocation) + } + return targetLocation + } + promise(result) + } else { + let error = error ?? Error.unknown + promise(.failure(error)) + } + } + }.eraseToAnyPublisher() + } +} diff --git a/Sources/MediaSwiftUI/API/PHPicker/PHPicker.swift b/Sources/MediaSwiftUI/API/PHPicker/PHPicker.swift index 1af5442..9d88dc5 100644 --- a/Sources/MediaSwiftUI/API/PHPicker/PHPicker.swift +++ b/Sources/MediaSwiftUI/API/PHPicker/PHPicker.swift @@ -11,7 +11,9 @@ import PhotosUI import SwiftUI @available(iOS 14, macCatalyst 14, *) +/// `SwiftUI` port of the `PHPickerViewController`. public struct PHPicker: UIViewControllerRepresentable { + /// The coordinator of the view. Mainly it's the delegate of the underlying `PHPickerViewController`. public final class Coordinator: NSObject, PHPickerViewControllerDelegate { private let picker: PHPicker @@ -22,16 +24,26 @@ public struct PHPicker: UIViewControllerRepresentable { public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { self.picker.completion(.success(results)) picker.dismiss(animated: true, completion: nil) + self.picker.isPresented = false } } + @Binding var isPresented: Bool let configuration: PHPickerConfiguration let completion: ResultGenericCompletion<[PHPickerResult]> + /// Initializes the picker. + /// + /// - Parameters: + /// - isPresented: A binding to whether the picker is presented. + /// - configuration: The configuration for the picker. + /// - completion: A closure called on completion with a result - an array of `PHPickerResult` on `success` or an `Error` on `failure`. public init( + isPresented: Binding, configuration: PHPickerConfiguration, _ completion: @escaping ResultGenericCompletion<[PHPickerResult]> ) { + _isPresented = isPresented self.configuration = configuration self.completion = completion } @@ -45,7 +57,12 @@ public struct PHPicker: UIViewControllerRepresentable { public func updateUIViewController( _ uiViewController: PHPickerViewController, context: Context - ) {} + ) { + guard !isPresented else { + return + } + uiViewController.dismiss(animated: true, completion: nil) + } public func makeCoordinator() -> Coordinator { Coordinator(picker: self) @@ -56,7 +73,7 @@ public struct PHPicker: UIViewControllerRepresentable { @available(iOS 14, macCatalyst 14, *) struct PHPicker_Previews: PreviewProvider { static var previews: some View { - PHPicker(configuration: .init(), { _ in }) + PHPicker(isPresented: .constant(true), configuration: .init(), { _ in }) } } #endif diff --git a/Sources/MediaSwiftUI/API/PHPicker/PHPickerResult+loadImage.swift b/Sources/MediaSwiftUI/API/PHPicker/PHPickerResult+loadImage.swift deleted file mode 100644 index aaf30b2..0000000 --- a/Sources/MediaSwiftUI/API/PHPicker/PHPickerResult+loadImage.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PHPickerResult+loadImage.swift -// MediaSwiftUI -// -// Created by Christian Elies on 14.10.20. -// - -#if !os(tvOS) && !os(macOS) -import PhotosUI - -@available(iOS 14, macCatalyst 14, *) -extension PHPickerResult { - public enum Error: Swift.Error { - case couldNotLoadObject(underlying: Swift.Error) - case unknown - } - - public func loadImage(_ completion: @escaping (Result) -> Void) { - guard itemProvider.canLoadObject(ofClass: UIImage.self) else { - completion(.failure(Error.couldNotLoadObject(underlying: Error.unknown))) - return - } - - itemProvider.loadObject(ofClass: UIImage.self) { newImage, error in - if let error = error { - DispatchQueue.main.async { - completion(.failure(Error.couldNotLoadObject(underlying: error))) - } - } else if let newImage = newImage { - DispatchQueue.main.async { - completion(.success(newImage as! UIImage)) - } - } - } - } -} -#endif diff --git a/Sources/MediaSwiftUI/API/Photo/Photo+SwiftUI.swift b/Sources/MediaSwiftUI/API/Photo/Photo+SwiftUI.swift index 42a7344..74c1841 100644 --- a/Sources/MediaSwiftUI/API/Photo/Photo+SwiftUI.swift +++ b/Sources/MediaSwiftUI/API/Photo/Photo+SwiftUI.swift @@ -6,6 +6,7 @@ // #if canImport(SwiftUI) && (!os(macOS) || targetEnvironment(macCatalyst)) +import Combine import MediaCore import Photos import PhotosUI @@ -60,41 +61,67 @@ public extension Photo { /// Creates a ready-to-use `SwiftUI` view for browsing the photo library /// If an error occurs during initialization a `SwiftUI.Text` with the `localizedDescription` is shown. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter completion: A closure which gets a `Result` (`Photo` on `success` or `Error` on `failure`). /// /// - Returns: some View - static func browser(selectionLimit: Int = 1, _ completion: @escaping ResultPhotosCompletion) -> some View { - browser(selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) + static func browser(isPresented: Binding, selectionLimit: Int = 1, _ completion: @escaping ResultPhotosCompletion) -> some View { + browser(isPresented: isPresented, selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) } /// Creates a ready-to-use `SwiftUI` view for browsing the photo library /// If an error occurs during initialization the provided `errorView` closure is used to construct the view to be displayed. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter errorView: A closure that constructs an error view for the given error. /// - Parameter completion: A closure which gets a `Result` (`Photo` on `success` or `Error` on `failure`). /// /// - Returns: some View - @ViewBuilder static func browser(selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultPhotosCompletion) -> some View { + @ViewBuilder static func browser(isPresented: Binding, selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultPhotosCompletion) -> some View { if #available(iOS 14, macOS 11, *) { - PHPicker(configuration: { - var configuration = PHPickerConfiguration() + PHPicker(isPresented: isPresented, configuration: { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current return configuration }()) { result in switch result { case let .success(result): - let result = Result { - try result.compactMap { object -> Photo? in - guard let assetIdentifier = object.assetIdentifier else { - return nil + if Media.currentPermission == .authorized { + let result = Result { + try result.compactMap { object -> BrowserResult? in + guard let assetIdentifier = object.assetIdentifier else { + return nil + } + guard let photo = try Photo.with(identifier: .init(stringLiteral: assetIdentifier)) else { + return nil + } + return .media(photo) } - return try Photo.with(identifier: .init(stringLiteral: assetIdentifier)) + } + completion(result) + } else { + DispatchQueue.global(qos: .userInitiated).async { + let loadImages = result.map { $0.itemProvider.loadImage() } + Publishers.MergeMany(loadImages) + .collect() + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case let .failure(error): + completion(.failure(error)) + case .finished: () + } + } receiveValue: { urls in + let browserResults = urls.map { BrowserResult.data($0) } + completion(.success(browserResults)) + } + .store(in: &Garbage.cancellables) } } - completion(result) case let .failure(error): () completion(.failure(error)) } @@ -104,7 +131,7 @@ public extension Photo { try ViewCreator.browser(mediaTypes: [.image]) { (result: Result) in switch result { case let .success(photo): - completion(.success([photo])) + completion(.success([.media(photo)])) case let .failure(error): completion(.failure(error)) } diff --git a/Sources/MediaSwiftUI/API/Video/Video+SwiftUI.swift b/Sources/MediaSwiftUI/API/Video/Video+SwiftUI.swift index eb0c6a4..c9d00cb 100644 --- a/Sources/MediaSwiftUI/API/Video/Video+SwiftUI.swift +++ b/Sources/MediaSwiftUI/API/Video/Video+SwiftUI.swift @@ -6,6 +6,7 @@ // #if canImport(SwiftUI) && (!os(macOS) || targetEnvironment(macCatalyst)) +import Combine import MediaCore import PhotosUI import SwiftUI @@ -76,41 +77,67 @@ public extension Video { /// Creates a ready-to-use `SwiftUI` view for browsing `Video`s in the photo library /// If an error occurs during initialization a `SwiftUI.Text` with the `localizedDescription` is shown. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter completion: A closure wich gets `Video` on `success` or `Error` on `failure`. /// /// - Returns: some View - static func browser(selectionLimit: Int = 1, _ completion: @escaping ResultVideosCompletion) -> some View { - browser(selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) + static func browser(isPresented: Binding, selectionLimit: Int = 1, _ completion: @escaping ResultVideosCompletion) -> some View { + browser(isPresented: isPresented, selectionLimit: selectionLimit, errorView: { error in Text(error.localizedDescription) }, completion) } /// Creates a ready-to-use `SwiftUI` view for browsing `Video`s in the photo library /// If an error occurs during initialization the provided `errorView` closure is used to construct the view to be displayed. /// + /// - Parameter isPresented: A binding to whether the underlying picker is presented. /// - Parameter selectionLimit: Specifies the number of items which can be selected. Works only on iOS 14 and macOS 11 where the `PHPicker` is used under the hood. Defaults to `1`. /// - Parameter errorView: A closure that constructs an error view for the given error. /// - Parameter completion: A closure wich gets `Video` on `success` or `Error` on `failure`. /// /// - Returns: some View - @ViewBuilder static func browser(selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultVideosCompletion) -> some View { + @ViewBuilder static func browser(isPresented: Binding, selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultVideosCompletion) -> some View { if #available(iOS 14, macOS 11, *) { - PHPicker(configuration: { - var configuration = PHPickerConfiguration() + PHPicker(isPresented: isPresented, configuration: { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .videos configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current return configuration }()) { result in switch result { case let .success(result): - let result = Result { - try result.compactMap { object -> Video? in - guard let assetIdentifier = object.assetIdentifier else { - return nil + if Media.currentPermission == .authorized { + let browserResult = Result { + try result.compactMap { object -> BrowserResult? in + guard let assetIdentifier = object.assetIdentifier else { + return nil + } + guard let video = try Video.with(identifier: .init(stringLiteral: assetIdentifier)) else { + return nil + } + return .media(video) } - return try Video.with(identifier: .init(stringLiteral: assetIdentifier)) + } + completion(browserResult) + } else { + DispatchQueue.global(qos: .userInitiated).async { + let loadVideos = result.map { $0.itemProvider.loadVideo() } + Publishers.MergeMany(loadVideos) + .collect() + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case let .failure(error): + completion(.failure(error)) + case .finished: () + } + } receiveValue: { urls in + let browserResults = urls.map { BrowserResult.data($0) } + completion(.success(browserResults)) + } + .store(in: &Garbage.cancellables) } } - completion(result) case let .failure(error): () completion(.failure(error)) } @@ -120,7 +147,7 @@ public extension Video { try ViewCreator.browser(mediaTypes: [.movie]) { (result: Result) in switch result { case let .success(video): - completion(.success([video])) + completion(.success([.media(video)])) case let .failure(error): completion(.failure(error)) } diff --git a/Sources/MediaSwiftUI/internal/Models/Garbage.swift b/Sources/MediaSwiftUI/internal/Models/Garbage.swift new file mode 100644 index 0000000..4d8fd1f --- /dev/null +++ b/Sources/MediaSwiftUI/internal/Models/Garbage.swift @@ -0,0 +1,12 @@ +// +// Garbage.swift +// MediaSwiftUI +// +// Created by Christian Elies on 03.05.21. +// + +import Combine + +struct Garbage { + static var cancellables: [AnyCancellable] = [] +} diff --git a/Sources/MediaSwiftUI/internal/Views/ActivityIndicatorView.swift b/Sources/MediaSwiftUI/internal/Views/ActivityIndicatorView.swift index 52f65eb..8476dd4 100644 --- a/Sources/MediaSwiftUI/internal/Views/ActivityIndicatorView.swift +++ b/Sources/MediaSwiftUI/internal/Views/ActivityIndicatorView.swift @@ -5,6 +5,7 @@ // Created by Christian Elies on 15.02.21. // +#if canImport(UIKit) import SwiftUI import UIKit @@ -15,3 +16,4 @@ struct ActivityIndicatorView: UIViewRepresentable { func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {} } +#endif diff --git a/Sources/MediaSwiftUI/internal/Views/LivePhoto/PhotosUILivePhotoView.swift b/Sources/MediaSwiftUI/internal/Views/LivePhoto/PhotosUILivePhotoView.swift deleted file mode 100644 index 1ff4e4f..0000000 --- a/Sources/MediaSwiftUI/internal/Views/LivePhoto/PhotosUILivePhotoView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// PhotosUILivePhotoView.swift -// -// -// Created by Christian Elies on 28.11.19. -// - -#if canImport(SwiftUI) && !os(macOS) && !targetEnvironment(macCatalyst) -import PhotosUI -import SwiftUI - -@available(iOS 13, tvOS 13, *) -struct PhotosUILivePhotoView: UIViewRepresentable { - let phLivePhoto: PHLivePhoto - - func makeUIView(context: UIViewRepresentableContext) -> PHLivePhotoView { - let livePhotoView = PHLivePhotoView() - livePhotoView.livePhoto = phLivePhoto - return livePhotoView - } - - func updateUIView(_ uiView: PHLivePhotoView, context: UIViewRepresentableContext) { - - } -} -#endif diff --git a/Sources/MediaSwiftUI/internal/Views/UniversalProgressView.swift b/Sources/MediaSwiftUI/internal/Views/UniversalProgressView.swift index 8cc0c85..367b2f9 100644 --- a/Sources/MediaSwiftUI/internal/Views/UniversalProgressView.swift +++ b/Sources/MediaSwiftUI/internal/Views/UniversalProgressView.swift @@ -7,17 +7,21 @@ import SwiftUI +@available(macOS 11, *) struct UniversalProgressView: View { var body: some View { - if #available(iOS 14, macOS 11, tvOS 14, *) { + if #available(iOS 14, tvOS 14, *) { ProgressView() } else { + #if canImport(UIKit) ActivityIndicatorView() + #endif } } } #if DEBUG +@available(macOS 11, *) struct UniversalProgressView_Previews: PreviewProvider { static var previews: some View { UniversalProgressView()