Skip to content
Merged
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
128 changes: 124 additions & 4 deletions Example/Media-Example/Views/BrowserSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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
Expand All @@ -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: {
Expand All @@ -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
Expand All @@ -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<Video, URL>], Swift.Error>) {
switch result {
case let .success(browserResult):
switch browserResult.first {
case let .data(url):
playerURL = url
default: ()
}
default: ()
}
}

func handlePhotoBrowserResult(_ result: Result<[BrowserResult<Photo, UIImage>], Swift.Error>) {
switch result {
case let .success(browserResult):
switch browserResult.first {
case let .data(uiImage):
image = uiImage
default: ()
}
default: ()
}
}

func handleLivePhotoBrowserResult(_ result: Result<[BrowserResult<LivePhoto, PHLivePhoto>], Swift.Error>) {
switch result {
case let .success(browserResult):
switch browserResult.first {
case let .data(phLivePhoto):
livePhoto = phLivePhoto
default: ()
}
default: ()
}
}

func handleMediaBrowserResult(_ result: Result<[BrowserResult<PHAsset, NSItemProvider>], 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: ()
}
}
}
22 changes: 16 additions & 6 deletions Example/Media-Example/Views/PermissionsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, PermissionError>) -> 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)
}
}
Expand All @@ -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)
}
}
}
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions Sources/MediaCore/API/Media/Media.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public struct Media {

/// Returns the current camera permission.
///
@available(tvOS, unavailable)
public static var currentCameraPermission: AVAuthorizationStatus {
AVCaptureDevice.authorizationStatus(for: .video)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// Aliases.swift
//
// MediaCoreAliases.swift
// MediaCore
//
// Created by Christian Elies on 30.11.19.
//
Expand All @@ -18,13 +18,9 @@ public typealias MediaSubtype = PHAssetMediaSubtype
public typealias ResultDataCompletion = (Result<Data, Swift.Error>) -> Void
public typealias ResultGenericCompletion<T> = (Result<T, Swift.Error>) -> Void
public typealias ResultLivePhotoCompletion = (Result<LivePhoto, Error>) -> Void
public typealias ResultLivePhotosCompletion = (Result<[LivePhoto], Error>) -> Void
public typealias RequestLivePhotoResultHandler = (PHLivePhoto?, [AnyHashable : Any]) -> Void
public typealias ResultPHAssetCompletion = (Result<PHAsset, Swift.Error>) -> Void
public typealias ResultPHAssetsCompletion = (Result<[PHAsset], Swift.Error>) -> Void
public typealias ResultPhotoCompletion = (Result<Photo, Swift.Error>) -> Void
public typealias ResultPhotosCompletion = (Result<[Photo], Swift.Error>) -> Void
public typealias ResultURLCompletion = (Result<URL, Swift.Error>) -> Void
public typealias ResultVideoCompletion = (Result<Video, Swift.Error>) -> Void
public typealias ResultVideosCompletion = (Result<[Video], Swift.Error>) -> Void
public typealias ResultVoidCompletion = (Result<Void, Swift.Error>) -> Void
4 changes: 4 additions & 0 deletions Sources/MediaCore/API/Video/Video.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ public extension Video {

let copyCGImageResult: Result<UniversalImage, Swift.Error> = 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 {
Expand Down
51 changes: 39 additions & 12 deletions Sources/MediaSwiftUI/API/LivePhoto/LivePhoto+SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

#if canImport(SwiftUI)
import Combine
import MediaCore
import PhotosUI
import SwiftUI
Expand Down Expand Up @@ -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<Bool>, 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<ErrorView: View>(selectionLimit: Int = 1, @ViewBuilder errorView: (Swift.Error) -> ErrorView, _ completion: @escaping ResultLivePhotosCompletion) -> some View {
@ViewBuilder static func browser<ErrorView: View>(isPresented: Binding<Bool>, 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<LivePhoto, PHLivePhoto>? 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<LivePhoto, PHLivePhoto>.data($0) }
completion(.success(browserResults))
}
.store(in: &Garbage.cancellables)
}
}
completion(result)
case let .failure(error): ()
completion(.failure(error))
}
Expand All @@ -94,7 +121,7 @@ public extension LivePhoto {
try ViewCreator.browser(mediaTypes: [.image, .livePhoto]) { (result: Result<LivePhoto, Error>) in
switch result {
case let .success(livePhoto):
completion(.success([livePhoto]))
completion(.success([.media(livePhoto)]))
case let .failure(error):
completion(.failure(error))
}
Expand Down
Loading