// // ZLPhotoManager.swift // ZLPhotoBrowser // // Created by long on 2020/8/11. // // Copyright (c) 2020 Long Zhang <495181165@qq.com> // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import UIKit import Photos @objcMembers public class ZLPhotoManager: NSObject { /// Save image to album. public class func saveImageToAlbum(image: UIImage, completion: ((Bool, PHAsset?) -> Void)?) { let status = PHPhotoLibrary.authorizationStatus() if status == .denied || status == .restricted { completion?(false, nil) return } var placeholderAsset: PHObjectPlaceholder? let completionHandler: ((Bool, Error?) -> Void) = { suc, _ in ZLMainAsync { if suc { let asset = self.getAsset(from: placeholderAsset?.localIdentifier) completion?(suc, asset) } else { completion?(false, nil) } } } if image.zl.hasAlphaChannel(), let data = image.pngData() { PHPhotoLibrary.shared().performChanges({ let newAssetRequest = PHAssetCreationRequest.forAsset() newAssetRequest.addResource(with: .photo, data: data, options: nil) placeholderAsset = newAssetRequest.placeholderForCreatedAsset }, completionHandler: completionHandler) } else { PHPhotoLibrary.shared().performChanges({ let newAssetRequest = PHAssetChangeRequest.creationRequestForAsset(from: image) placeholderAsset = newAssetRequest.placeholderForCreatedAsset }, completionHandler: completionHandler) } } /// Save video to album. public class func saveVideoToAlbum(url: URL, completion: ((Bool, PHAsset?) -> Void)?) { let status = PHPhotoLibrary.authorizationStatus() if status == .denied || status == .restricted { completion?(false, nil) return } var placeholderAsset: PHObjectPlaceholder? PHPhotoLibrary.shared().performChanges({ let newAssetRequest = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) placeholderAsset = newAssetRequest?.placeholderForCreatedAsset }) { suc, _ in ZLMainAsync { if suc { let asset = self.getAsset(from: placeholderAsset?.localIdentifier) completion?(suc, asset) } else { completion?(false, nil) } } } } private class func getAsset(from localIdentifier: String?) -> PHAsset? { guard let id = localIdentifier else { return nil } let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) return result.firstObject } /// Fetch photos from result. public class func fetchPhoto(in result: PHFetchResult, ascending: Bool, allowSelectImage: Bool, allowSelectVideo: Bool, limitCount: Int = .max) -> [ZLPhotoModel] { var models: [ZLPhotoModel] = [] let option: NSEnumerationOptions = ascending ? .init(rawValue: 0) : .reverse var count = 1 result.enumerateObjects(options: option) { asset, _, stop in let m = ZLPhotoModel(asset: asset) if m.type == .image, !allowSelectImage { return } if m.type == .video, !allowSelectVideo { return } if count == limitCount { stop.pointee = true } models.append(m) count += 1 } return models } /// Fetch all album list. public class func getPhotoAlbumList(ascending: Bool, allowSelectImage: Bool, allowSelectVideo: Bool, completion: ([ZLAlbumListModel]) -> Void) { let option = PHFetchOptions() if !allowSelectImage { option.predicate = NSPredicate(format: "mediaType == %ld", PHAssetMediaType.video.rawValue) } if !allowSelectVideo { option.predicate = NSPredicate(format: "mediaType == %ld", PHAssetMediaType.image.rawValue) } let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .albumRegular, options: nil) as! PHFetchResult let albums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumRegular, options: nil) as! PHFetchResult let streamAlbums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumMyPhotoStream, options: nil) as! PHFetchResult let syncedAlbums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumSyncedAlbum, options: nil) as! PHFetchResult let sharedAlbums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumCloudShared, options: nil) as! PHFetchResult let arr = [smartAlbums, albums, streamAlbums, syncedAlbums, sharedAlbums] var albumList: [ZLAlbumListModel] = [] arr.forEach { album in album.enumerateObjects { collection, _, _ in guard let collection = collection as? PHAssetCollection else { return } if collection.assetCollectionSubtype == .smartAlbumAllHidden { return } if #available(iOS 11.0, *), collection.assetCollectionSubtype.rawValue > PHAssetCollectionSubtype.smartAlbumLongExposures.rawValue { return } let result = PHAsset.fetchAssets(in: collection, options: option) if result.count == 0 { return } let title = self.getCollectionTitle(collection) if collection.assetCollectionSubtype == .smartAlbumUserLibrary { // Album of all photos. let m = ZLAlbumListModel(title: title, result: result, collection: collection, option: option, isCameraRoll: true) albumList.insert(m, at: 0) } else { let m = ZLAlbumListModel(title: title, result: result, collection: collection, option: option, isCameraRoll: false) albumList.append(m) } } } completion(albumList) } /// Fetch camera roll album. public class func getCameraRollAlbum(allowSelectImage: Bool, allowSelectVideo: Bool, completion: @escaping (ZLAlbumListModel) -> Void) { DispatchQueue.global().async { let option = PHFetchOptions() if !allowSelectImage { option.predicate = NSPredicate(format: "mediaType == %ld", PHAssetMediaType.video.rawValue) } if !allowSelectVideo { option.predicate = NSPredicate(format: "mediaType == %ld", PHAssetMediaType.image.rawValue) } let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .albumRegular, options: nil) smartAlbums.enumerateObjects { collection, _, stop in if collection.assetCollectionSubtype == .smartAlbumUserLibrary { stop.pointee = true let result = PHAsset.fetchAssets(in: collection, options: option) let albumModel = ZLAlbumListModel(title: self.getCollectionTitle(collection), result: result, collection: collection, option: option, isCameraRoll: true) ZLMainAsync { completion(albumModel) } } } } } /// Conversion collection title. private class func getCollectionTitle(_ collection: PHAssetCollection) -> String { if collection.assetCollectionType == .album { // Albums created by user. var title: String? if ZLCustomLanguageDeploy.language == .system { title = collection.localizedTitle } else { switch collection.assetCollectionSubtype { case .albumMyPhotoStream: title = localLanguageTextValue(.myPhotoStream) default: title = collection.localizedTitle } } return title ?? localLanguageTextValue(.noTitleAlbumListPlaceholder) } var title: String? if ZLCustomLanguageDeploy.language == .system { title = collection.localizedTitle } else { switch collection.assetCollectionSubtype { case .smartAlbumUserLibrary: title = localLanguageTextValue(.cameraRoll) case .smartAlbumPanoramas: title = localLanguageTextValue(.panoramas) case .smartAlbumVideos: title = localLanguageTextValue(.videos) case .smartAlbumFavorites: title = localLanguageTextValue(.favorites) case .smartAlbumTimelapses: title = localLanguageTextValue(.timelapses) case .smartAlbumRecentlyAdded: title = localLanguageTextValue(.recentlyAdded) case .smartAlbumBursts: title = localLanguageTextValue(.bursts) case .smartAlbumSlomoVideos: title = localLanguageTextValue(.slomoVideos) case .smartAlbumSelfPortraits: title = localLanguageTextValue(.selfPortraits) case .smartAlbumScreenshots: title = localLanguageTextValue(.screenshots) case .smartAlbumDepthEffect: title = localLanguageTextValue(.depthEffect) case .smartAlbumLivePhotos: title = localLanguageTextValue(.livePhotos) default: title = collection.localizedTitle } if #available(iOS 11.0, *) { if collection.assetCollectionSubtype == PHAssetCollectionSubtype.smartAlbumAnimated { title = localLanguageTextValue(.animated) } } } return title ?? localLanguageTextValue(.noTitleAlbumListPlaceholder) } @discardableResult public class func fetchImage(for asset: PHAsset, size: CGSize, progress: ((CGFloat, Error?, UnsafeMutablePointer, [AnyHashable: Any]?) -> Void)? = nil, completion: @escaping (UIImage?, Bool) -> Void) -> PHImageRequestID { return fetchImage(for: asset, size: size, resizeMode: .fast, progress: progress, completion: completion) } @discardableResult public class func fetchOriginalImage(for asset: PHAsset, progress: ((CGFloat, Error?, UnsafeMutablePointer, [AnyHashable: Any]?) -> Void)? = nil, completion: @escaping (UIImage?, Bool) -> Void) -> PHImageRequestID { return fetchImage(for: asset, size: PHImageManagerMaximumSize, resizeMode: .fast, progress: progress, completion: completion) } /// Fetch asset data. @discardableResult public class func fetchOriginalImageData(for asset: PHAsset, progress: ((CGFloat, Error?, UnsafeMutablePointer, [AnyHashable: Any]?) -> Void)? = nil, completion: @escaping (Data, [AnyHashable: Any]?, Bool) -> Void) -> PHImageRequestID { let option = PHImageRequestOptions() if asset.zl.isGif { option.version = .original } option.isNetworkAccessAllowed = true option.resizeMode = .fast option.deliveryMode = .highQualityFormat option.progressHandler = { pro, error, stop, info in ZLMainAsync { progress?(CGFloat(pro), error, stop, info) } } return PHImageManager.default().requestImageData(for: asset, options: option) { data, _, _, info in let cancel = info?[PHImageCancelledKey] as? Bool ?? false let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool ?? false) if !cancel, let data = data { completion(data, info, isDegraded) } } } /// Fetch image for asset. private class func fetchImage(for asset: PHAsset, size: CGSize, resizeMode: PHImageRequestOptionsResizeMode, progress: ((CGFloat, Error?, UnsafeMutablePointer, [AnyHashable: Any]?) -> Void)? = nil, completion: @escaping (UIImage?, Bool) -> Void) -> PHImageRequestID { let option = PHImageRequestOptions() option.resizeMode = resizeMode option.isNetworkAccessAllowed = true option.progressHandler = { pro, error, stop, info in ZLMainAsync { progress?(CGFloat(pro), error, stop, info) } } return PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: option) { image, info in var downloadFinished = false if let info = info { downloadFinished = !(info[PHImageCancelledKey] as? Bool ?? false) && (info[PHImageErrorKey] == nil) } let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool ?? false) if downloadFinished { ZLMainAsync { completion(image, isDegraded) } } } } public class func fetchLivePhoto(for asset: PHAsset, completion: @escaping (PHLivePhoto?, [AnyHashable: Any]?, Bool) -> Void) -> PHImageRequestID { let option = PHLivePhotoRequestOptions() option.version = .current option.deliveryMode = .opportunistic option.isNetworkAccessAllowed = true return PHImageManager.default().requestLivePhoto(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: option) { livePhoto, info in let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool ?? false) completion(livePhoto, info, isDegraded) } } public class func fetchVideo(for asset: PHAsset, progress: ((CGFloat, Error?, UnsafeMutablePointer, [AnyHashable: Any]?) -> Void)? = nil, completion: @escaping (AVPlayerItem?, [AnyHashable: Any]?, Bool) -> Void) -> PHImageRequestID { let option = PHVideoRequestOptions() option.isNetworkAccessAllowed = true option.progressHandler = { pro, error, stop, info in ZLMainAsync { progress?(CGFloat(pro), error, stop, info) } } // https://github.com/longitachi/ZLPhotoBrowser/issues/369#issuecomment-728679135 if asset.zl.isInCloud { return PHImageManager.default().requestExportSession(forVideo: asset, options: option, exportPreset: AVAssetExportPresetHighestQuality, resultHandler: { session, info in // iOS11 and earlier, callback is not on the main thread. ZLMainAsync { let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool ?? false) if let avAsset = session?.asset { let item = AVPlayerItem(asset: avAsset) completion(item, info, isDegraded) } else { completion(nil, nil, true) } } }) } else { return PHImageManager.default().requestPlayerItem(forVideo: asset, options: option) { item, info in // iOS11 and earlier, callback is not on the main thread. ZLMainAsync { let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool ?? false) completion(item, info, isDegraded) } } } } class func isFetchImageError(_ error: Error?) -> Bool { guard let e = error as NSError? else { return false } if e.domain == "CKErrorDomain" || e.domain == "CloudPhotoLibraryErrorDomain" { return true } return false } public class func fetchAVAsset(forVideo asset: PHAsset, completion: @escaping (AVAsset?, [AnyHashable: Any]?) -> Void) -> PHImageRequestID { let options = PHVideoRequestOptions() options.deliveryMode = .automatic options.isNetworkAccessAllowed = true if asset.zl.isInCloud { return PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { session, info in // iOS11 and earlier, callback is not on the main thread. ZLMainAsync { if let avAsset = session?.asset { completion(avAsset, info) } else { completion(nil, info) } } } } else { return PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in ZLMainAsync { completion(avAsset, info) } } } } /// Fetch the size of asset. Unit is KB. public class func fetchAssetSize(for asset: PHAsset) -> ZLPhotoConfiguration.KBUnit? { guard let resource = PHAssetResource.assetResources(for: asset).first, let size = resource.value(forKey: "fileSize") as? CGFloat else { return nil } return size / 1024 } /// Fetch asset local file path. /// - Note: Asynchronously to fetch the file path. calls completionHandler block on the main queue. public class func fetchAssetFilePath(for asset: PHAsset, completion: @escaping (String?) -> Void) { asset.requestContentEditingInput(with: nil) { input, _ in var path = input?.fullSizeImageURL?.absoluteString if path == nil, let dir = asset.value(forKey: "directory") as? String, let name = asset.zl.filename { path = String(format: "file:///var/mobile/Media/%@/%@", dir, name) } completion(path) } } /// Save asset original data to file url. Support save image and video. /// - Note: Asynchronously write to a local file. Calls completionHandler block on the main queue. public class func saveAsset(_ asset: PHAsset, toFile fileUrl: URL, completion: @escaping ((Error?) -> Void)) { guard let resource = asset.zl.resource else { completion(NSError.assetSaveError) return } PHAssetResourceManager.default().writeData(for: resource, toFile: fileUrl, options: nil) { error in ZLMainAsync { completion(error) } } } } /// Authority related. public extension ZLPhotoManager { class func hasPhotoLibratyAuthority() -> Bool { return PHPhotoLibrary.authorizationStatus() == .authorized } class func hasCameraAuthority() -> Bool { let status = AVCaptureDevice.authorizationStatus(for: .video) if status == .restricted || status == .denied { return false } return true } class func hasMicrophoneAuthority() -> Bool { let status = AVCaptureDevice.authorizationStatus(for: .audio) if status == .restricted || status == .denied { return false } return true } }