From 84cc5d207fbaffb114aedaecb4c023dc214b3b10 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Fri, 29 Aug 2025 21:27:48 +0800 Subject: [PATCH] feat: memories --- wake/View/Memories/MemoriesView.swift | 309 ++++++++++++++++++++++---- 1 file changed, 260 insertions(+), 49 deletions(-) diff --git a/wake/View/Memories/MemoriesView.swift b/wake/View/Memories/MemoriesView.swift index f3bcdb9..194de72 100644 --- a/wake/View/Memories/MemoriesView.swift +++ b/wake/View/Memories/MemoriesView.swift @@ -65,6 +65,7 @@ struct MemoriesView: View { @State private var memories: [MemoryItem] = [] @State private var isLoading = false @State private var errorMessage: String? + @State private var selectedMemory: MemoryItem? = nil let columns = [ GridItem(.flexible(), spacing: 1), @@ -73,51 +74,64 @@ struct MemoriesView: View { var body: some View { NavigationView { - VStack(spacing: 0) { - // 顶部导航栏 - HStack { - Button(action: { - // 返回上一级 - self.dismiss() - }) { - Image(systemName: "chevron.left") + ZStack { + VStack(spacing: 0) { + // Top navigation bar + HStack { + Button(action: { + self.dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.themeTextMessageMain) + .font(.system(size: 20)) + } + Spacer() + Text("My Memories") .foregroundColor(.themeTextMessageMain) - .font(.system(size: 20)) + .font(Typography.font(for: .body, family: .quicksandBold)) + Spacer() } - Spacer() - Text("My Memories") - .foregroundColor(.themeTextMessageMain) - .font(Typography.font(for: .body, family: .quicksandBold)) - Spacer() - } - .padding() - .background(Color.themeTextWhiteSecondary) - - // 内容区域 - ZStack { - Color.themeTextWhiteSecondary.ignoresSafeArea() + .padding() + .background(Color.themeTextWhiteSecondary) - Group { - if isLoading { - ProgressView() - .scaleEffect(1.5) - } else if let error = errorMessage { - Text("Error: \(error)") - .foregroundColor(.red) - } else { - ScrollView { - LazyVGrid(columns: columns, spacing: 4) { - ForEach(memories) { memory in - MemoryCard(memory: memory) - .padding(.horizontal, 2) + // Content area + ZStack { + Color.themeTextWhiteSecondary.ignoresSafeArea() + + Group { + if isLoading { + ProgressView() + .scaleEffect(1.5) + } else if let error = errorMessage { + Text("Error: \(error)") + .foregroundColor(.red) + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 4) { + ForEach(memories) { memory in + MemoryCard(memory: memory) + .padding(.horizontal, 2) + .onTapGesture { + withAnimation(.spring()) { + selectedMemory = memory + } + } + } } + .padding(.top, 4) + .padding(.horizontal, 4) } - .padding(.top, 4) - .padding(.horizontal, 4) } } } } + + // Full Screen Modal + if let memory = selectedMemory { + FullScreenMediaView(memory: memory, isPresented: $selectedMemory) + .transition(.opacity) + .zIndex(1) + } } } .navigationBarBackButtonHidden(true) @@ -152,17 +166,221 @@ struct MemoriesView: View { } } +struct FullScreenMediaView: View { + let memory: MemoryItem + @Binding var isPresented: MemoryItem? + @State private var isVideoPlaying = false + @State private var showControls = true + @State private var controlsTimer: Timer? = nil + @State private var player: AVPlayer? = nil + + var body: some View { + ZStack { + // Background + Color.black.ignoresSafeArea() + + // Media content with back button overlay + ZStack { + // Media content + switch memory.mediaType { + case .image(let url): + if let imageURL = URL(string: url) { + AsyncImage(url: imageURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.height) + .edgesIgnoringSafeArea(.all) + case .failure(_): + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + } + + case .video(let url, let previewUrl): + if let videoURL = URL(string: url) { + VideoPlayer(player: player) + .onAppear { + self.player = AVPlayer(url: videoURL) + self.player?.play() + self.isVideoPlaying = true + } + .onDisappear { + self.player?.pause() + self.player = nil + } + .frame(width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.height) + .onTapGesture { + togglePlayPause() + } + } + } + + // Back button - Always visible at the top-left of the device screen + VStack { + HStack { + Button(action: { + withAnimation(.spring()) { + isPresented = nil + pauseVideo() + } + }) { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .padding(12) + } + .padding(.leading, 16) + .padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) + + Spacer() + } + Spacer() + } + .zIndex(2) // Higher z-index to keep it above media content + + // Video controls overlay (only for video) + if case .video = memory.mediaType, showControls { + VStack { + Spacer() + // Play/pause button + Button(action: { + togglePlayPause() + }) { + Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 70)) + .foregroundColor(.white.opacity(0.9)) + .shadow(radius: 3) + } + .padding(.bottom, 30) + } + .transition(.opacity) + .onAppear { + resetControlsTimer() + } + .zIndex(3) // Highest z-index for controls + } + } + .ignoresSafeArea() + .statusBar(hidden: true) + } + .onTapGesture { + if case .video = memory.mediaType { + withAnimation(.easeInOut) { + showControls.toggle() + } + if showControls { + resetControlsTimer() + } + } + } + .statusBar(hidden: true) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + if case .video = memory.mediaType { + setupVideoPlayer() + } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + controlsTimer?.invalidate() + pauseVideo() + } + } + + private func setupVideoPlayer() { + if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { + self.player = AVPlayer(url: videoURL) + self.player?.play() + self.isVideoPlaying = true + + // Add observer for playback end + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: self.player?.currentItem, + queue: .main + ) { _ in + self.player?.seek(to: .zero) { _ in + self.player?.play() + } + } + } + } + + private func togglePlayPause() { + if isVideoPlaying { + pauseVideo() + } else { + playVideo() + } + withAnimation { + showControls = true + } + resetControlsTimer() + } + + private func playVideo() { + player?.play() + isVideoPlaying = true + } + + private func pauseVideo() { + player?.pause() + isVideoPlaying = false + } + + private func resetControlsTimer() { + controlsTimer?.invalidate() + controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + withAnimation(.easeInOut) { + showControls = false + } + } + } +} + +struct VideoPlayer: UIViewRepresentable { + let player: AVPlayer? + + func makeUIView(context: Context) -> UIView { + let view = UIView() + if let player = player { + let playerLayer = AVPlayerLayer(player: player) + playerLayer.frame = UIScreen.main.bounds + playerLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(playerLayer) + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer { + playerLayer.player = player + playerLayer.frame = UIScreen.main.bounds + } + } +} + struct MemoryCard: View { let memory: MemoryItem var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { ZStack { // Media content Group { switch memory.mediaType { - case .image(let urlString): - if let url = URL(string: urlString) { + case .image(let url): + if let url = URL(string: url) { AsyncImage(url: url) { phase in if let image = phase.image { image @@ -176,8 +394,7 @@ struct MemoryCard: View { } } - case .video(let url, let previewUrl): - // Use preview image for video + case .video(_, let previewUrl): if let previewUrl = URL(string: previewUrl) { AsyncImage(url: previewUrl) { phase in if let image = phase.image { @@ -210,7 +427,7 @@ struct MemoryCard: View { } // Title and Subtitle - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 16) { Text(memory.title) .font(Typography.font(for: .body, family: .quicksandBold)) .foregroundColor(.themeTextMessageMain) @@ -219,6 +436,7 @@ struct MemoryCard: View { Text(memory.subtitle) .font(.system(size: 14)) .foregroundColor(.themeTextMessageMain) + .lineLimit(2) } .padding(.horizontal, 2) .padding(.bottom, 8) @@ -226,13 +444,6 @@ struct MemoryCard: View { } } -// Helper extension to pause video -private extension AVPlayer { - func pause() { - self.pause() - } -} - #Preview { MemoriesView() }