feat: 项目文件整合
This commit is contained in:
parent
8572ce06af
commit
3a0ea7c8be
Binary file not shown.
@ -1,65 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 素材服务,处理与素材相关的网络请求
|
|
||||||
class MaterialService {
|
|
||||||
|
|
||||||
/// 单例实例
|
|
||||||
static let shared = MaterialService()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// 上传素材信息
|
|
||||||
/// - Parameters:
|
|
||||||
/// - fileId: 原文件ID
|
|
||||||
/// - previewFileId: 预览文件ID
|
|
||||||
/// - completion: 完成回调,返回是否成功
|
|
||||||
func uploadMaterialInfo(fileId: String,
|
|
||||||
previewFileId: String,
|
|
||||||
completion: @escaping (Bool, String?) -> Void) {
|
|
||||||
|
|
||||||
let materialData: [String: Any] = [
|
|
||||||
"material": [
|
|
||||||
"file_id": fileId,
|
|
||||||
"preview_file_id": previewFileId
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
guard let url = URL(string: "\(APIConfig.baseURL)/material") else {
|
|
||||||
completion(false, "无效的URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.allHTTPHeaderFields = APIConfig.authHeaders
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
do {
|
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: materialData)
|
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
||||||
if let error = error {
|
|
||||||
completion(false, "上传失败: \(error.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
if (200...299).contains(httpResponse.statusCode) {
|
|
||||||
completion(true, nil)
|
|
||||||
} else {
|
|
||||||
let statusCode = httpResponse.statusCode
|
|
||||||
let errorMessage = String(data: data ?? Data(), encoding: .utf8) ?? ""
|
|
||||||
completion(false, "服务器返回错误状态码: \(statusCode), 响应: \(errorMessage)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
completion(false, "无效的服务器响应")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task.resume()
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
completion(false, "请求数据序列化失败: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import AVFoundation
|
|
||||||
import os.log
|
import os.log
|
||||||
|
import AVKit
|
||||||
|
|
||||||
/// 媒体类型
|
/// 媒体类型
|
||||||
public enum MediaType: Equatable {
|
public enum MediaType: Equatable {
|
||||||
@ -38,13 +38,11 @@ public enum MediaType: Equatable {
|
|||||||
|
|
||||||
struct MediaPicker: UIViewControllerRepresentable {
|
struct MediaPicker: UIViewControllerRepresentable {
|
||||||
@Binding var selectedMedia: [MediaType]
|
@Binding var selectedMedia: [MediaType]
|
||||||
var selectionLimit: Int
|
let selectionLimit: Int
|
||||||
var onDismiss: (() -> Void)?
|
let onDismiss: (() -> Void)?
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||||
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
|
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
|
||||||
|
|
||||||
// 配置支持图片和视频
|
|
||||||
configuration.filter = .any(of: [.videos, .images])
|
configuration.filter = .any(of: [.videos, .images])
|
||||||
configuration.selectionLimit = selectionLimit
|
configuration.selectionLimit = selectionLimit
|
||||||
configuration.preferredAssetRepresentationMode = .current
|
configuration.preferredAssetRepresentationMode = .current
|
||||||
@ -63,9 +61,6 @@ struct MediaPicker: UIViewControllerRepresentable {
|
|||||||
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||||
let parent: MediaPicker
|
let parent: MediaPicker
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "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) {
|
init(_ parent: MediaPicker) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
@ -77,26 +72,36 @@ struct MediaPicker: UIViewControllerRepresentable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
processedCount = 0
|
var processedMedia: [MediaType] = []
|
||||||
totalToProcess = results.count
|
let group = DispatchGroup()
|
||||||
tempMedia = []
|
|
||||||
|
|
||||||
for result in results {
|
for result in results {
|
||||||
let itemProvider = result.itemProvider
|
let itemProvider = result.itemProvider
|
||||||
|
|
||||||
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||||
processImage(itemProvider: itemProvider) { [weak self] media in
|
group.enter()
|
||||||
self?.handleProcessedMedia(media)
|
processImage(itemProvider: itemProvider) { media in
|
||||||
|
if let media = media {
|
||||||
|
processedMedia.append(media)
|
||||||
|
}
|
||||||
|
group.leave()
|
||||||
}
|
}
|
||||||
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||||
processVideo(itemProvider: itemProvider) { [weak self] media in
|
group.enter()
|
||||||
self?.handleProcessedMedia(media)
|
processVideo(itemProvider: itemProvider) { media in
|
||||||
|
if let media = media {
|
||||||
|
processedMedia.append(media)
|
||||||
|
}
|
||||||
|
group.leave()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
processedCount += 1
|
|
||||||
checkCompletion()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group.notify(queue: .main) {
|
||||||
|
self.parent.selectedMedia = processedMedia
|
||||||
|
self.printMediaInfo(media: processedMedia)
|
||||||
|
self.parent.onDismiss?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
|
private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
|
||||||
@ -118,25 +123,21 @@ struct MediaPicker: UIViewControllerRepresentable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建临时文件URL
|
|
||||||
let tempDirectory = FileManager.default.temporaryDirectory
|
let tempDirectory = FileManager.default.temporaryDirectory
|
||||||
let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
|
let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// 将视频复制到临时目录
|
|
||||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||||
try FileManager.default.removeItem(at: targetURL)
|
try FileManager.default.removeItem(at: targetURL)
|
||||||
}
|
}
|
||||||
try FileManager.default.copyItem(at: videoURL, to: targetURL)
|
try FileManager.default.copyItem(at: videoURL, to: targetURL)
|
||||||
|
|
||||||
// 提取视频缩略图
|
|
||||||
MediaUtils.extractFirstFrame(from: targetURL) { result in
|
MediaUtils.extractFirstFrame(from: targetURL) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let thumbnail):
|
case .success(let thumbnail):
|
||||||
completion(.video(targetURL, thumbnail))
|
completion(.video(targetURL, thumbnail))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
self.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)")
|
self.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)")
|
||||||
// 即使缩略图提取失败,也继续处理视频
|
|
||||||
completion(.video(targetURL, nil))
|
completion(.video(targetURL, nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,30 +148,54 @@ struct MediaPicker: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleProcessedMedia(_ media: MediaType?) {
|
private func printMediaInfo(media: [MediaType]) {
|
||||||
if let media = media {
|
print("=== Selected Media Information ===")
|
||||||
DispatchQueue.main.async { [weak self] in
|
for (index, media) in media.enumerated() {
|
||||||
self?.tempMedia.append(media)
|
print("\nItem \(index + 1):")
|
||||||
self?.checkCompletion()
|
|
||||||
|
switch media {
|
||||||
|
case .image(let image):
|
||||||
|
print("Type: Image")
|
||||||
|
print("Dimensions: \(Int(image.size.width))x\(Int(image.size.height))")
|
||||||
|
if let data = image.jpegData(compressionQuality: 1.0) {
|
||||||
|
print("File Size: \(formatFileSize(Int64(data.count)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .video(let url, _):
|
||||||
|
print("Type: Video")
|
||||||
|
print("File Name: \(url.lastPathComponent)")
|
||||||
|
print("File Path: \(url.path)")
|
||||||
|
|
||||||
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let fileSize = attributes[.size] as? Int64 {
|
||||||
|
print("File Size: \(formatFileSize(fileSize))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let duration = asset.duration.seconds
|
||||||
|
print("Duration: \(formatTimeInterval(duration))")
|
||||||
|
|
||||||
|
if let track = asset.tracks(withMediaType: .video).first {
|
||||||
|
let size = track.naturalSize
|
||||||
|
print("Video Dimensions: \(Int(size.width))x\(Int(size.height))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
processedCount += 1
|
|
||||||
checkCompletion()
|
|
||||||
}
|
}
|
||||||
|
print("================================\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkCompletion() {
|
private func formatFileSize(_ bytes: Int64) -> String {
|
||||||
processedCount += 1
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||||
if processedCount >= totalToProcess {
|
formatter.countStyle = .file
|
||||||
DispatchQueue.main.async { [weak self] in
|
return formatter.string(fromByteCount: bytes)
|
||||||
guard let self = self else { return }
|
}
|
||||||
if !self.tempMedia.isEmpty {
|
|
||||||
self.parent.selectedMedia = self.tempMedia
|
private func formatTimeInterval(_ interval: TimeInterval) -> String {
|
||||||
}
|
let formatter = DateComponentsFormatter()
|
||||||
self.parent.onDismiss?()
|
formatter.allowedUnits = [.hour, .minute, .second]
|
||||||
}
|
formatter.zeroFormattingBehavior = .pad
|
||||||
}
|
return formatter.string(from: interval) ?? "00:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,168 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import PhotosUI
|
|
||||||
import os.log
|
|
||||||
import AVKit
|
|
||||||
|
|
||||||
struct MediaPickerWithLogging: UIViewControllerRepresentable {
|
|
||||||
@Binding var selectedMedia: [MediaType]
|
|
||||||
let selectionLimit: Int
|
|
||||||
let 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: MediaPickerWithLogging
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaPickerWithLogging")
|
|
||||||
|
|
||||||
init(_ parent: MediaPickerWithLogging) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
||||||
guard !results.isEmpty else {
|
|
||||||
parent.onDismiss?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var processedMedia: [MediaType] = []
|
|
||||||
let group = DispatchGroup()
|
|
||||||
|
|
||||||
for result in results {
|
|
||||||
let itemProvider = result.itemProvider
|
|
||||||
|
|
||||||
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
|
||||||
group.enter()
|
|
||||||
processImage(itemProvider: itemProvider) { media in
|
|
||||||
if let media = media {
|
|
||||||
processedMedia.append(media)
|
|
||||||
}
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
|
||||||
group.enter()
|
|
||||||
processVideo(itemProvider: itemProvider) { media in
|
|
||||||
if let media = media {
|
|
||||||
processedMedia.append(media)
|
|
||||||
}
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
group.notify(queue: .main) {
|
|
||||||
self.parent.selectedMedia = processedMedia
|
|
||||||
self.printMediaInfo(media: processedMedia)
|
|
||||||
self.parent.onDismiss?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 printMediaInfo(media: [MediaType]) {
|
|
||||||
print("=== Selected Media Information ===")
|
|
||||||
for (index, media) in media.enumerated() {
|
|
||||||
print("\nItem \(index + 1):")
|
|
||||||
|
|
||||||
switch media {
|
|
||||||
case .image(let image):
|
|
||||||
print("Type: Image")
|
|
||||||
print("Dimensions: \(Int(image.size.width))x\(Int(image.size.height))")
|
|
||||||
if let data = image.jpegData(compressionQuality: 1.0) {
|
|
||||||
print("File Size: \(formatFileSize(Int64(data.count)))")
|
|
||||||
}
|
|
||||||
|
|
||||||
case .video(let url, _):
|
|
||||||
print("Type: Video")
|
|
||||||
print("File Name: \(url.lastPathComponent)")
|
|
||||||
print("File Path: \(url.path)")
|
|
||||||
|
|
||||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
||||||
let fileSize = attributes[.size] as? Int64 {
|
|
||||||
print("File Size: \(formatFileSize(fileSize))")
|
|
||||||
}
|
|
||||||
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let duration = asset.duration.seconds
|
|
||||||
print("Duration: \(formatTimeInterval(duration))")
|
|
||||||
|
|
||||||
if let track = asset.tracks(withMediaType: .video).first {
|
|
||||||
let size = track.naturalSize
|
|
||||||
print("Video Dimensions: \(Int(size.width))x\(Int(size.height))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print("================================\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatFileSize(_ bytes: Int64) -> String {
|
|
||||||
let formatter = ByteCountFormatter()
|
|
||||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
|
||||||
formatter.countStyle = .file
|
|
||||||
return formatter.string(fromByteCount: bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatTimeInterval(_ interval: TimeInterval) -> String {
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.allowedUnits = [.hour, .minute, .second]
|
|
||||||
formatter.zeroFormattingBehavior = .pad
|
|
||||||
return formatter.string(from: interval) ?? "00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -186,7 +186,7 @@ struct MediaUploadExample: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("媒体上传")
|
.navigationTitle("媒体上传")
|
||||||
.sheet(isPresented: $showMediaPicker) {
|
.sheet(isPresented: $showMediaPicker) {
|
||||||
MediaPickerWithLogging(
|
MediaPicker(
|
||||||
selectedMedia: $uploadManager.selectedMedia,
|
selectedMedia: $uploadManager.selectedMedia,
|
||||||
selectionLimit: 5,
|
selectionLimit: 5,
|
||||||
onDismiss: { showMediaPicker = false }
|
onDismiss: { showMediaPicker = false }
|
||||||
@ -1,138 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,7 +23,7 @@ struct MediaUploadDemo: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.sheet(isPresented: $showMediaPicker) {
|
.sheet(isPresented: $showMediaPicker) {
|
||||||
MediaPickerWithLogging(
|
MediaPicker(
|
||||||
selectedMedia: $uploadManager.selectedMedia,
|
selectedMedia: $uploadManager.selectedMedia,
|
||||||
selectionLimit: 10,
|
selectionLimit: 10,
|
||||||
onDismiss: {
|
onDismiss: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user