wake-ios/wake/View/Blind/BlindBoxViewModel.swift

263 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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 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<Void, Never>? = nil
private var countdownTask: Task<Void, Never>? = 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 {
try await BlindBoxApi.shared.openBlindBox(boxId: id)
}
private func loadMemberProfile() {
NetworkService.shared.get(
path: "/membership/personal-center-info",
parameters: nil
) { [weak self] (result: Result<MemberProfileResponse, NetworkError>) 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)")
}
}
}
}