265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
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 {
|
||
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<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: 30, seconds: 0)
|
||
}
|
||
} else {
|
||
stopCountdown()
|
||
}
|
||
}
|
||
|
||
func startCountdown(minutes: Int = 30, seconds: Int = 0) {
|
||
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)")
|
||
}
|
||
}
|
||
}
|
||
}
|