484 lines
19 KiB
Swift
484 lines
19 KiB
Swift
import SwiftUI
|
|
import AVKit
|
|
import WaterfallGrid
|
|
|
|
// MARK: - API Response Models for BlindList
|
|
struct BlindListMaterialResponse: Decodable {
|
|
let code: Int
|
|
let data: [BlindBoxItem]
|
|
}
|
|
|
|
struct BlindBoxItem: Identifiable, Decodable {
|
|
let id: Int64
|
|
let boxCode: String
|
|
let userId: Int64
|
|
let name: String
|
|
let boxType: String
|
|
let features: String?
|
|
let resultFile: FileInfo?
|
|
let status: String
|
|
let workflowInstanceId: Int64?
|
|
let videoGenerateTime: String?
|
|
let createTime: String
|
|
let coverFile: FileInfo?
|
|
let description: String
|
|
|
|
struct FileInfo: Decodable {
|
|
let id: String
|
|
let fileName: String
|
|
let url: String
|
|
let metadata: [String: String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case fileName = "file_name"
|
|
case url
|
|
case metadata
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case boxCode = "box_code"
|
|
case userId = "user_id"
|
|
case name
|
|
case boxType = "box_type"
|
|
case features
|
|
case resultFile = "result_file"
|
|
case status
|
|
case workflowInstanceId = "workflow_instance_id"
|
|
case videoGenerateTime = "video_generate_time"
|
|
case createTime = "create_time"
|
|
case coverFile = "cover_file"
|
|
case description
|
|
}
|
|
}
|
|
|
|
enum BlindListMemoryMediaType: Equatable {
|
|
case image(String)
|
|
case video(url: String, previewUrl: String)
|
|
|
|
var isVideo: Bool {
|
|
if case .video = self { return true }
|
|
return false
|
|
}
|
|
|
|
var url: String {
|
|
switch self {
|
|
case .image(let url):
|
|
return url
|
|
case .video(let url, _):
|
|
return url
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Models
|
|
struct BlindListFileInfo {
|
|
let id: String
|
|
let fileName: String
|
|
let url: String
|
|
}
|
|
|
|
struct BlindListMemoryItem: Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let description: String
|
|
let fileInfo: BlindListFileInfo
|
|
let previewFileInfo: BlindListFileInfo
|
|
|
|
var title: String { name }
|
|
var subtitle: String { description }
|
|
|
|
var mediaType: BlindListMemoryMediaType {
|
|
// Determine media type based on file extension or other criteria
|
|
// For now, default to image
|
|
return .image(fileInfo.url)
|
|
}
|
|
|
|
var aspectRatio: CGFloat { 1.0 }
|
|
}
|
|
|
|
struct BlindListView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var memories: [BlindListMemoryItem] = []
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var selectedMemory: BlindListMemoryItem? = nil
|
|
|
|
let columns = [
|
|
GridItem(.flexible(), spacing: 1),
|
|
GridItem(.flexible(), spacing: 1)
|
|
]
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ZStack {
|
|
VStack(spacing: 0) {
|
|
// 顶部导航栏
|
|
HStack {
|
|
Button(action: {
|
|
self.dismiss()
|
|
}) {
|
|
Image(systemName: "chevron.left")
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.font(.system(size: 20))
|
|
}
|
|
Spacer()
|
|
Text("我的盲盒")
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.font(Typography.font(for: .body, family: .quicksandBold))
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.background(Color.themeTextWhiteSecondary)
|
|
|
|
// 内容区域
|
|
ZStack {
|
|
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
WaterfallGrid(memories) { memory in
|
|
BlindListMemoryCard(memory: memory)
|
|
.onTapGesture {
|
|
withAnimation(.spring()) {
|
|
selectedMemory = memory
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 全屏模态
|
|
if let memory = selectedMemory {
|
|
BlindListFullScreenMediaView(memory: memory, isPresented: $selectedMemory)
|
|
.transition(.opacity)
|
|
.zIndex(1)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarBackButtonHidden(true)
|
|
.onAppear {
|
|
fetchList()
|
|
}
|
|
}
|
|
|
|
private func fetchList() {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
NetworkService.shared.get(path: "/blind_boxs/query", parameters: nil) { [self] (result: Result<BlindListMaterialResponse, NetworkError>) in
|
|
DispatchQueue.main.async { [self] in
|
|
self.isLoading = false
|
|
|
|
switch result {
|
|
case .success(let response):
|
|
print("✅ Successfully fetched \(response.data.count) blind box items")
|
|
// Convert BlindBoxItem to BlindListMemoryItem
|
|
self.memories = response.data
|
|
.filter { $0.status == "Opened" }
|
|
.map { item in
|
|
BlindListMemoryItem(
|
|
id: String(item.id),
|
|
name: item.name,
|
|
description: item.description,
|
|
fileInfo: BlindListFileInfo(
|
|
id: item.resultFile?.id ?? "",
|
|
fileName: item.resultFile?.fileName ?? "",
|
|
url: item.resultFile?.url ?? ""
|
|
),
|
|
previewFileInfo: BlindListFileInfo(
|
|
id: item.coverFile?.id ?? "",
|
|
fileName: item.coverFile?.fileName ?? "",
|
|
url: item.coverFile?.url ?? ""
|
|
)
|
|
)
|
|
}
|
|
|
|
case .failure(let error):
|
|
self.errorMessage = error.localizedDescription
|
|
print("❌ Failed to fetch blind box items: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BlindListFullScreenMediaView: View {
|
|
let memory: BlindListMemoryItem
|
|
@Binding var isPresented: BlindListMemoryItem?
|
|
@State private var isVideoPlaying = false
|
|
@State private var showControls = true
|
|
@State private var controlsTimer: Timer? = nil
|
|
@State private var imageAspectRatio: CGFloat = 1.0
|
|
@State private var isLoading = true
|
|
|
|
private func loadAspectRatio(from url: URL) {
|
|
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
|
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
|
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
|
|
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
|
|
height > 0 else {
|
|
imageAspectRatio = 16/9
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
imageAspectRatio = width / height
|
|
isLoading = false
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
|
|
ZStack {
|
|
GeometryReader { geometry in
|
|
switch memory.mediaType {
|
|
case .image(let url):
|
|
if let imageURL = URL(string: url) {
|
|
AsyncImage(url: imageURL) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Color.black
|
|
image
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(
|
|
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
|
|
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.onAppear {
|
|
if let uiImage = image.asUIImage() {
|
|
let size = uiImage.size
|
|
imageAspectRatio = size.width / size.height
|
|
isLoading = false
|
|
}
|
|
}
|
|
case .failure(_):
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.red)
|
|
case .empty:
|
|
ProgressView()
|
|
@unknown default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
case .video(_, let previewUrl):
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Color.clear
|
|
BlindListVideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying)
|
|
.aspectRatio(imageAspectRatio, contentMode: .fit)
|
|
.frame(
|
|
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
|
|
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
|
|
)
|
|
.onAppear {
|
|
if let previewUrl = URL(string: previewUrl) {
|
|
loadAspectRatio(from: previewUrl)
|
|
}
|
|
isVideoPlaying = true
|
|
}
|
|
.onDisappear {
|
|
isVideoPlaying = false
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack {
|
|
HStack {
|
|
Button(action: {
|
|
withAnimation(.spring()) {
|
|
isPresented = nil
|
|
}
|
|
}) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 20, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(12)
|
|
.background(Circle().fill(Color.black.opacity(0.4)))
|
|
}
|
|
.padding(.leading, 16)
|
|
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
|
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
.zIndex(2)
|
|
}
|
|
.ignoresSafeArea()
|
|
.statusBar(hidden: true)
|
|
}
|
|
.onTapGesture {
|
|
if case .video = memory.mediaType {
|
|
withAnimation(.easeInOut) {
|
|
showControls.toggle()
|
|
}
|
|
}
|
|
}
|
|
.statusBar(hidden: true)
|
|
.onAppear {
|
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
}
|
|
.onDisappear {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
controlsTimer?.invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BlindListVideoPlayer: UIViewControllerRepresentable {
|
|
let url: String
|
|
@Binding var isPlaying: Bool
|
|
|
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
|
let controller = AVPlayerViewController()
|
|
let player = AVPlayer(url: URL(string: url)!)
|
|
controller.player = player
|
|
controller.showsPlaybackControls = true
|
|
controller.videoGravity = .resizeAspect
|
|
controller.view.backgroundColor = .clear
|
|
controller.view.isOpaque = false
|
|
|
|
return controller
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
|
if isPlaying {
|
|
uiViewController.player?.play()
|
|
} else {
|
|
uiViewController.player?.pause()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BlindListMemoryCard: View {
|
|
let memory: BlindListMemoryItem
|
|
@State private var aspectRatio: CGFloat = 1.0
|
|
@State private var isLoading = true
|
|
|
|
private func loadAspectRatio(from url: URL) {
|
|
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
|
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
|
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
|
|
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
|
|
height > 0 else {
|
|
aspectRatio = 16/9
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
aspectRatio = width / height
|
|
isLoading = false
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ZStack {
|
|
Group {
|
|
switch memory.mediaType {
|
|
case .image(let url):
|
|
if let url = URL(string: url) {
|
|
AsyncImage(url: url) { phase in
|
|
Group {
|
|
if let image = phase.image {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Color.black
|
|
image
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(
|
|
width: min(geometry.size.width, geometry.size.height * aspectRatio),
|
|
height: min(geometry.size.height, geometry.size.width / aspectRatio)
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill)
|
|
.onAppear {
|
|
if let uiImage = image.asUIImage() {
|
|
let size = uiImage.size
|
|
aspectRatio = size.width / size.height
|
|
isLoading = false
|
|
}
|
|
}
|
|
} else if phase.error != nil {
|
|
Color.gray.opacity(0.3)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case .video(_, let previewUrl):
|
|
if let previewUrl = URL(string: previewUrl) {
|
|
AsyncImage(url: previewUrl) { phase in
|
|
Group {
|
|
if let image = phase.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.onAppear {
|
|
loadAspectRatio(from: previewUrl)
|
|
}
|
|
} else if phase.error != nil {
|
|
Color.gray.opacity(0.3)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Color.gray.opacity(0.3)
|
|
}
|
|
}
|
|
}
|
|
.frame(
|
|
width: (UIScreen.main.bounds.width / 2) - 24,
|
|
height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio)
|
|
)
|
|
.clipped()
|
|
.cornerRadius(12)
|
|
|
|
if case .video = memory.mediaType {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.white.opacity(0.9))
|
|
.shadow(radius: 3)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(memory.title)
|
|
.font(Typography.font(for: .body, family: .quicksandBold))
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.lineLimit(1)
|
|
|
|
Text(memory.subtitle)
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.horizontal, 2)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|