diff --git a/wake/Features/BlindBox/Components/BlindBoxAnimationPhase.swift b/wake/Features/BlindBox/Components/BlindBoxAnimationPhase.swift new file mode 100644 index 0000000..ff5fbb9 --- /dev/null +++ b/wake/Features/BlindBox/Components/BlindBoxAnimationPhase.swift @@ -0,0 +1,9 @@ +import Foundation + +// 盲盒动画阶段 +enum BlindBoxAnimationPhase { + case loading + case ready + case opening + case none +} diff --git a/wake/Features/BlindBox/Components/BlindBoxAnimationView.swift b/wake/Features/BlindBox/Components/BlindBoxAnimationView.swift new file mode 100644 index 0000000..fddb069 --- /dev/null +++ b/wake/Features/BlindBox/Components/BlindBoxAnimationView.swift @@ -0,0 +1,36 @@ +import SwiftUI +import Lottie + +/// 统一管理盲盒开启动画 4 状态的组件:loading / ready / opening / none +struct BlindBoxAnimationView: View { + @Binding var phase: BlindBoxAnimationPhase + let onTapReady: () -> Void + let onOpeningCompleted: () -> Void + + var body: some View { + ZStack { + switch phase { + case .loading: + LottieView(name: "loading", isPlaying: true) + case .ready: + ZStack { + LottieView(name: "ready", isPlaying: true) + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onTapReady() + } + } + case .opening: + BlindBoxLottieOnceView(name: "opening") { + onOpeningCompleted() + } + case .none: + Image("Empty") + .resizable() + .scaledToFit() + } + } + .frame(width: 300, height: 300) + } +} diff --git a/wake/Features/BlindBox/Components/BlindBoxLottieOnceView.swift b/wake/Features/BlindBox/Components/BlindBoxLottieOnceView.swift new file mode 100644 index 0000000..f77c32d --- /dev/null +++ b/wake/Features/BlindBox/Components/BlindBoxLottieOnceView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import Lottie + +/// 仅播放一次并在完成时回调的 Lottie 视图 +struct BlindBoxLottieOnceView: UIViewRepresentable { + let name: String + var animationSpeed: CGFloat = 1.0 + let onCompleted: () -> Void + + func makeUIView(context: Context) -> LottieAnimationView { + let animationView = LottieAnimationView() + if let animation = LottieAnimation.named(name) { + animationView.animation = animation + } else if let path = Bundle.main.path(forResource: name, ofType: "json") { + let animation = LottieAnimation.filepath(path) + animationView.animation = animation + } + animationView.loopMode = .playOnce + animationView.animationSpeed = animationSpeed + animationView.contentMode = .scaleAspectFit + animationView.backgroundBehavior = .pauseAndRestore + animationView.play { _ in + onCompleted() + } + return animationView + } + + func updateUIView(_ uiView: LottieAnimationView, context: Context) { + // 单次播放,不需要在更新时重复触发 + } +} diff --git a/wake/Features/BlindBox/View/BlindBoxView.swift b/wake/Features/BlindBox/View/BlindBoxView.swift index 5d15a9b..32d90b0 100644 --- a/wake/Features/BlindBox/View/BlindBoxView.swift +++ b/wake/Features/BlindBox/View/BlindBoxView.swift @@ -8,13 +8,6 @@ extension Notification.Name { static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged") } -internal enum BlindBoxAnimationPhase { - case loading - case ready - case opening - case none -} - extension Notification.Name { static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer") } @@ -28,33 +21,18 @@ struct BlindBoxView: View { @State private var showSettings = false // 控制设置页面显示 @State private var showLogin = false // 倒计时由 ViewModel 管理(countdownText) - // 盲盒数据 - @State private var showScalingOverlay = false @State private var animationPhase: BlindBoxAnimationPhase = .none - @State private var scale: CGFloat = 0.1 - // showControls 状态已迁移至 BlindBoxMediaOverlay 组件内 - @State private var isAnimating = true - @State private var showMedia = false - // 查询数据 - 简单查询 + // 查询数据 - 简单查询 @Query private var login: [Login] - + init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { self.mediaType = mediaType self.currentBoxId = blindBoxId _viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId)) } - - private func startScalingAnimation() { - self.scale = 0.1 - self.showScalingOverlay = true - - withAnimation(.spring(response: 2.0, dampingFraction: 0.5, blendDuration: 0.8)) { - self.scale = 1.0 - } - } - // 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件 + // 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用) var body: some View { ZStack { @@ -63,7 +41,7 @@ struct BlindBoxView: View { Perf.event("BlindBox_Appear") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 Current thread: \(Thread.current)") - + // 调用接口 Task { await viewModel.load() @@ -100,12 +78,12 @@ struct BlindBoxView: View { } } .onChange(of: viewModel.videoURL) { _, url in - if !url.isEmpty { + if !url.isEmpty && self.animationPhase != .opening { withAnimation { self.animationPhase = .ready } } } .onChange(of: viewModel.imageURL) { _, url in - if !url.isEmpty { + if !url.isEmpty && self.animationPhase != .opening { withAnimation { self.animationPhase = .ready } } } @@ -123,243 +101,144 @@ struct BlindBoxView: View { } } - if showScalingOverlay { - BlindBoxMediaOverlay( - mediaType: mediaType, - player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }), - displayImage: viewModel.displayImage, - isPortrait: viewModel.isPortrait, - aspectRatio: viewModel.aspectRatio, - scale: $scale, - onBack: { - if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) { - Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember)) - } else if mediaType == .image, let image = viewModel.displayImage { - Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember)) - } - } - ) - } else { - // Original content - VStack { - VStack(spacing: 20) { - if mediaType == .all { - BlindBoxHeaderBar( - onMenuTap: showUserProfile, - remainPoints: viewModel.memberProfile?.remainPoints ?? 0, - showLogin: $showLogin - ) - } - - // 标题 - BlindBoxTitleView() - .opacity(showScalingOverlay ? 0 : 1) - .offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0) - .animation(.easeInOut(duration: 0.5), value: showScalingOverlay) - - // 盲盒 - ZStack { - // 1. 背景Card - if !showScalingOverlay { - CardBlindBackground() - } - if mediaType == .all && !showScalingOverlay { - BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes") - .position(x: UIScreen.main.bounds.width * 0.7, - y: UIScreen.main.bounds.height * 0.18) - .opacity(showScalingOverlay ? 0 : 1) - .animation(.easeOut(duration: 1.5), value: showScalingOverlay) - } - if !showScalingOverlay { - VStack(spacing: 20) { - switch animationPhase { - case .loading: - LottieView(name: "ready", isPlaying: animationPhase == .loading && !showScalingOverlay) - .frame(width: 300, height: 300) - // .onAppear { - // DispatchQueue.main.asyncAfter(deadline: .now() + 6) { - // withAnimation { - // animationPhase = .ready - // } - // } - // } - - case .ready: - ZStack { - LottieView(name: "ready", isPlaying: animationPhase == .ready && !showScalingOverlay) - .frame(width: 300, height: 300) - - // Add a transparent overlay to capture taps - Color.clear - .contentShape(Rectangle()) // Make the entire area tappable - .frame(width: 300, height: 300) - .onTapGesture { - Perf.event("BlindBox_Open_Tapped") - print("点击了盲盒") - - let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id - if let boxId = boxIdToOpen { - Task { - do { - try await viewModel.openBlindBox(for: boxId) - print("✅ 盲盒开启成功") - } catch { - print("❌ 开启盲盒失败: \(error)") - } - } - } - withAnimation { - animationPhase = .opening - } - } - } - .frame(width: 300, height: 300) - - case .opening: - ZStack { - if !showMedia { - LottieView(name: "ready", loopMode: .playOnce, isPlaying: !showMedia) - .frame(width: 300, height: 300) - .scaleEffect(scale) - } - // 当显示媒体时,移除 GIFView 避免后台播放 - Color.clear - .onAppear { - Perf.event("BlindBox_Opening_Begin") - print("开始播放开启动画") - // 初始缩放为1(原始大小) - self.scale = 1.0 - - // 1秒后开始全屏动画 - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - withAnimation(.spring(response: 1.0, dampingFraction: 0.7)) { - // 缩放到全屏 - self.scale = max( - UIScreen.main.bounds.width / 300, - UIScreen.main.bounds.height / 300 - ) * 1.2 - - // 全屏后稍作停留,然后缩小回原始大小 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { - self.scale = 1.0 - - // 显示媒体内容 - Perf.event("BlindBox_Opening_ShowMedia") - self.showScalingOverlay = true - Task { await viewModel.prepareMedia() } - - // 标记显示媒体,隐藏GIF - self.showMedia = true - } - } - } - } - } - } - .frame(width: 300, height: 300) - - case .none: - // 首帧占位,避免加载时闪烁 - LottieView(name: "ready", loopMode: .loop, isPlaying: true) - .frame(width: 300, height: 300) - .scaleEffect(scale) - // Color.clear - // .frame(width: 300, height: 300) - // SVGImage(svgName: "BlindNone") - // .frame(width: 300, height: 300) - } - } - .offset(y: -50) - .compositingGroup() - .padding() - } - // 只在未显示媒体且未播放动画时显示文字 - if !showScalingOverlay && !showMedia { - BlindBoxDescriptionView( - name: viewModel.blindGenerate?.name ?? "Some box", - description: viewModel.blindGenerate?.description ?? "" - ) - .offset(x: -10, y: UIScreen.main.bounds.height * 0.2) - } - } - .padding() - .frame( - maxWidth: .infinity, - maxHeight: UIScreen.main.bounds.height * 0.65 + // 原 overlay 分支已移除,直接展示内容 + // Original content + VStack { + VStack(spacing: 20) { + if mediaType == .all { + BlindBoxHeaderBar( + onMenuTap: showUserProfile, + remainPoints: viewModel.memberProfile?.remainPoints ?? 0, + showLogin: $showLogin ) - .opacity(showScalingOverlay ? 0 : 1) - .animation(.easeOut(duration: 1.5), value: showScalingOverlay) - .offset(y: showScalingOverlay ? -100 : 0) - .animation(.easeInOut(duration: 1.5), value: showScalingOverlay) - - // 打开 TODO 引导时,也要有按钮 - if mediaType == .all, viewModel.didBootstrap { - BlindBoxActionButton( - phase: animationPhase, - countdownText: viewModel.countdownText, - onOpen: { + } + + // 标题 + BlindBoxTitleView() + .opacity(animationPhase == .opening ? 0 : 1) + + // 盲盒 + ZStack { + // 1. 背景Card + CardBlindBackground() + if mediaType == .all { + BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes") + .position(x: UIScreen.main.bounds.width * 0.7, + y: UIScreen.main.bounds.height * 0.18) + } + VStack(spacing: 20) { + BlindBoxAnimationView( + phase: $animationPhase, + onTapReady: { + Perf.event("BlindBox_Open_Tapped") + print("点击了盲盒") let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id if let boxId = boxIdToOpen { Task { do { try await viewModel.openBlindBox(for: boxId) print("✅ 盲盒开启成功") + await viewModel.startPolling() + withAnimation { + animationPhase = .opening + } } catch { print("❌ 开启盲盒失败: \(error)") } } } - withAnimation { - animationPhase = .opening - } }, - onGoToBuy: { - Router.shared.navigate(to: .mediaUpload) + onOpeningCompleted: { + navigateToOutcome() } ) - .padding(.horizontal) + } + .offset(y: -50) + .compositingGroup() + .padding() + // 非 opening 阶段显示文字 + if animationPhase != .opening { + BlindBoxDescriptionView( + name: viewModel.blindGenerate?.name ?? "Some box", + description: viewModel.blindGenerate?.description ?? "" + ) + .offset(x: -10, y: UIScreen.main.bounds.height * 0.2) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.themeTextWhiteSecondary) - .offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0) - .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal) - .edgesIgnoringSafeArea(.all) - } - - // 用户资料弹窗 - SlideInModal( - isPresented: $showModal, - onDismiss: hideUserProfile - ) { - UserProfileModal( - showModal: $showModal, - showSettings: $showSettings, - isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }), - memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 }) + .padding() + .frame( + maxWidth: .infinity, + maxHeight: UIScreen.main.bounds.height * 0.65 ) - } - .offset(x: showSettings ? UIScreen.main.bounds.width : 0) - .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) - - // 设置页面遮罩层 - ZStack { - if showSettings { - Color.black.opacity(0.3) - .edgesIgnoringSafeArea(.all) - .onTapGesture(perform: hideSettings) - .transition(.opacity) - } - if showSettings { - SettingsView(isPresented: $showSettings) - .transition(.move(edge: .leading)) - .zIndex(1) + + // 打开 TODO 引导时,也要有按钮 + if mediaType == .all, viewModel.didBootstrap { + BlindBoxActionButton( + phase: animationPhase, + countdownText: viewModel.countdownText, + onOpen: { + let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id + if let boxId = boxIdToOpen { + Task { + do { + try await viewModel.openBlindBox(for: boxId) + print("✅ 盲盒开启成功") + await viewModel.startPolling() + withAnimation { + animationPhase = .opening + } + } catch { + print("❌ 开启盲盒失败: \(error)") + } + } + } + }, + onGoToBuy: { + Router.shared.navigate(to: .mediaUpload) + } + ) + .padding(.horizontal) } } - .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.themeTextWhiteSecondary) + .offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal) + .edgesIgnoringSafeArea(.all) } + + // 用户资料弹窗 + SlideInModal( + isPresented: $showModal, + onDismiss: hideUserProfile + ) { + UserProfileModal( + showModal: $showModal, + showSettings: $showSettings, + isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }), + memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 }) + ) + } + .offset(x: showSettings ? UIScreen.main.bounds.width : 0) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) + + // 设置页面遮罩层 + ZStack { + if showSettings { + Color.black.opacity(0.3) + .edgesIgnoringSafeArea(.all) + .onTapGesture(perform: hideSettings) + .transition(.opacity) + } + + if showSettings { + SettingsView(isPresented: $showSettings) + .transition(.move(edge: .leading)) + .zIndex(1) + } + } + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) } .navigationBarBackButtonHidden(true) } @@ -374,7 +253,7 @@ struct BlindBoxView: View { for (index, item) in login.enumerated() { print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)") } - showModal.toggle() + showModal.toggle() } } @@ -384,13 +263,65 @@ struct BlindBoxView: View { showModal = false } } - + /// 隐藏设置页面 private func hideSettings() { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showSettings = false } } + + /// 开启动画播放完成后,准备媒体并跳转到结果页 + private func navigateToOutcome() { + Perf.event("BlindBox_Opening_Completed") + Task { @MainActor in + let interval: UInt64 = 300_000_000 // 300ms + let timeout: UInt64 = 6_000_000_000 // 6s + var waited: UInt64 = 0 + + if mediaType == .all { + // 等待视频 URL 就绪 + while viewModel.videoURL.isEmpty && waited < timeout { + try? await Task.sleep(nanoseconds: interval) + waited += interval + } + // 拿到 URL 即可跳转;不强依赖 player 准备 + if !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) { + Router.shared.navigate( + to: .blindOutcome( + media: .video(url, nil), + time: viewModel.blindGenerate?.name ?? "Your box", + description: viewModel.blindGenerate?.description ?? "", + isMember: viewModel.isMember + ) + ) + return + } + } else if mediaType == .image { + // 等到有 imageURL 后再加载 UIImage + while viewModel.imageURL.isEmpty && waited < timeout { + try? await Task.sleep(nanoseconds: interval) + waited += interval + } + if viewModel.displayImage == nil && !viewModel.imageURL.isEmpty { + await viewModel.prepareMedia() + } + if let image = viewModel.displayImage { + Router.shared.navigate( + to: .blindOutcome( + media: .image(image), + time: viewModel.blindGenerate?.name ?? "Your box", + description: viewModel.blindGenerate?.description ?? "", + isMember: viewModel.isMember + ) + ) + return + } + } + // 若仍未获取到媒体,记录日志以便排查 + print("⚠️ navigateToOutcome: 媒体尚未准备好,videoURL=\(viewModel.videoURL), image=\(String(describing: viewModel.displayImage))") + } + } } // MARK: - 预览 @@ -423,4 +354,4 @@ struct BlindBoxView: View { } #endif } -} \ No newline at end of file +}