feat: 媒体上传
This commit is contained in:
parent
1fca9f413c
commit
1e6305ec35
84
wake/Utils/MediaUtils.swift
Normal file
84
wake/Utils/MediaUtils.swift
Normal file
@ -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<UIImage, Error>) -> 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<UIImage, Error>) -> 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
wake/View/Components/Upload/MediaPicker.swift
Normal file
221
wake/View/Components/Upload/MediaPicker.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
wake/View/Components/Upload/MediaUploader.swift
Normal file
138
wake/View/Components/Upload/MediaUploader.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
wake/View/Components/Upload/VideoPicker.swift
Normal file
88
wake/View/Components/Upload/VideoPicker.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
wake/View/Examples/MediaUpload.swift
Normal file
104
wake/View/Examples/MediaUpload.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -46,7 +46,7 @@ struct WakeApp: App {
|
|||||||
// 已登录:显示userInfo页面
|
// 已登录:显示userInfo页面
|
||||||
// UserInfo()
|
// UserInfo()
|
||||||
// .environmentObject(authState)
|
// .environmentObject(authState)
|
||||||
MultiImageUploadExampleView()
|
ExampleView()
|
||||||
.environmentObject(authState)
|
.environmentObject(authState)
|
||||||
} else {
|
} else {
|
||||||
// 未登录:显示登录界面
|
// 未登录:显示登录界面
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user