diff --git a/wake/ContentView.swift b/wake/ContentView.swift index 189a468..e66f3e6 100644 --- a/wake/ContentView.swift +++ b/wake/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import AVKit // MARK: - 自定义过渡动画 extension AnyTransition { @@ -12,6 +13,70 @@ extension AnyTransition { } } +// MARK: - Video Player View +struct VideoPlayerView: View { + let player: AVPlayer + @Binding var isFullscreen: Bool + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VideoPlayer(player: player) + .frame(width: 300, height: 200) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + + // 全屏按钮 + Button(action: { + isFullscreen = true + player.play() + }) { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 20)) + .foregroundColor(.white) + .padding(8) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + } + .padding(16) + } + .frame(width: 300, height: 200) + .onTapGesture { + player.play() + } + .onAppear { + player.play() + } + .fullScreenCover(isPresented: $isFullscreen) { + ZStack(alignment: .topLeading) { + // 全屏视频播放器 + VideoPlayer(player: player) + .edgesIgnoringSafeArea(.all) + .onAppear { player.play() } + + // 关闭按钮 + Button(action: { + isFullscreen = false + player.pause() + }) { + Image(systemName: "xmark.circle.fill") + .font(.title) + .foregroundColor(.white) + .padding() + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .padding(.top, 50) + .padding(.leading, 20) + } + .background(Color.black.edgesIgnoringSafeArea(.all)) + .statusBar(hidden: true) + } + } +} + // MARK: - 主视图 struct ContentView: View { // MARK: - 状态属性 @@ -20,6 +85,9 @@ struct ContentView: View { @State private var contentOffset: CGFloat = 0 // 内容偏移量 @State private var showLogin = false @State private var animateGradient = false + @State private var showLottieAnimation = true // 控制Lottie动画显示 + @State private var showVideoPlayer = false // 控制视频播放器显示 + @State private var isVideoFullscreen = false // 控制视频全屏状态 let timer = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect() @@ -29,6 +97,19 @@ struct ContentView: View { // 查询数据 - 简单查询 @Query private var login: [Login] + // 视频播放器 + private let player: AVPlayer? + + init() { + // 使用远程视频URL + if let videoURL = URL(string: "https://cdn.fairclip.cn/files/7342843896868769793/飞书20250617-144935.mp4") { + self.player = AVPlayer(url: videoURL) + } else { + self.player = nil + print("Error: Invalid video URL") + } + } + // MARK: - 主体视图 var body: some View { NavigationView { @@ -112,10 +193,29 @@ struct ContentView: View { y: UIScreen.main.bounds.height * 0.325) // 2. Lottie动画层 - LottieView(name: "loading", loopMode: .loop) - .frame(width: 200, height: 200) - .position(x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height * 0.325) + if showLottieAnimation { + LottieView(name: "loading", loopMode: .loop) + .frame(width: 200, height: 200) + .position(x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height * 0.325) + .onAppear { + // 5秒后隐藏Lottie动画并显示视频 + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + withAnimation { + showLottieAnimation = false + showVideoPlayer = true + } + } + } + } + + // 3. 视频播放器 + if showVideoPlayer, let player = player { + VideoPlayerView(player: player, isFullscreen: $isVideoFullscreen) + } else if showVideoPlayer { + Text("Video not found") + .foregroundColor(.red) + } } .frame( maxWidth: .infinity, diff --git a/wake/Models/MediaType.swift b/wake/Models/MediaType.swift new file mode 100644 index 0000000..b132c43 --- /dev/null +++ b/wake/Models/MediaType.swift @@ -0,0 +1,46 @@ +import SwiftUI +import AVKit + +/// Represents different types of media that can be displayed or processed +public enum MediaType: Equatable, Hashable { + case image(UIImage) + case video(URL, UIImage?) // URL is the video URL, UIImage is the thumbnail + + public var thumbnail: UIImage? { + switch self { + case .image(let image): + return image + case .video(_, let thumbnail): + return thumbnail + } + } + + public var isVideo: Bool { + if case .video = self { + return true + } + return false + } + + public static func == (lhs: MediaType, rhs: MediaType) -> Bool { + switch (lhs, rhs) { + case (.image(let lhsImage), .image(let rhsImage)): + return lhsImage.pngData() == rhsImage.pngData() + case (.video(let lhsURL, _), .video(let rhsURL, _)): + return lhsURL == rhsURL + default: + return false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .image(let image): + hasher.combine("image") + hasher.combine(image.pngData()) + case .video(let url, _): + hasher.combine("video") + hasher.combine(url) + } + } +} diff --git a/wake/Utils/APIConfig.swift b/wake/Utils/APIConfig.swift index 5257f1b..5b8265b 100644 --- a/wake/Utils/APIConfig.swift +++ b/wake/Utils/APIConfig.swift @@ -3,7 +3,7 @@ import Foundation /// API 配置信息 public enum APIConfig { /// API 基础 URL - public static let baseURL = "https://api.memorywake.com/api/v1" + public static let baseURL = "https://api-dev.memorywake.com:31274/api/v1" /// 认证 token - 从 Keychain 中获取 public static var authToken: String { diff --git a/wake/Utils/Router.swift b/wake/Utils/Router.swift index e64f03f..09d5dad 100644 --- a/wake/Utils/Router.swift +++ b/wake/Utils/Router.swift @@ -6,7 +6,8 @@ enum AppRoute: Hashable { case feedbackView case feedbackDetail(type: FeedbackView.FeedbackType) case mediaUpload - // Add other routes here as needed + case blindBox + case blindOutcome(media: MediaType) @ViewBuilder var view: some View { @@ -19,6 +20,10 @@ enum AppRoute: Hashable { FeedbackDetailView(feedbackType: type) case .mediaUpload: MediaUploadView() + case .blindBox: + BlindBoxView() + case .blindOutcome(let media): + BlindOutcomeView(media: media) } } } diff --git a/wake/View/Blind/BlindBox.swift b/wake/View/Blind/BlindBox.swift new file mode 100644 index 0000000..570af09 --- /dev/null +++ b/wake/View/Blind/BlindBox.swift @@ -0,0 +1,86 @@ +import SwiftUI +import SwiftData +import AVKit + +extension Notification.Name { + static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer") +} + +// MARK: - 主视图 +struct BlindBoxView: View { + @State private var showLottieAnimation = true // 控制Lottie动画显示 + + // MARK: - 主体视图 + var body: some View { + ZStack { + // 全局背景颜色背景色 + Color.themeTextWhiteSecondary.ignoresSafeArea() + + // 主内容区域 + VStack { + VStack(spacing: 20) { + // 标题 + VStack(alignment: .leading, spacing: 4) { + Text("Hi! Click And") + Text("Open Your First Box~") + } + .font(Typography.font(for: .smallLargeTitle)) + .fontWeight(.bold) + .foregroundColor(Color.themeTextMessageMain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + // 盲盒 + // 添加SVG背景图片 + ZStack { + // 1. 背景SVG + SVGImage(svgName: "BlindBg") + .frame( + width: UIScreen.main.bounds.width * 1.8, + height: UIScreen.main.bounds.height * 0.85 + ) + .position(x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height * 0.325) + + LottieView(name: "loading", loopMode: .loop) + .frame(width: 200, height: 200) + .position(x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height * 0.325) + .onAppear { + // Load image from URL asynchronously + if let url = URL(string: "https://cdn.fairclip.cn/files/7348219809961742336/c5ca6151-91d3-483e-b7e7-c37f2cb69dc0.png") { + URLSession.shared.dataTask(with: url) { data, response, error in + if let data = data, let image = UIImage(data: data) { + let media = MediaType.image(image) + DispatchQueue.main.async { + // Present the outcome view modally + let outcomeView = BlindOutcomeView(media: media) + .ignoresSafeArea() + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.rootViewController?.present(UIHostingController(rootView: outcomeView), animated: true) + } + } + } + }.resume() + } + } + } + .frame( + maxWidth: .infinity, + maxHeight: UIScreen.main.bounds.height * 0.65 + ) + .clipped() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.themeTextWhiteSecondary) + .edgesIgnoringSafeArea(.all) + } + } + .navigationBarBackButtonHidden(true) + } +} +// MARK: - 预览 +#Preview { + BlindBoxView() +} diff --git a/wake/View/Blind/BlindOutCome.swift b/wake/View/Blind/BlindOutCome.swift new file mode 100644 index 0000000..f60aa5e --- /dev/null +++ b/wake/View/Blind/BlindOutCome.swift @@ -0,0 +1,304 @@ +import SwiftUI +import AVKit +import os.log + +/// A view that displays either an image or a video with fullscreen support +struct BlindOutcomeView: View { + let media: MediaType + @Environment(\.presentationMode) var presentationMode + @State private var isFullscreen = false + @State private var isPlaying = false + + var body: some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + // Custom navigation header + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(.white) + .padding() + } + + Spacer() + + Text("Blind Box") + .font(.headline) + .foregroundColor(.white) + + Spacer() + + // Invisible view to balance the layout + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(.clear) + .padding() + } + .background(Color.black) + + // Media content + ZStack { + switch media { + case .image(let uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .onTapGesture { + withAnimation { + isFullscreen.toggle() + } + } + + case .video(let url, _): + // Create an AVPlayer with the video URL + let player = AVPlayer(url: url) + VideoPlayer(player: player) + .onAppear { + player.play() + isPlaying = true + } + .onDisappear { + player.pause() + isPlaying = false + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + } + .navigationBarHidden(true) + .statusBar(hidden: isFullscreen) + .fullScreenCover(isPresented: $isFullscreen) { + if case .video(let url, _) = media { + let player = AVPlayer(url: url) + FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: player) + } else { + FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: nil) + } + } + } +} + +// MARK: - Fullscreen Media View +private struct FullscreenMediaView: View { + let media: MediaType + @Binding var isPresented: Bool + @Binding var isPlaying: Bool + @State private var showControls = true + let player: AVPlayer? + + var body: some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + + // Media content + ZStack { + switch media { + case .image(let uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .onTapGesture { + withAnimation { + showControls.toggle() + } + } + + case .video(_, _): + if let player = player { + VideoPlayer(player: player) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + withAnimation { + showControls.toggle() + } + } + .overlay( + showControls ? VideoControls( + isPlaying: $isPlaying, + player: player, + onClose: { isPresented = false } + ) : nil + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Close button (always visible) + VStack { + HStack { + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.title2) + .foregroundColor(.white) + .padding() + .background(Color.black.opacity(0.5)) + .clipShape(Circle()) + } + .padding() + Spacer() + } + Spacer() + } + } + .onAppear { + if case .video = media { + player?.play() + isPlaying = true + } + } + .onDisappear { + if case .video = media { + player?.pause() + isPlaying = false + } + } + } +} + +// MARK: - Video Controls +private struct VideoControls: View { + @Binding var isPlaying: Bool + let player: AVPlayer + let onClose: () -> Void + @State private var currentTime: Double = 0 + @State private var duration: Double = 0 + @State private var isSeeking = false + + private let timeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = .pad + formatter.unitsStyle = .positional + return formatter + }() + + var body: some View { + VStack { + Spacer() + + HStack(spacing: 20) { + // Play/Pause button + Button(action: togglePlayPause) { + Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 30)) + .foregroundColor(.white) + } + + // Time slider + VStack { + Slider( + value: $currentTime, + in: 0...max(duration, 1), + onEditingChanged: { editing in + isSeeking = editing + if !editing { + let targetTime = CMTime(seconds: currentTime, preferredTimescale: 1) + player.seek(to: targetTime) + } + } + ) + .accentColor(.white) + + HStack { + Text(timeString(from: currentTime)) + .font(.caption) + .foregroundColor(.white) + + Spacer() + + Text(timeString(from: duration)) + .font(.caption) + .foregroundColor(.white) + } + } + + // Fullscreen button + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + } + } + .padding() + .background( + LinearGradient( + gradient: Gradient(colors: [Color.clear, Color.black.opacity(0.7)]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + .onAppear(perform: setupPlayerObservers) + .onDisappear(perform: removePlayerObservers) + } + + private func togglePlayPause() { + if isPlaying { + player.pause() + } else { + player.play() + } + isPlaying.toggle() + } + + private func timeString(from seconds: Double) -> String { + guard !seconds.isNaN && !seconds.isInfinite else { return "--:--" } + return timeFormatter.string(from: seconds) ?? "--:--" + } + + private func setupPlayerObservers() { + // Add time observer to update slider + let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + _ = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [self] time in + guard !isSeeking else { return } + currentTime = time.seconds + + // Update duration if needed + if let duration = player.currentItem?.duration.seconds, duration > 0 { + self.duration = duration + } + } + + // Add observer for when the video ends + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem, + queue: .main + ) { [self] _ in + // Loop the video + player.seek(to: .zero) + player.play() + isPlaying = true + } + } + + private func removePlayerObservers() { + // Remove all observers when the view disappears + NotificationCenter.default.removeObserver( + self, + name: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) + } +} + +// MARK: - Preview +struct BlindOutcomeView_Previews: PreviewProvider { + static var previews: some View { + // Preview with image + BlindOutcomeView(media: .image(UIImage(systemName: "photo")!)) + + // Preview with video + if let url = URL(string: "https://example.com/sample.mp4") { + BlindOutcomeView(media: .video(url, nil)) + } + } +} diff --git a/wake/View/Components/Upload/MediaPicker.swift b/wake/View/Components/Upload/MediaPicker.swift index 9c4aee1..9dd7273 100644 --- a/wake/View/Components/Upload/MediaPicker.swift +++ b/wake/View/Components/Upload/MediaPicker.swift @@ -3,39 +3,6 @@ import PhotosUI import os.log import AVKit -/// 媒体类型 -public enum MediaType: Equatable { - case image(UIImage) - case video(URL, UIImage?) // URL 是视频地址,UIImage 是视频缩略图 - - public var thumbnail: UIImage? { - switch self { - case .image(let image): - return image - case .video(_, let thumbnail): - return thumbnail - } - } - - public var isVideo: Bool { - if case .video = self { - return true - } - return false - } - - public static func == (lhs: MediaType, rhs: MediaType) -> Bool { - switch (lhs, rhs) { - case (.image(let lhsImage), .image(let rhsImage)): - return lhsImage.pngData() == rhsImage.pngData() - case (.video(let lhsURL, _), .video(let rhsURL, _)): - return lhsURL == rhsURL - default: - return false - } - } -} - enum MediaTypeFilter { case imagesOnly case videosOnly diff --git a/wake/View/Owner/UserInfo/UserInfo.swift b/wake/View/Owner/UserInfo/UserInfo.swift index a03f7ce..54f2452 100644 --- a/wake/View/Owner/UserInfo/UserInfo.swift +++ b/wake/View/Owner/UserInfo/UserInfo.swift @@ -142,7 +142,6 @@ struct UserInfo: View { // Continue Button Button(action: { if showUsername { - router.navigate(to: .avatarBox) let parameters: [String: Any] = [ "username": userName, "avatar_file_id": uploadedFileId ?? "" @@ -161,7 +160,7 @@ struct UserInfo: View { self.userName = userData.username } // Navigate using router - router.navigate(to: .avatarBox) + router.navigate(to: .blindBox) case .failure(let error): print("❌ 用户信息更新失败: \(error.localizedDescription)") diff --git a/wake/WakeApp.swift b/wake/WakeApp.swift index 838f5e8..13ff9ad 100644 --- a/wake/WakeApp.swift +++ b/wake/WakeApp.swift @@ -46,7 +46,9 @@ struct WakeApp: App { // 根据登录状态显示不同视图 if authState.isAuthenticated { // 已登录:显示userInfo页面 - ContentView() + // ContentView() + // .environmentObject(authState) + UserInfo() .environmentObject(authState) } else { // 未登录:显示登录界面