450 lines
16 KiB
Swift
450 lines
16 KiB
Swift
import SwiftUI
|
|
import AVKit
|
|
|
|
// MARK: - API Response Models
|
|
struct MaterialResponse: Decodable {
|
|
let code: Int
|
|
let data: MaterialData
|
|
|
|
struct MaterialData: Decodable {
|
|
let items: [MemoryItem]
|
|
let hasMore: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case items
|
|
case hasMore = "has_more"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MemoryItem: Identifiable, Decodable {
|
|
let id: String
|
|
let name: String?
|
|
let description: String?
|
|
let fileInfo: FileInfo
|
|
let previewFileInfo: FileInfo
|
|
|
|
var title: String { name ?? "Untitled" }
|
|
var subtitle: String { description ?? "" }
|
|
var mediaType: MemoryMediaType {
|
|
let url = fileInfo.url.lowercased()
|
|
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
|
|
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
|
|
} else {
|
|
return .image(fileInfo.url)
|
|
}
|
|
}
|
|
var aspectRatio: CGFloat { 1.0 }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, description
|
|
case fileInfo = "file_info"
|
|
case previewFileInfo = "preview_file_info"
|
|
}
|
|
}
|
|
|
|
struct FileInfo: Decodable {
|
|
let id: String
|
|
let fileName: String
|
|
let url: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case fileName = "file_name"
|
|
case url
|
|
}
|
|
}
|
|
|
|
enum MemoryMediaType: Equatable {
|
|
case image(String)
|
|
case video(url: String, previewUrl: String)
|
|
}
|
|
|
|
struct MemoriesView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@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),
|
|
GridItem(.flexible(), spacing: 1)
|
|
]
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
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(Typography.font(for: .body, family: .quicksandBold))
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.background(Color.themeTextWhiteSecondary)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Full Screen Modal
|
|
if let memory = selectedMemory {
|
|
FullScreenMediaView(memory: memory, isPresented: $selectedMemory)
|
|
.transition(.opacity)
|
|
.zIndex(1)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarBackButtonHidden(true)
|
|
.onAppear {
|
|
fetchMemories()
|
|
}
|
|
}
|
|
|
|
private func fetchMemories() {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
let parameters: [String: Any] = ["page": 0]
|
|
|
|
NetworkService.shared.get(path: "/material/list", parameters: parameters) { [self] (result: Result<MaterialResponse, NetworkError>) in
|
|
DispatchQueue.main.async { [self] in
|
|
self.isLoading = false
|
|
|
|
switch result {
|
|
case .success(let response):
|
|
print("✅ Successfully fetched \(response.data.items) memory items")
|
|
response.data.items.forEach { item in
|
|
print("📝 Item ID: \(item.id), Title: \(item.name ?? "Untitled"), URL: \(item)")
|
|
}
|
|
self.memories = response.data.items
|
|
case .failure(let error):
|
|
self.errorMessage = error.localizedDescription
|
|
print("❌ Failed to fetch memories: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 16) {
|
|
ZStack {
|
|
// Media content
|
|
Group {
|
|
switch memory.mediaType {
|
|
case .image(let url):
|
|
if let url = URL(string: url) {
|
|
AsyncImage(url: url) { phase in
|
|
if let image = phase.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else if phase.error != nil {
|
|
Color.gray.opacity(0.3)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
|
|
case .video(_, let previewUrl):
|
|
if let previewUrl = URL(string: previewUrl) {
|
|
AsyncImage(url: previewUrl) { phase in
|
|
if let image = phase.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else if phase.error != nil {
|
|
Color.gray.opacity(0.3)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
} else {
|
|
Color.gray.opacity(0.3)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: (UIScreen.main.bounds.width / 2) - 24,
|
|
height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio))
|
|
.clipped()
|
|
.cornerRadius(12)
|
|
|
|
// Show play button for videos
|
|
if case .video = memory.mediaType {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.white.opacity(0.9))
|
|
.shadow(radius: 3)
|
|
}
|
|
}
|
|
|
|
// Title and Subtitle
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(memory.title)
|
|
.font(Typography.font(for: .body, family: .quicksandBold))
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.lineLimit(1)
|
|
|
|
Text(memory.subtitle)
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.themeTextMessageMain)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.horizontal, 2)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
MemoriesView()
|
|
}
|