feat: 页面暂调

This commit is contained in:
jinyaqiu 2025-08-27 11:36:15 +08:00
parent 0ef80ca913
commit 963c87a9b9
9 changed files with 551 additions and 42 deletions

View File

@ -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 {
// 5Lottie
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,

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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))
}
}
}

View File

@ -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

View File

@ -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)")

View File

@ -46,7 +46,9 @@ struct WakeApp: App {
//
if authState.isAuthenticated {
// userInfo
ContentView()
// ContentView()
// .environmentObject(authState)
UserInfo()
.environmentObject(authState)
} else {
//