wake-ios/wake/View/OnBoarding/MediaUploadView.swift

726 lines
27 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import PhotosUI
import AVKit
import CoreTransferable
import CoreImage.CIFilterBuiltins
extension Notification.Name {
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
}
///
///
@MainActor
struct MediaUploadView: View {
// MARK: -
///
@StateObject private var uploadManager = MediaUploadManager()
/// /
@State private var showMediaPicker = false
///
@State private var selectedMedia: MediaType? = nil
///
@State private var selectedIndices: Set<Int> = []
@State private var mediaPickerSelection: [MediaType] = [] //
///
@State private var uploadComplete = false
/// ID
@State private var uploadedFileIds: [[String: String]] = []
// MARK: -
var body: some View {
VStack(spacing: 0) {
//
topNavigationBar
//
uploadHintView
Spacer()
.frame(height: uploadManager.selectedMedia.isEmpty ? 80 : 40)
//
MainUploadArea(
uploadManager: uploadManager,
showMediaPicker: $showMediaPicker,
selectedMedia: $selectedMedia
)
.id("mainUploadArea\(uploadManager.selectedMedia.count)")
Spacer()
// //
// if uploadComplete && !uploadedFileIds.isEmpty {
// VStack(alignment: .leading) {
// Text("")
// .font(.headline)
// ScrollView {
// ForEach(Array(uploadedFileIds.enumerated()), id: \.offset) { index, fileInfo in
// VStack(alignment: .leading) {
// Text(" \(index + 1):")
// .font(.subheadline)
// Text("ID: \(fileInfo["file_id"] ?? "")")
// .font(.caption)
// .foregroundColor(.gray)
// }
// .padding()
// .frame(maxWidth: .infinity, alignment: .leading)
// .background(Color.gray.opacity(0.1))
// .cornerRadius(8)
// }
// }
// .frame(height: 200)
// }
// .padding()
// }
//
continueButton
.padding(.bottom, 24)
}
.background(Color.themeTextWhiteSecondary)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.sheet(isPresented: $showMediaPicker) {
//
mediaPickerView
}
.onChange(of: uploadManager.uploadResults) { _, newResults in
handleUploadCompletion(results: newResults)
}
}
// MARK: -
///
private var topNavigationBar: some View {
HStack {
//
Button(action: { Router.shared.pop() }) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.themeTextMessageMain)
}
.padding(.leading, 16)
Spacer()
//
Text("Complete Your Profile")
.font(Typography.font(for: .title2, family: .quicksandBold))
.foregroundColor(.themeTextMessageMain)
Spacer()
//
Color.clear
.frame(width: 24, height: 24)
.padding(.trailing, 16)
}
.background(Color.themeTextWhiteSecondary)
// .padding(.horizontal)
.zIndex(1) //
}
///
private var uploadHintView: some View {
HStack (spacing: 6) {
Image(systemName: "lightbulb")
.font(.system(size: 16, weight: .regular))
.foregroundColor(.themeTextMessageMain)
.padding(.leading,6)
Text("The upload process will take approximately 2 minutes. Thank you for your patience.")
.font(.caption)
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(3)
}
.background(
Color.themeTextWhite
.cornerRadius(6)
)
.padding(.vertical, 8)
.padding(.horizontal)
}
///
private var continueButton: some View {
Button(action: handleContinue) {
Text("Continue")
.font(.headline)
.foregroundColor(uploadManager.selectedMedia.isEmpty ? Color.themeTextMessage : Color.themeTextMessageMain)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(uploadManager.selectedMedia.isEmpty ? Color.white : Color.themePrimary)
.cornerRadius(28)
.padding(.horizontal, 24)
}
.buttonStyle(PlainButtonStyle())
.disabled(uploadManager.selectedMedia.isEmpty)
}
///
private var mediaPickerView: some View {
MediaPicker(
selectedMedia: Binding(
get: { mediaPickerSelection },
set: { newSelections in
print("🔄 开始处理用户选择的媒体文件")
print("📌 新选择的媒体数量: \(newSelections.count)")
// 1.
var uniqueNewMedia: [MediaType] = []
for newItem in newSelections {
let isDuplicate = uploadManager.selectedMedia.contains { existingItem in
switch (existingItem, newItem) {
case (.image(let existingImage), .image(let newImage)):
return existingImage.pngData() == newImage.pngData()
case (.video(let existingURL, _), .video(let newURL, _)):
return existingURL == newURL
default:
return false
}
}
if !isDuplicate {
uniqueNewMedia.append(newItem)
} else {
print("⚠️ 检测到重复文件,已跳过: \(newItem)")
}
}
// 2.
if !uniqueNewMedia.isEmpty {
print("✅ 添加 \(uniqueNewMedia.count) 个新文件")
uploadManager.addMedia(uniqueNewMedia)
//
if selectedMedia == nil, let firstNewItem = uniqueNewMedia.first {
selectedMedia = firstNewItem
}
//
uploadManager.startUpload()
} else {
print(" 没有新文件需要添加,所有选择的文件都已存在")
}
}
),
imageSelectionLimit: max(0, 20 - uploadManager.selectedMedia.filter {
if case .image = $0 { return true }
return false
}.count),
videoSelectionLimit: max(0, 5 - uploadManager.selectedMedia.filter {
if case .video = $0 { return true }
return false
}.count),
selectionMode: .multiple,
onDismiss: handleMediaPickerDismiss,
onUploadProgress: { index, progress in
print("文件 \(index) 上传进度: \(progress * 100)%")
}
)
.onAppear {
//
mediaPickerSelection = []
}
}
// MARK: -
///
private func handleMediaPickerDismiss() {
showMediaPicker = false
print("媒体选择器关闭 - 开始处理")
//
if !uploadManager.selectedMedia.isEmpty {
// handleMediaChange
}
}
///
/// - Parameters:
/// - newMedia:
/// - oldMedia:
private func handleMediaChange(_ newMedia: [MediaType], oldMedia: [MediaType]) {
print("开始处理媒体变化,新数量: \(newMedia.count), 原数量: \(oldMedia.count)")
//
guard newMedia != oldMedia else {
print("媒体未发生变化,跳过处理")
return
}
// 线
DispatchQueue.global(qos: .userInitiated).async { [self] in
// newMediaoldMedia
let newItems = newMedia.filter { newItem in
!oldMedia.contains { $0.id == newItem.id }
}
print("检测到\(newItems.count)个新增媒体项")
//
if !newItems.isEmpty {
print("准备添加\(newItems.count)个新项...")
// 线UI
DispatchQueue.main.async { [self] in
//
var updatedMedia = uploadManager.selectedMedia
updatedMedia.append(contentsOf: newItems)
//
uploadManager.clearAllMedia()
uploadManager.addMedia(updatedMedia)
//
if selectedIndices.isEmpty && !newItems.isEmpty {
selectedIndices = [oldMedia.count] //
selectedMedia = newItems.first
}
//
uploadManager.startUpload()
print("媒体添加完成,总数量: \(uploadManager.selectedMedia.count)")
}
}
}
}
///
/// - Returns:
private func isUploading() -> Bool {
return uploadManager.uploadStatus.values.contains { status in
if case .uploading = status { return true }
return false
}
}
///
private func handleUploadCompletion(results: [String: MediaUploadManager.UploadResult]) {
//
let formattedResults = results.map { (_, result) -> [String: String] in
return [
"file_id": result.fileId,
"preview_file_id": result.thumbnailId ?? result.fileId
]
}
uploadedFileIds = formattedResults
uploadComplete = !uploadedFileIds.isEmpty
}
///
private func handleContinue() {
//
let uploadResults = uploadManager.uploadResults
guard !uploadResults.isEmpty else {
print("⚠️ 没有可用的文件ID")
return
}
//
let files = uploadResults.map { (_, result) -> [String: String] in
return [
"file_id": result.fileId,
"preview_file_id": result.thumbnailId ?? result.fileId
]
}
// id
Task {
do {
let materialIds = try await MaterialUpload.shared.addMaterials(files: files)
print("🚀 素材ID: \(materialIds ?? [])")
//
if let materialIds = materialIds {
let result = try await BlindBoxApi.shared.generateBlindBox(boxType: "Second", materialIds: materialIds)
print("🎉 盲盒结果: \(result ?? nil)")
if let result = result {
let blindBoxId = result.id ?? ""
print("🎉 盲盒ID: \(blindBoxId)")
//
Router.shared.navigate(to: .blindBox(mediaType: .all, blindBoxId: blindBoxId))
}
}
} catch {
print("❌ 添加素材失败: \(error)")
}
}
}
}
// MARK: -
///
///
struct MainUploadArea: View {
// MARK: -
///
@ObservedObject var uploadManager: MediaUploadManager
/// /
@Binding var showMediaPicker: Bool
///
@Binding var selectedMedia: MediaType?
// MARK: -
var body: some View {
VStack() {
Spacer()
.frame(height: 30)
//
Text("Click to upload 5+ videos to generate your next blind box.")
.font(Typography.font(for: .title2, family: .quicksandBold))
.fontWeight(.bold)
.foregroundColor(.black)
.multilineTextAlignment(.center)
.padding(.horizontal)
Spacer()
.frame(height: 50)
//
if let mediaToDisplay = selectedMedia ?? uploadManager.selectedMedia.first {
Button(action: { showMediaPicker = true }) {
MediaPreview(media: mediaToDisplay)
.id(mediaToDisplay.id)
.frame(width: 225, height: 225)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.themePrimary, lineWidth: 5)
)
.cornerRadius(16)
.padding(.horizontal)
.transition(.opacity)
}
} else {
UploadPromptView(showMediaPicker: $showMediaPicker)
}
//
mediaPreviewSection
Spacer()
.frame(height: 10)
}
.onAppear {
print("MainUploadArea appeared")
print("Selected media count: \(uploadManager.selectedMedia.count)")
if selectedMedia == nil, let firstMedia = uploadManager.selectedMedia.first {
print("Selecting first media: \(firstMedia.id)")
selectedMedia = firstMedia
}
}
.onReceive(NotificationCenter.default.publisher(for: .didAddFirstMedia)) { notification in
if let media = notification.userInfo?["media"] as? MediaType, selectedMedia == nil {
selectedMedia = media
}
}
.background(Color.white)
.cornerRadius(18)
.animation(.default, value: selectedMedia?.id)
}
// MARK: -
///
private var mediaPreviewSection: some View {
Group {
if !uploadManager.selectedMedia.isEmpty {
VStack(spacing: 4) {
//
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in
mediaItemView(for: media, at: index)
}
//
if !uploadManager.selectedMedia.isEmpty {
addMoreButton
}
}
.padding(.horizontal)
}
.frame(height: 70)
}
.padding(.top, 10)
}
}
}
///
/// - Parameters:
/// - media:
/// - index:
/// - Returns:
private func mediaItemView(for media: MediaType, at index: Int) -> some View {
ZStack(alignment: .topTrailing) {
// - 使
MediaPreview(media: media)
.frame(width: 58, height: 58)
.cornerRadius(8)
.shadow(radius: 1)
.overlay(
//
ZStack(alignment: .topLeading) {
Path { path in
let radius: CGFloat = 4
let width: CGFloat = 14
let height: CGFloat = 10
//
path.move(to: CGPoint(x: 0, y: radius))
path.addQuadCurve(to: CGPoint(x: radius, y: 0),
control: CGPoint(x: 0, y: 0))
//
path.addLine(to: CGPoint(x: width, y: 0))
//
path.addLine(to: CGPoint(x: width, y: height - radius))
//
path.addQuadCurve(to: CGPoint(x: width - radius, y: height),
control: CGPoint(x: width, y: height))
//
path.addLine(to: CGPoint(x: 0, y: height))
//
path.closeSubpath()
}
.fill(Color(hex: "BEBEBE").opacity(0.6))
.frame(width: 14, height: 10)
.overlay(
Text("\(index + 1)")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.frame(width: 14, height: 10)
.offset(y: -1),
alignment: .topLeading
)
.padding([.top, .leading], 2)
//
if case .video(let url, _) = media, let videoURL = url as? URL {
VStack {
Spacer()
HStack {
Spacer()
Text(getVideoDuration(url: videoURL))
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.padding(.horizontal, 4)
.frame(height: 10)
.background(Color(hex: "BEBEBE").opacity(0.6))
.cornerRadius(2)
}
.padding([.trailing, .bottom], 0)
}
}else{
//
VStack {
Spacer()
HStack {
Spacer()
Text("占位")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.padding(.horizontal, 4)
.frame(height: 10)
.background(Color(hex: "BEBEBE").opacity(0.6))
.cornerRadius(2)
}
.padding([.trailing, .bottom], 0)
}
.opacity(0)
}
},
alignment: .topLeading
)
.onTapGesture {
print("点击了媒体项,索引: \(index)")
withAnimation {
selectedMedia = media
}
}
.contentShape(Rectangle())
//
Button(action: {
uploadManager.removeMedia(id: media.id)
if selectedMedia == media {
selectedMedia = nil
}
}) {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.frame(width: 12, height: 12)
.background(
Circle()
.fill(Color(hex: "BEBEBE").opacity(0.6))
.frame(width: 12, height: 12)
)
}
.offset(x: 6, y: -6)
}
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
///
private var addMoreButton: some View {
Button(action: { showMediaPicker = true }) {
Image(systemName: "plus")
.font(.system(size: 8, weight: .bold))
.foregroundColor(.black)
.frame(width: 58, height: 58)
.background(Color.white)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(style: StrokeStyle(
lineWidth: 2,
dash: [4, 4]
))
.foregroundColor(Color.themePrimary)
)
}
}
}
// MARK: -
///
///
struct UploadPromptView: View {
/// /
@Binding var showMediaPicker: Bool
var body: some View {
Button(action: { showMediaPicker = true }) {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.white)
.frame(width: 225, height: 225)
}
.contentShape(Rectangle())
.overlay(
ZStack {
RoundedRectangle(cornerRadius: 20)
.stroke(style: StrokeStyle(
lineWidth: 5,
lineCap: .round,
dash: [12, 8]
))
.foregroundColor(Color.themePrimary)
// Add plus icon in the center
Image(systemName: "plus")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.black)
}
)
}
}
}
// MARK: -
///
/// 使
struct MediaPreview: View {
// MARK: -
///
let media: MediaType
// MARK: -
///
private var displayImage: UIImage? {
switch media {
case .image(let uiImage):
return uiImage
case .video(_, let thumbnail):
return thumbnail
}
}
// MARK: -
var body: some View {
ZStack {
// 1.
if let image = displayImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
} else {
// 2.
Color.gray.opacity(0.1)
}
}
.aspectRatio(1, contentMode: .fill)
.clipped()
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.themePrimary.opacity(0.3), lineWidth: 1)
)
}
}
private func getVideoDuration(url: URL) -> String {
let asset = AVURLAsset(url: url)
let durationInSeconds = CMTimeGetSeconds(asset.duration)
guard durationInSeconds.isFinite else { return "0:00" }
let minutes = Int(durationInSeconds) / 60
let seconds = Int(durationInSeconds) % 60
return String(format: "%d:%02d", minutes, seconds)
}
// MARK: - Response Types
private struct EmptyResponse: Decodable {
// Empty response type for endpoints that don't return data
}
// MARK: -
/// MediaType Identifiable
extension MediaType: Identifiable {
///
public var id: String {
switch self {
case .image(let uiImage):
return "image_\(uiImage.hashValue)"
case .video(let url, _):
return "video_\(url.absoluteString.hashValue)"
}
}
}
extension TimeInterval {
var formattedDuration: String {
let minutes = Int(self) / 60
let seconds = Int(self) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
// MARK: -
struct MediaUploadView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
MediaUploadView()
}
}
}