import Foundation import Combine import UIKit import AVKit @MainActor final class BlindBoxViewModel: ObservableObject { // Inputs let mediaType: BlindBoxMediaType let currentBoxId: String? // Published state @Published var isMember: Bool = false @Published var memberDate: String = "" @Published var memberProfile: MemberProfile? = nil @Published var blindCount: BlindCount? = nil @Published var blindGenerate: BlindBoxData? = nil @Published var videoURL: String = "" @Published var imageURL: String = "" @Published var didBootstrap: Bool = false @Published var countdownText: String = "" // Media prepared for display @Published var player: AVPlayer? = nil @Published var displayImage: UIImage? = nil @Published var aspectRatio: CGFloat = 1.0 @Published var isPortrait: Bool = false // Tasks private var pollingTask: Task? = nil private var countdownTask: Task? = nil private var remainingSeconds: Int = 0 init(mediaType: BlindBoxMediaType, currentBoxId: String?) { self.mediaType = mediaType self.currentBoxId = currentBoxId } func load() async { Perf.event("BlindVM_Load_Begin") await bootstrapInitialState() await startPolling() loadMemberProfile() await loadBlindCount() Perf.event("BlindVM_Load_End") } func startPolling() async { // 如果已经是 Unopened,无需继续轮询 if blindGenerate?.status.lowercased() == "unopened" { return } stopPolling() if let boxId = currentBoxId { // Poll a single box until unopened pollingTask = Task { @MainActor [weak self] in guard let self else { return } do { for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) { Perf.event("BlindVM_Poll_Single_Yield") print("[VM] SingleBox polled status: \(data.status)") self.blindGenerate = data if self.mediaType == .image { self.imageURL = data.resultFile?.url ?? "" } else { self.videoURL = data.resultFile?.url ?? "" } self.applyStatusSideEffects() Task { await self.prepareMedia() } break } } catch is CancellationError { // cancelled } catch { print("❌ BlindBoxViewModel polling error (single): \(error)") } } } else { // Poll list and yield first unopened pollingTask = Task { @MainActor [weak self] in guard let self else { return } do { for try await item in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) { Perf.event("BlindVM_Poll_List_Yield") print("[VM] List polled first unopened: id=\(item.id ?? "nil"), status=\(item.status)") self.blindGenerate = item if self.mediaType == .image { self.imageURL = item.resultFile?.url ?? "" } else { self.videoURL = item.resultFile?.url ?? "" } self.applyStatusSideEffects() Task { await self.prepareMedia() } break } } catch is CancellationError { // cancelled } catch { print("❌ BlindBoxViewModel polling error (list): \(error)") } } } } private func bootstrapInitialState() async { if let boxId = currentBoxId { do { let data = try await BlindBoxApi.shared.getBlindBox(boxId: boxId) if let data = data { self.blindGenerate = data if mediaType == .image { self.imageURL = data.resultFile?.url ?? "" } else { self.videoURL = data.resultFile?.url ?? "" } self.applyStatusSideEffects() Task { await self.prepareMedia() } } } catch { print("❌ bootstrapInitialState (single) failed: \(error)") } } else { do { let list = try await BlindBoxApi.shared.getBlindBoxList() // 更新未开启数量(忽略大小写) let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count self.blindCount = BlindCount(availableQuantity: count) if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) { self.blindGenerate = item if mediaType == .image { self.imageURL = item.resultFile?.url ?? "" } else { self.videoURL = item.resultFile?.url ?? "" } self.applyStatusSideEffects() Task { await self.prepareMedia() } } else if let first = list?.first { // 没有 Unopened,选取第一个用于展示状态(通常是 Preparing) self.blindGenerate = first self.applyStatusSideEffects() } } catch { print("❌ bootstrapInitialState (list) failed: \(error)") } } // 标记首帧状态已准备,供视图决定是否显示 loading/ready self.didBootstrap = true Perf.event("BlindVM_Bootstrap_Done") } func stopPolling() { pollingTask?.cancel() pollingTask = nil } func openBlindBox(for id: String) async throws { let sp = Perf.begin("BlindVM_Open") defer { Perf.end("BlindVM_Open", id: sp) } try await BlindBoxApi.shared.openBlindBox(boxId: id) } private func loadMemberProfile() { NetworkService.shared.get( path: "/membership/personal-center-info", parameters: nil ) { [weak self] (result: Result) in Task { @MainActor in guard let self else { return } switch result { case .success(let response): self.memberProfile = response.data self.isMember = response.data.membershipLevel == "Pioneer" self.memberDate = response.data.membershipEndAt ?? "" print("✅ 成功获取会员信息:", response.data) print("✅ 用户ID:", response.data.userInfo.userId) case .failure(let error): print("❌ 获取会员信息失败:", error) } } } } private func loadBlindCount() async { do { let list = try await BlindBoxApi.shared.getBlindBoxList() let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count self.blindCount = BlindCount(availableQuantity: count) } catch { print("❌ 获取盲盒列表失败: \(error)") } } // MARK: - 状态副作用(倒计时等) private func applyStatusSideEffects() { let status = blindGenerate?.status.lowercased() ?? "" if status == "preparing" { // 若没有在计时或已结束,则从默认 36:50 开始;如后续需要可改为读取服务端剩余时间 if countdownTask == nil || remainingSeconds <= 0 { startCountdown(minutes: 36, seconds: 50) } } else { stopCountdown() } } func startCountdown(minutes: Int = 36, seconds: Int = 50) { stopCountdown() remainingSeconds = max(0, minutes * 60 + seconds) countdownText = String(format: "%02d:%02d", remainingSeconds / 60, remainingSeconds % 60) countdownTask = Task { [weak self] in while let self, !Task.isCancelled, self.remainingSeconds > 0 { do { try await Task.sleep(nanoseconds: 1_000_000_000) } catch { break } await MainActor.run { self.remainingSeconds -= 1 self.countdownText = String( format: "%02d:%02d", self.remainingSeconds / 60, self.remainingSeconds % 60 ) } } } } func stopCountdown() { countdownTask?.cancel() countdownTask = nil } // MARK: - Media Preparation func prepareMedia() async { if mediaType == .all { // Video path guard !videoURL.isEmpty, let url = URL(string: videoURL) else { return } let asset = AVAsset(url: url) let item = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: item) if let track = asset.tracks(withMediaType: .video).first { let size = track.naturalSize.applying(track.preferredTransform) let width = abs(size.width) let height = abs(size.height) self.aspectRatio = height == 0 ? 1.0 : width / height self.isPortrait = height > width } self.player = player } else if mediaType == .image { guard !imageURL.isEmpty, let url = URL(string: imageURL) else { return } do { let (data, _) = try await URLSession.shared.data(from: url) if let image = UIImage(data: data) { self.displayImage = image self.aspectRatio = image.size.height == 0 ? 1.0 : image.size.width / image.size.height self.isPortrait = image.size.height > image.size.width } } catch { print("⚠️ prepareMedia image load failed: \(error)") } } } }