diff --git a/ConversationsClassic/AppData/Model/GalleryItem.swift b/ConversationsClassic/AppData/Model/GalleryItem.swift new file mode 100644 index 0000000..8a57186 --- /dev/null +++ b/ConversationsClassic/AppData/Model/GalleryItem.swift @@ -0,0 +1,53 @@ +import Photos +import SwiftUI + +enum GalleryMediaType { + case video + case photo +} + +struct GalleryItem: Identifiable { + let id: String + let type: GalleryMediaType + var thumbnail: Image? + var duration: String? +} + +extension GalleryItem { + static func fetchAll() async -> [GalleryItem] { + await Task { + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let assets = PHAsset.fetchAssets(with: fetchOptions) + var tmpGalleryItems: [GalleryItem] = [] + assets.enumerateObjects { asset, _, _ in + if asset.mediaType == .image { + let item = GalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: nil, duration: nil) + tmpGalleryItems.append(item) + } + if asset.mediaType == .video { + let item = GalleryItem(id: asset.localIdentifier, type: .video, thumbnail: nil, duration: asset.duration.minAndSec) + tmpGalleryItems.append(item) + } + } + return tmpGalleryItems + }.value + } + + mutating func fetchThumbnail() async throws { + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { return } + let size = CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize) + + switch type { + case .photo: + let originalImage = try await PHImageManager.default().getPhoto(for: asset) + let cropped = try await originalImage.scaleAndCropImage(size) + thumbnail = Image(uiImage: cropped) + + case .video: + let avAsset = try await PHImageManager.default().getVideo(for: asset) + let cropped = try await avAsset.generateVideoThumbnail(size) + thumbnail = Image(uiImage: cropped) + } + } +} diff --git a/ConversationsClassic/AppData/Store/ClientStoreError.swift b/ConversationsClassic/AppData/Store/ClientStoreError.swift index 7bb2871..5f8f01c 100644 --- a/ConversationsClassic/AppData/Store/ClientStoreError.swift +++ b/ConversationsClassic/AppData/Store/ClientStoreError.swift @@ -1,4 +1,6 @@ enum ClientStoreError: Error { case clientNotFound case rosterNotFound + case imageNotFound + case videoNotFound } diff --git a/ConversationsClassic/AppData/Store/FileStore.swift b/ConversationsClassic/AppData/Store/FileStore.swift index 61ad3a3..ef3efac 100644 --- a/ConversationsClassic/AppData/Store/FileStore.swift +++ b/ConversationsClassic/AppData/Store/FileStore.swift @@ -1,11 +1,13 @@ import Combine import Foundation import Photos +import SwiftUI @MainActor final class FileStore: ObservableObject { @Published var cameraAccessGranted = false @Published var galleryAccessGranted = false + @Published var galleryItems: [GalleryItem] = [] private let client: Client private let roster: Roster @@ -35,4 +37,9 @@ extension FileStore { } galleryAccessGranted = isAuthorized } + + func fetchGalleryItems() async { + guard galleryAccessGranted else { return } + galleryItems = await GalleryItem.fetchAll() + } } diff --git a/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift b/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift new file mode 100644 index 0000000..3bccc0f --- /dev/null +++ b/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift @@ -0,0 +1,16 @@ +import AVFoundation +import UIKit + +extension AVAsset { + func generateVideoThumbnail(_ size: CGSize) async throws -> UIImage { + try await Task { + let assetImgGenerate = AVAssetImageGenerator(asset: self) + assetImgGenerate.appliesPreferredTrackTransform = true + let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600) + let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil) + let image = UIImage(cgImage: cgImage) + let result = try await image.scaleAndCropImage(size) + return result + }.value + } +} diff --git a/ConversationsClassic/Helpers/PHImageManager+Fetch.swift b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift new file mode 100644 index 0000000..b97fe1f --- /dev/null +++ b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift @@ -0,0 +1,43 @@ +import Photos +import UIKit + +extension PHImageManager { + func getPhoto(for asset: PHAsset) async throws -> UIImage { + let options = PHImageRequestOptions() + options.version = .original + options.isSynchronous = true + return try await withCheckedThrowingContinuation { continuation in + requestImage( + for: asset, + targetSize: PHImageManagerMaximumSize, + contentMode: .aspectFill, + options: options + ) { image, _ in + if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: ClientStoreError.imageNotFound) + } + } + } + } + + func getVideo(for asset: PHAsset) async throws -> AVAsset { + let options = PHVideoRequestOptions() + options.version = .original + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + return try await withCheckedThrowingContinuation { continuation in + requestAVAsset( + forVideo: asset, + options: options + ) { avAsset, _, _ in + if let avAsset { + continuation.resume(returning: avAsset) + } else { + continuation.resume(throwing: ClientStoreError.videoNotFound) + } + } + } + } +} diff --git a/ConversationsClassic/Helpers/UIImage+Crop.swift b/ConversationsClassic/Helpers/UIImage+Crop.swift new file mode 100644 index 0000000..239a1e8 --- /dev/null +++ b/ConversationsClassic/Helpers/UIImage+Crop.swift @@ -0,0 +1,30 @@ +import Foundation +import UIKit + +extension UIImage { + func scaleAndCropImage(_ size: CGSize) async throws -> UIImage { + try await Task { + let aspect = self.size.width / self.size.height + let targetAspect = size.width / size.height + var newWidth: CGFloat + var newHeight: CGFloat + if aspect < targetAspect { + newWidth = size.width + newHeight = size.width / aspect + } else { + newHeight = size.height + newWidth = size.height * aspect + } + + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + self.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + if let newImage = newImage { + return newImage + } else { + throw NSError(domain: "UIImage", code: -900, userInfo: nil) + } + }.value + } +} diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraView.swift index 23e7b55..e7261fd 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraView.swift @@ -25,7 +25,9 @@ struct CameraView: UIViewRepresentable { view.layer.addSublayer(previewLayer) view.previewLayer = previewLayer - captureSession.startRunning() + DispatchQueue.global(qos: .background).async { + captureSession.startRunning() + } return view } diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift index effc456..1d7647b 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift @@ -1,104 +1,120 @@ import SwiftUI struct GalleryView: View { - // @State private var selectedItems: [String] = [] + @EnvironmentObject var store: FileStore + @Binding var selectedItems: [String] var body: some View { - Text("test") - // Group { - // if store.state.sharingState.isGalleryAccessGranted { - // ForEach(store.state.sharingState.galleryItems) { item in - // GridViewItem(item: item, selected: $selectedItems) - // } - // } else { - // Button { - // openAppSettings() - // } label: { - // ZStack { - // Rectangle() - // .fill(Color.Material.Background.light) - // .overlay { - // VStack { - // Image(systemName: "photo") - // .foregroundColor(.Material.Elements.active) - // .font(.system(size: 30)) - // Text("Allow gallery access") - // .foregroundColor(.Material.Text.main) - // .font(.body3) - // } - // } - // .frame(height: 100) - // } - // } - // } - // } + Group { + if store.galleryAccessGranted { + ForEach(store.galleryItems) { item in + GridViewItem(item: item, selected: $selectedItems) + } + } else { + Button { + openAppSettings() + } label: { + ZStack { + Rectangle() + .fill(Color.Material.Background.light) + .overlay { + VStack { + Image(systemName: "photo") + .foregroundColor(.Material.Elements.active) + .font(.system(size: 30)) + Text("Allow gallery access") + .foregroundColor(.Material.Text.main) + .font(.body3) + } + } + .frame(height: 100) + } + } + } + } + .task { + await store.checkGalleryAuthorization() + } + .onChange(of: store.galleryAccessGranted) { flag in + if flag { + Task { + await store.fetchGalleryItems() + } + } + } } } private struct GridViewItem: View { - // let item: SharingGalleryItem + @State var item: GalleryItem @Binding var selected: [String] - @State var isSelected = false var body: some View { - Text("Test") - // if let data = item.thumbnail { - // ZStack { - // Image(uiImage: UIImage(data: data) ?? UIImage()) - // .resizable() - // .aspectRatio(contentMode: .fill) - // .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) - // .clipped() - // if let duration = item.duration { - // VStack { - // Spacer() - // HStack { - // Spacer() - // Text(duration) - // .foregroundColor(.Material.Text.white) - // .font(.sub1) - // .shadow(color: .black, radius: 2) - // .padding(4) - // } - // } - // } - // if isSelected { - // VStack { - // HStack { - // Spacer() - // Circle() - // .frame(width: 30, height: 30) - // .shadow(color: .black, radius: 2) - // .foregroundColor(.Material.Shape.white) - // .overlay { - // Image(systemName: "checkmark") - // .foregroundColor(.Material.Elements.active) - // .font(.body3) - // } - // .padding(4) - // } - // Spacer() - // } - // } - // } - // .onTapGesture { - // isSelected.toggle() - // if isSelected { - // selected.append(item.id) - // } else { - // selected.removeAll { $0 == item.id } - // } - // } - // } else { - // ZStack { - // Rectangle() - // .fill(Color.Material.Background.light) - // .overlay { - // ProgressView() - // .foregroundColor(.Material.Elements.active) - // } - // .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) - // } - // } + if let img = item.thumbnail { + ZStack { + img + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) + .clipped() + if let duration = item.duration { + VStack { + Spacer() + HStack { + Spacer() + Text(duration) + .foregroundColor(.Material.Text.white) + .font(.sub1) + .shadow(color: .black, radius: 2) + .padding(4) + } + } + } + // if isSelected { + // VStack { + // HStack { + // Spacer() + // Circle() + // .frame(width: 30, height: 30) + // .shadow(color: .black, radius: 2) + // .foregroundColor(.Material.Shape.white) + // .overlay { + // Image(systemName: "checkmark") + // .foregroundColor(.Material.Elements.active) + // .font(.body3) + // } + // .padding(4) + // } + // Spacer() + // } + // } + } + // .onTapGesture { + // if isSelected { + // selected.append(item.id) + // } else { + // selected.removeAll { $0 == item.id } + // } + // } + } else { + ZStack { + Rectangle() + .fill(Color.Material.Background.light) + .overlay { + ProgressView() + .foregroundColor(.Material.Elements.active) + } + .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) + } + .task { + if item.thumbnail == nil { + try? await item.fetchThumbnail() + } + } + } + } + + private var isSelected: Bool { + selected.contains(item.id) } } diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift index ae7dbba..ea21355 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift @@ -17,7 +17,7 @@ struct MediaPickerView: View { CameraCellPreview() // For gallery - GalleryView() + GalleryView(selectedItems: $selectedItems) } }