From 1e6305ec354bdc72ebd63c3ede62681f6b6f9f28 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Wed, 20 Aug 2025 16:20:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AA=92=E4=BD=93=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/Utils/MediaUtils.swift | 84 +++++++ wake/View/Components/Upload/MediaPicker.swift | 221 ++++++++++++++++++ .../Components/Upload/MediaUploader.swift | 138 +++++++++++ wake/View/Components/Upload/VideoPicker.swift | 88 +++++++ wake/View/Examples/MediaUpload.swift | 104 +++++++++ wake/WakeApp.swift | 2 +- 6 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 wake/Utils/MediaUtils.swift create mode 100644 wake/View/Components/Upload/MediaPicker.swift create mode 100644 wake/View/Components/Upload/MediaUploader.swift create mode 100644 wake/View/Components/Upload/VideoPicker.swift create mode 100644 wake/View/Examples/MediaUpload.swift diff --git a/wake/Utils/MediaUtils.swift b/wake/Utils/MediaUtils.swift new file mode 100644 index 0000000..abb6cba --- /dev/null +++ b/wake/Utils/MediaUtils.swift @@ -0,0 +1,84 @@ +import AVFoundation +import UIKit +import os.log + +/// 媒体工具类,提供视频处理相关功能 +enum MediaUtils { + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaUtils") + + /// 从视频URL中提取第一帧 + /// - Parameters: + /// - videoURL: 视频文件的URL + /// - completion: 完成回调,返回UIImage或错误 + static func extractFirstFrame(from videoURL: URL, completion: @escaping (Result) -> Void) { + let asset = AVURLAsset(url: videoURL) + let assetImgGenerate = AVAssetImageGenerator(asset: asset) + assetImgGenerate.appliesPreferredTrackTransform = true + + // 获取视频时长 + let duration = asset.duration + let durationTime = CMTimeGetSeconds(duration) + + // 如果视频时长小于等于0,返回错误 + guard durationTime > 0 else { + let error = NSError(domain: "com.yourapp.media", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid video duration"]) + completion(.failure(error)) + return + } + + // 获取第一帧(时间点为0) + let time = CMTime(seconds: 0, preferredTimescale: 600) + + // 生成图片 + assetImgGenerate.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { (_, cgImage, _, result, error) in + if let error = error { + logger.error("Failed to generate image: \(error.localizedDescription)") + DispatchQueue.main.async { + completion(.failure(error)) + } + return + } + + guard result == .succeeded, let cgImage = cgImage else { + let error = NSError(domain: "com.yourapp.media", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to generate image from video"]) + logger.error("Failed to generate image: \(error.localizedDescription)") + DispatchQueue.main.async { + completion(.failure(error)) + } + return + } + + // 创建UIImage并返回 + let image = UIImage(cgImage: cgImage) + DispatchQueue.main.async { + completion(.success(image)) + } + } + } + + /// 从视频数据中提取第一帧 + /// - Parameters: + /// - videoData: 视频数据 + /// - completion: 完成回调,返回UIImage或错误 + static func extractFirstFrame(from videoData: Data, completion: @escaping (Result) -> Void) { + // 创建临时文件URL + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileName = "tempVideo_\(UUID().uuidString).mov" + let fileURL = tempDirectoryURL.appendingPathComponent(fileName) + + do { + // 将数据写入临时文件 + try videoData.write(to: fileURL) + + // 调用URL版本的方法 + extractFirstFrame(from: fileURL) { result in + // 清理临时文件 + try? FileManager.default.removeItem(at: fileURL) + completion(result) + } + } catch { + logger.error("Failed to write video data to temporary file: \(error.localizedDescription)") + completion(.failure(error)) + } + } +} diff --git a/wake/View/Components/Upload/MediaPicker.swift b/wake/View/Components/Upload/MediaPicker.swift new file mode 100644 index 0000000..ec671a5 --- /dev/null +++ b/wake/View/Components/Upload/MediaPicker.swift @@ -0,0 +1,221 @@ +import SwiftUI +import PhotosUI +import AVFoundation +import os.log + +/// 媒体类型 +enum MediaType: Equatable { + case image(UIImage) + case video(URL, UIImage?) // URL 是视频地址,UIImage 是视频缩略图 + + var thumbnail: UIImage? { + switch self { + case .image(let image): + return image + case .video(_, let thumbnail): + return thumbnail + } + } + + var isVideo: Bool { + if case .video = self { + return true + } + return false + } + + static func == (lhs: MediaType, rhs: MediaType) -> Bool { + switch (lhs, rhs) { + case (.image(let lhsImage), .image(let rhsImage)): + return lhsImage.pngData() == rhsImage.pngData() + case (.video(let lhsURL, _), .video(let rhsURL, _)): + return lhsURL == rhsURL + default: + return false + } + } +} + +struct MediaPicker: UIViewControllerRepresentable { + @Binding var selectedMedia: [MediaType] + var selectionLimit: Int + var onDismiss: (() -> Void)? + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + + // 配置支持图片和视频 + configuration.filter = .any(of: [.videos, .images]) + configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: MediaPicker + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaPicker") + private var processedCount = 0 + private var totalToProcess = 0 + private var tempMedia: [MediaType] = [] + + init(_ parent: MediaPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !results.isEmpty else { + parent.onDismiss?() + return + } + + processedCount = 0 + totalToProcess = results.count + tempMedia = [] + + for result in results { + let itemProvider = result.itemProvider + + if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + processImage(itemProvider: itemProvider) { [weak self] media in + self?.handleProcessedMedia(media) + } + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + processVideo(itemProvider: itemProvider) { [weak self] media in + self?.handleProcessedMedia(media) + } + } else { + processedCount += 1 + checkCompletion() + } + } + } + + private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) { + itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in + if let image = object as? UIImage { + completion(.image(image)) + } else { + self.logger.error("Failed to load image: \(error?.localizedDescription ?? "Unknown error")") + completion(nil) + } + } + } + + private func processVideo(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) { + itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in + guard let videoURL = url, error == nil else { + self.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")") + completion(nil) + return + } + + // 创建临时文件URL + let tempDirectory = FileManager.default.temporaryDirectory + let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)") + + do { + // 将视频复制到临时目录 + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + try FileManager.default.copyItem(at: videoURL, to: targetURL) + + // 提取视频缩略图 + MediaUtils.extractFirstFrame(from: targetURL) { result in + switch result { + case .success(let thumbnail): + completion(.video(targetURL, thumbnail)) + case .failure(let error): + self.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)") + // 即使缩略图提取失败,也继续处理视频 + completion(.video(targetURL, nil)) + } + } + } catch { + self.logger.error("Failed to copy video file: \(error.localizedDescription)") + completion(nil) + } + } + } + + private func handleProcessedMedia(_ media: MediaType?) { + if let media = media { + DispatchQueue.main.async { [weak self] in + self?.tempMedia.append(media) + self?.checkCompletion() + } + } else { + processedCount += 1 + checkCompletion() + } + } + + private func checkCompletion() { + processedCount += 1 + + if processedCount >= totalToProcess { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if !self.tempMedia.isEmpty { + self.parent.selectedMedia = self.tempMedia + } + self.parent.onDismiss?() + } + } + } + } +} + +// MARK: - 预览辅助视图 +struct MediaThumbnailView: View { + let media: MediaType + let onDelete: (() -> Void)? + + var body: some View { + ZStack(alignment: .topTrailing) { + // 显示缩略图 + if let thumbnail = media.thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipped() + .cornerRadius(8) + } else { + Color.gray + .frame(width: 80, height: 80) + .cornerRadius(8) + } + + // 视频标识 + if media.isVideo { + Image(systemName: "video.fill") + .foregroundColor(.white) + .padding(4) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + .padding(4) + } + + // 删除按钮 + if let onDelete = onDelete { + Button(action: onDelete) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Color.white) + .clipShape(Circle()) + } + .offset(x: 8, y: -8) + } + } + } +} diff --git a/wake/View/Components/Upload/MediaUploader.swift b/wake/View/Components/Upload/MediaUploader.swift new file mode 100644 index 0000000..eb922b6 --- /dev/null +++ b/wake/View/Components/Upload/MediaUploader.swift @@ -0,0 +1,138 @@ +import SwiftUI +import os.log + +class MediaUploader: ObservableObject { + @Published var selectedMedia: [MediaType] = [] + @Published private(set) var isUploading = false + @Published private(set) var uploadProgress: [Int: Double] = [:] // 跟踪每个上传任务的进度 + @Published var showError = false + @Published private(set) var errorMessage = "" + + @Published var showMediaPicker = false + let maxSelection: Int + var onUploadComplete: ([(MediaType, URL)]?) -> Void + var uploadFunction: (MediaType, @escaping (Double) -> Void) async throws -> URL + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaUploader") + + init(maxSelection: Int, + onUploadComplete: @escaping ([(MediaType, URL)]?) -> Void, + uploadFunction: @escaping (MediaType, @escaping (Double) -> Void) async throws -> URL) { + self.maxSelection = maxSelection + self.onUploadComplete = onUploadComplete + self.uploadFunction = uploadFunction + } + + // 显示媒体选择器 + func showPicker() { + DispatchQueue.main.async { [weak self] in + self?.showMediaPicker = true + } + } + + // 公开的方法供外部调用 + func startUpload() async -> [(MediaType, URL)]? { + guard !selectedMedia.isEmpty else { return nil } + + isUploading = true + uploadProgress.removeAll() + + do { + var uploadedResults: [(MediaType, URL)] = [] + + for (index, media) in selectedMedia.enumerated() { + let url = try await uploadFunction(media) { [weak self] progress in + DispatchQueue.main.async { + self?.uploadProgress[index] = progress + } + } + uploadedResults.append((media, url)) + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isUploading = false + self.onUploadComplete(uploadedResults) + self.selectedMedia.removeAll() + } + + return uploadedResults + } catch { + logger.error("上传失败: \(error.localizedDescription)") + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.errorMessage = "上传失败: \(error.localizedDescription)" + self.showError = true + self.isUploading = false + } + return nil + } + } +} + +struct MediaUploaderView: View { + @ObservedObject var mediaUploader: MediaUploader + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // 显示已选媒体数量 + if !mediaUploader.selectedMedia.isEmpty { + Text("已选择 \(mediaUploader.selectedMedia.count) 个文件") + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + .fullScreenCover(isPresented: $mediaUploader.showMediaPicker) { + MediaPicker( + selectedMedia: $mediaUploader.selectedMedia, + selectionLimit: mediaUploader.maxSelection + ) { + mediaUploader.showMediaPicker = false + } + } + .alert("错误", isPresented: $mediaUploader.showError) { + Button("确定", role: .cancel) {} + } message: { + Text(mediaUploader.errorMessage) + } + } +} + +// MARK: - 预览 +struct MediaUploader_Previews: PreviewProvider { + static var previews: some View { + Group { + MediaUploaderView( + mediaUploader: MediaUploader( + maxSelection: 5, + onUploadComplete: { _ in }, + uploadFunction: { _, _ in + // 模拟上传 + try await Task.sleep(nanoseconds: 1_000_000_000) + return URL(string: "https://example.com/uploaded")! + } + ) + ) + .padding() + .previewLayout(.sizeThatFits) + + MediaUploaderView( + mediaUploader: MediaUploader( + maxSelection: 5, + onUploadComplete: { _ in }, + uploadFunction: { _, _ in + // 模拟上传 + try await Task.sleep(nanoseconds: 1_000_000_000) + return URL(string: "https://example.com/uploaded")! + } + ) + ) + .padding() + .previewLayout(.sizeThatFits) + .preferredColorScheme(.dark) + } + } +} diff --git a/wake/View/Components/Upload/VideoPicker.swift b/wake/View/Components/Upload/VideoPicker.swift new file mode 100644 index 0000000..b50aa4f --- /dev/null +++ b/wake/View/Components/Upload/VideoPicker.swift @@ -0,0 +1,88 @@ +import SwiftUI +import PhotosUI +import AVFoundation +import os.log + +struct VideoPicker: UIViewControllerRepresentable { + @Binding var selectedVideoURL: URL? + @Binding var thumbnailImage: UIImage? + var onDismiss: (() -> Void)? + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + configuration.filter = .videos + configuration.selectionLimit = 1 + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: VideoPicker + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "VideoPicker") + + init(_ parent: VideoPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let result = results.first else { + parent.onDismiss?() + return + } + + // 获取视频URL + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, error in + guard let self = self, let videoURL = url, error == nil else { + self?.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")") + DispatchQueue.main.async { + self?.parent.onDismiss?() + } + return + } + + // 创建临时文件URL + let tempDirectory = FileManager.default.temporaryDirectory + let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)") + + do { + // 将视频复制到临时目录 + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + try FileManager.default.copyItem(at: videoURL, to: targetURL) + + // 提取视频首帧 + MediaUtils.extractFirstFrame(from: targetURL) { [weak self] result in + switch result { + case .success(let image): + DispatchQueue.main.async { + self?.parent.thumbnailImage = image + self?.parent.selectedVideoURL = targetURL + self?.parent.onDismiss?() + } + case .failure(let error): + self?.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)") + DispatchQueue.main.async { + self?.parent.onDismiss?() + } + } + } + } catch { + self.logger.error("Failed to copy video file: \(error.localizedDescription)") + DispatchQueue.main.async { + self.parent.onDismiss?() + } + } + } + } + } +} diff --git a/wake/View/Examples/MediaUpload.swift b/wake/View/Examples/MediaUpload.swift new file mode 100644 index 0000000..6db94e5 --- /dev/null +++ b/wake/View/Examples/MediaUpload.swift @@ -0,0 +1,104 @@ +import SwiftUI +import os.log + +public struct ExampleView: View { + @StateObject private var mediaUploader = MediaUploader( + maxSelection: 5, + onUploadComplete: { _ in }, + uploadFunction: { media, progress in + // 模拟上传进度 + for i in 0...10 { + try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒 + progress(Double(i) / 10.0) + } + + // 这里替换为您的实际上传逻辑 + switch media { + case .image(let image): + // 上传图片 + guard let url = URL(string: "https://example.com/images/\(UUID().uuidString).jpg") else { + throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) + } + return url + case .video(let url, _): + // 上传视频 + guard let url = URL(string: "https://example.com/videos/\(UUID().uuidString).mp4") else { + throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) + } + return url + } + } + ) + @State private var uploadedURLs: [URL] = [] + @EnvironmentObject private var authState: AuthState + + public init() {} + + public var body: some View { + NavigationView { + VStack { + // 添加媒体按钮 + Button(action: { + mediaUploader.showPicker() + }) { + HStack { + Image(systemName: "plus.circle.fill") + Text("添加媒体") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + + // 媒体上传组件 + MediaUploaderView(mediaUploader: mediaUploader) + .onChange(of: mediaUploader.selectedMedia) { newValue in + if !newValue.isEmpty { + Task { + if let results = await mediaUploader.startUpload() { + let urls = results.map { $0.1 } + uploadedURLs.append(contentsOf: urls) + } + } + } + } + + // 显示已上传的URL + List(uploadedURLs, id: \.self) { url in + Text(url.absoluteString) + .font(.caption) + .padding(.vertical, 4) + } + .listStyle(PlainListStyle()) + } + .navigationTitle("媒体上传示例") + } + } + + // 实际上传函数示例 + private func uploadMedia(_ media: MediaType, progress: @escaping (Double) -> Void) async throws -> URL { + // 模拟上传进度 + for i in 0...10 { + try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒 + progress(Double(i) / 10.0) + } + + // 这里替换为您的实际上传逻辑 + switch media { + case .image(let image): + // 上传图片 + return URL(string: "https://example.com/images/\(UUID().uuidString).jpg")! + case .video(let url, _): + // 上传视频 + return URL(string: "https://example.com/videos/\(UUID().uuidString).mp4")! + } + } +} + +#Preview { + ExampleView() + .environmentObject(AuthState.shared) +} \ No newline at end of file diff --git a/wake/WakeApp.swift b/wake/WakeApp.swift index 13a58cf..98fd3b9 100644 --- a/wake/WakeApp.swift +++ b/wake/WakeApp.swift @@ -46,7 +46,7 @@ struct WakeApp: App { // 已登录:显示userInfo页面 // UserInfo() // .environmentObject(authState) - MultiImageUploadExampleView() + ExampleView() .environmentObject(authState) } else { // 未登录:显示登录界面