feat: 盲盒成果页

This commit is contained in:
jinyaqiu 2025-09-01 16:06:08 +08:00
parent c5cb87b90b
commit 34b6fa7894
3 changed files with 124 additions and 148 deletions

View File

@ -602,6 +602,12 @@ struct BlindBoxView: View {
stopPolling() stopPolling()
countdownTimer?.invalidate() countdownTimer?.invalidate()
countdownTimer = nil countdownTimer = nil
// Clean up video player
videoPlayer?.pause()
videoPlayer?.replaceCurrentItem(with: nil)
videoPlayer = nil
NotificationCenter.default.removeObserver( NotificationCenter.default.removeObserver(
self, self,
name: .blindBoxStatusChanged, name: .blindBoxStatusChanged,

View File

@ -10,6 +10,7 @@ struct BlindOutcomeView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@State private var isFullscreen = false @State private var isFullscreen = false
@State private var isPlaying = false @State private var isPlaying = false
@State private var showControls = true
@State private var showIPListModal = false @State private var showIPListModal = false
init(media: MediaType, time: String? = nil, description: String? = nil) { init(media: MediaType, time: String? = nil, description: String? = nil) {
@ -75,6 +76,7 @@ struct BlindOutcomeView: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.cornerRadius(10) .cornerRadius(10)
.padding(4) .padding(4)
.onTapGesture { .onTapGesture {
@ -84,19 +86,29 @@ struct BlindOutcomeView: View {
} }
case .video(let url, _): case .video(let url, _):
// Create an AVPlayer with the video URL VideoPlayerView(url: url, isPlaying: $isPlaying)
let player = AVPlayer(url: url) .frame(width: UIScreen.main.bounds.width - 40)
VideoPlayer(player: player) .background(Color.clear)
.cornerRadius(10) .cornerRadius(10)
.padding(4) .clipped()
.onAppear { .onAppear {
player.play() // Auto-play the video when it appears
isPlaying = true isPlaying = true
} }
.onDisappear { .onTapGesture {
player.pause() withAnimation {
isPlaying = false showControls.toggle()
}
} }
.fullScreenCover(isPresented: $isFullscreen) {
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: nil)
}
.overlay(
showControls ? VideoControls(
isPlaying: $isPlaying,
onClose: { isFullscreen = false }
) : nil
)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -154,17 +166,6 @@ struct BlindOutcomeView: View {
.navigationBarHidden(true) // .navigationBarHidden(true) //
.navigationBarBackButtonHidden(true) // .navigationBarBackButtonHidden(true) //
.statusBar(hidden: isFullscreen) .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)
}
}
.overlay(
JoinModal(isPresented: $showIPListModal)
)
} }
.navigationViewStyle(StackNavigationViewStyle()) // iPad .navigationViewStyle(StackNavigationViewStyle()) // iPad
.navigationBarHidden(true) // .navigationBarHidden(true) //
@ -177,7 +178,16 @@ private struct FullscreenMediaView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@Binding var isPlaying: Bool @Binding var isPlaying: Bool
@State private var showControls = true @State private var showControls = true
let player: AVPlayer? @State private var player: AVPlayer?
init(media: MediaType, isPresented: Binding<Bool>, isPlaying: Binding<Bool>, player: AVPlayer?) {
self.media = media
self._isPresented = isPresented
self._isPlaying = isPlaying
if let player = player {
self._player = State(initialValue: player)
}
}
var body: some View { var body: some View {
ZStack { ZStack {
@ -190,29 +200,27 @@ private struct FullscreenMediaView: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture { .onTapGesture {
withAnimation { withAnimation {
showControls.toggle() showControls.toggle()
} }
} }
case .video(_, _): case .video(let url, _):
if let player = player { VideoPlayerView(url: url, isPlaying: $isPlaying)
VideoPlayer(player: player) .frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture {
.onTapGesture { withAnimation {
withAnimation { showControls.toggle()
showControls.toggle()
}
} }
.overlay( }
showControls ? VideoControls( .overlay(
isPlaying: $isPlaying, showControls ? VideoControls(
player: player, isPlaying: $isPlaying,
onClose: { isPresented = false } onClose: { isPresented = false }
) : nil ) : nil
) )
}
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@ -236,14 +244,16 @@ private struct FullscreenMediaView: View {
} }
.onAppear { .onAppear {
if case .video = media { if case .video = media {
player?.play() if isPlaying {
isPlaying = true // player?.play()
}
} }
} }
.onDisappear { .onDisappear {
if case .video = media { if case .video = media {
player?.pause() // player?.pause()
isPlaying = false // player?.replaceCurrentItem(with: nil)
// player = nil
} }
} }
} }
@ -252,127 +262,87 @@ private struct FullscreenMediaView: View {
// MARK: - Video Controls // MARK: - Video Controls
private struct VideoControls: View { private struct VideoControls: View {
@Binding var isPlaying: Bool @Binding var isPlaying: Bool
let player: AVPlayer
let onClose: () -> Void 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 { var body: some View {
VStack { // Empty view - no controls shown
Spacer() EmptyView()
}
HStack(spacing: 20) { }
// Play/Pause button
Button(action: togglePlayPause) { // MARK: - Video Player with Dynamic Aspect Ratio
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") struct VideoPlayerView: UIViewRepresentable {
.font(.system(size: 30)) let url: URL
.foregroundColor(.white) @Binding var isPlaying: Bool
}
func makeUIView(context: Context) -> PlayerView {
// Time slider let view = PlayerView()
VStack { view.setupPlayer(url: url)
Slider( return view
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() { func updateUIView(_ uiView: PlayerView, context: Context) {
if isPlaying { if isPlaying {
player.pause() uiView.play()
} else { } else {
player.play() uiView.pause()
} }
isPlaying.toggle()
} }
}
class PlayerView: UIView {
private var player: AVPlayer?
private var playerLayer: AVPlayerLayer?
private func timeString(from seconds: Double) -> String { func setupPlayer(url: URL) {
guard !seconds.isNaN && !seconds.isInfinite else { return "--:--" } // Remove existing player if any
return timeFormatter.string(from: seconds) ?? "--:--" playerLayer?.removeFromSuperlayer()
}
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 // Create new player
let asset = AVAsset(url: url)
let playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
// Setup player layer
let playerLayer = AVPlayerLayer(player: player)
playerLayer.videoGravity = .resizeAspect
layer.addSublayer(playerLayer)
self.playerLayer = playerLayer
// Layout
playerLayer.frame = bounds
// Add observer for video end
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, self,
object: player.currentItem, selector: #selector(playerItemDidReachEnd),
queue: .main name: .AVPlayerItemDidPlayToEndTime,
) { [self] _ in object: playerItem
// Loop the video )
player.seek(to: .zero)
player.play()
isPlaying = true
}
} }
private func removePlayerObservers() { func play() {
// Remove all observers when the view disappears player?.play()
NotificationCenter.default.removeObserver( }
self,
name: .AVPlayerItemDidPlayToEndTime, func pause() {
object: player.currentItem player?.pause()
) }
@objc private func playerItemDidReachEnd() {
player?.seek(to: .zero)
player?.play()
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer?.frame = bounds
}
deinit {
player?.pause()
player?.replaceCurrentItem(with: nil)
NotificationCenter.default.removeObserver(self)
} }
} }

View File

@ -46,7 +46,7 @@ struct WakeApp: App {
if authState.isAuthenticated { if authState.isAuthenticated {
// //
NavigationStack(path: $router.path) { NavigationStack(path: $router.path) {
UserInfo() BlindBoxView(mediaType: .all)
.navigationDestination(for: AppRoute.self) { route in .navigationDestination(for: AppRoute.self) { route in
route.view route.view
} }