feat: memories

This commit is contained in:
jinyaqiu 2025-09-02 20:23:21 +08:00
parent 4836c1f4ae
commit 77c9a855c6

View File

@ -99,32 +99,6 @@ struct MemoriesView: View {
ZStack { ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea() 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)
// }
// }
// }
// Replace the WaterfallGrid line with this:
ScrollView { ScrollView {
WaterfallGrid(memories) { memory in WaterfallGrid(memories) { memory in
MemoryCard(memory: memory) MemoryCard(memory: memory)
@ -186,7 +160,23 @@ struct FullScreenMediaView: View {
@State private var isVideoPlaying = false @State private var isVideoPlaying = false
@State private var showControls = true @State private var showControls = true
@State private var controlsTimer: Timer? = nil @State private var controlsTimer: Timer? = nil
@State private var player: AVPlayer? = nil @State private var imageAspectRatio: CGFloat = 1.0
@State private var isLoading = true
private func loadAspectRatio(from url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
height > 0 else {
imageAspectRatio = 16/9 // Default to 16:9 if we can't determine the aspect ratio
isLoading = false
return
}
imageAspectRatio = width / height
isLoading = false
}
var body: some View { var body: some View {
ZStack { ZStack {
@ -196,46 +186,67 @@ struct FullScreenMediaView: View {
// Media content with back button overlay // Media content with back button overlay
ZStack { ZStack {
// Media content // Media content
switch memory.mediaType { GeometryReader { geometry in
case .image(let url):
if let imageURL = URL(string: url) { switch memory.mediaType {
AsyncImage(url: imageURL) { phase in case .image(let url):
switch phase { if let imageURL = URL(string: url) {
case .success(let image): AsyncImage(url: imageURL) { phase in
image switch phase {
.resizable() case .success(let image):
.scaledToFill() GeometryReader { geometry in
.frame(width: UIScreen.main.bounds.width, ZStack {
height: UIScreen.main.bounds.height) Color.black
.edgesIgnoringSafeArea(.all) image
case .failure(_): .resizable()
Image(systemName: "exclamationmark.triangle") .scaledToFit()
.foregroundColor(.red) .frame(
case .empty: width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
ProgressView() height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
@unknown default: )
EmptyView() }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
imageAspectRatio = size.width / size.height
isLoading = false
}
}
case .failure(_):
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
case .empty:
ProgressView()
@unknown default:
EmptyView()
}
} }
} }
}
case .video(_, let previewUrl):
case .video(let url, let previewUrl): GeometryReader { geometry in
if let videoURL = URL(string: url) { ZStack {
VideoPlayer(player: player) Color.clear
.onAppear { VideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying)
self.player = AVPlayer(url: videoURL) .aspectRatio(imageAspectRatio, contentMode: .fit)
self.player?.play() .frame(
self.isVideoPlaying = true width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
} height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
.onDisappear { )
self.player?.pause() .onAppear {
self.player = nil if let previewUrl = URL(string: previewUrl) {
} loadAspectRatio(from: previewUrl)
.frame(width: UIScreen.main.bounds.width, }
height: UIScreen.main.bounds.height) isVideoPlaying = true
.onTapGesture { }
togglePlayPause() .onDisappear {
isVideoPlaying = false
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
} }
} }
@ -245,13 +256,14 @@ struct FullScreenMediaView: View {
Button(action: { Button(action: {
withAnimation(.spring()) { withAnimation(.spring()) {
isPresented = nil isPresented = nil
pauseVideo() // pauseVideo()
} }
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .bold)) .font(.system(size: 20, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
.padding(12) .padding(12)
.background(Circle().fill(Color.black.opacity(0.4)))
} }
.padding(.leading, 16) .padding(.leading, 16)
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) .padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
@ -261,28 +273,6 @@ struct FullScreenMediaView: View {
Spacer() Spacer()
} }
.zIndex(2) // Higher z-index to keep it above media content .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() .ignoresSafeArea()
.statusBar(hidden: true) .statusBar(hidden: true)
@ -290,96 +280,86 @@ struct FullScreenMediaView: View {
.onTapGesture { .onTapGesture {
if case .video = memory.mediaType { if case .video = memory.mediaType {
withAnimation(.easeInOut) { withAnimation(.easeInOut) {
showControls.toggle() // showControls.toggle()
}
if showControls {
resetControlsTimer()
} }
// if showControls {
// resetControlsTimer()
// }
} }
} }
.statusBar(hidden: true) .statusBar(hidden: true)
.onAppear { .onAppear {
UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.isIdleTimerDisabled = true
if case .video = memory.mediaType { if case .video = memory.mediaType {
setupVideoPlayer() // setupVideoPlayer()
} }
} }
.onDisappear { .onDisappear {
UIApplication.shared.isIdleTimerDisabled = false UIApplication.shared.isIdleTimerDisabled = false
controlsTimer?.invalidate() controlsTimer?.invalidate()
pauseVideo() // pauseVideo()
} }
} }
private func setupVideoPlayer() { // private func setupVideoPlayer() {
if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) { // if case .video(let url, _) = memory.mediaType, let videoURL = URL(string: url) {
self.player = AVPlayer(url: videoURL) // // No need to set up player here
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() { // private func togglePlayPause() {
if isVideoPlaying { // if isVideoPlaying {
pauseVideo() // pauseVideo()
} else { // } else {
playVideo() // playVideo()
} // }
withAnimation { // withAnimation {
showControls = true // showControls = true
} // }
resetControlsTimer() // resetControlsTimer()
} // }
private func playVideo() { // private func playVideo() {
player?.play() // // No need to play video here
isVideoPlaying = true // }
}
private func pauseVideo() { // private func pauseVideo() {
player?.pause() // // No need to pause video here
isVideoPlaying = false // }
}
private func resetControlsTimer() { // private func resetControlsTimer() {
controlsTimer?.invalidate() // controlsTimer?.invalidate()
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in // controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
withAnimation(.easeInOut) { // withAnimation(.easeInOut) {
showControls = false // showControls = false
} // }
} // }
} // }
} }
struct VideoPlayer: UIViewRepresentable { struct VideoPlayer: UIViewControllerRepresentable {
let player: AVPlayer? let url: String
@Binding var isPlaying: Bool
func makeUIView(context: Context) -> UIView { func makeUIViewController(context: Context) -> AVPlayerViewController {
let view = UIView() let controller = AVPlayerViewController()
if let player = player { let player = AVPlayer(url: URL(string: url)!)
let playerLayer = AVPlayerLayer(player: player) controller.player = player
playerLayer.frame = UIScreen.main.bounds controller.showsPlaybackControls = true
playerLayer.videoGravity = .resizeAspectFill controller.videoGravity = .resizeAspect
view.layer.addSublayer(playerLayer)
} // Make the background transparent
return view controller.view.backgroundColor = .clear
controller.view.isOpaque = false
return controller
} }
func updateUIView(_ uiView: UIView, context: Context) { func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
if let player = player, let playerLayer = uiView.layer.sublayers?.first as? AVPlayerLayer { if isPlaying {
playerLayer.player = player uiViewController.player?.play()
playerLayer.frame = UIScreen.main.bounds } else {
uiViewController.player?.pause()
} }
} }
} }
@ -414,17 +394,27 @@ struct MemoryCard: View {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
Group { Group {
if let image = phase.image { if let image = phase.image {
image GeometryReader { geometry in
.resizable() ZStack {
.aspectRatio(contentMode: .fill) Color.black
.onAppear { image
// Get image dimensions .resizable()
if let uiImage = image.asUIImage() { .scaledToFit()
let size = uiImage.size .frame(
aspectRatio = size.width / size.height width: min(geometry.size.width, geometry.size.height * aspectRatio),
isLoading = false height: min(geometry.size.height, geometry.size.width / aspectRatio)
} )
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill)
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
aspectRatio = size.width / size.height
isLoading = false
}
}
} else if phase.error != nil { } else if phase.error != nil {
Color.gray.opacity(0.3) Color.gray.opacity(0.3)
} else { } else {
@ -506,6 +496,23 @@ extension View {
} }
} }
// Add this extension to MemoryMediaType to get the URL
private extension MemoryMediaType {
var isVideo: Bool {
if case .video = self { return true }
return false
}
var url: String {
switch self {
case .image(let url):
return url
case .video(let url, _):
return url
}
}
}
#Preview { #Preview {
MemoriesView() MemoriesView()
} }