import Foundation import Photos import UIKit import Combine private let imageExts: Set = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"] private let videoExts: Set = ["mp4","mov","m4v","avi","mkv"] @MainActor final class GalleryViewModel: ObservableObject { enum Tab: String, CaseIterable { case all = "All", phone = "Phone", nas = "NAS" } @Published var tab: Tab = .all @Published var searchText = "" @Published private(set) var sections: [GallerySection] = [] @Published private(set) var isLoading = false @Published private(set) var nasError: String? private(set) var allItems: [GalleryItem] = [] private var loadTask: Task? private var cancellables = Set() init() { $tab .dropFirst() .receive(on: RunLoop.main) .sink { [weak self] _ in self?.recomputeSections() } .store(in: &cancellables) $searchText .dropFirst() .debounce(for: .milliseconds(150), scheduler: RunLoop.main) .sink { [weak self] _ in self?.recomputeSections() } .store(in: &cancellables) } func load(connection: NASConnection?, statusService: BackupStatusService) async { loadTask?.cancel() isLoading = true nasError = nil // Prefer the in-memory lastManifest (most recent reconcile), fall back to // disk-cached NASManifestCache (survives launches without NAS connection). let manifestEntries: [ManifestEntry] if let m = statusService.lastManifest { manifestEntries = m.entries } else if let cached = await NASManifestCache.shared.manifest { manifestEntries = cached.entries } else { manifestEntries = [] } let phoneAssets = await Task.detached(priority: .userInitiated) { Self.fetchPhoneAssets() }.value let items = await Task.detached(priority: .userInitiated) { Self.buildItems(phone: phoneAssets, manifest: manifestEntries) }.value allItems = items recomputeSections() isLoading = false } func recomputeSections() { let filtered = filteredItems() sections = buildSections(from: filtered) } // MARK: — Static off-actor workers private nonisolated static func fetchPhoneAssets() -> [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)] { let opts = PHFetchOptions() opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] opts.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared, .typeiTunesSynced] let result = PHAsset.fetchAssets(with: opts) var out: [(String, String, Date?, TimeInterval?, Bool)] = [] out.reserveCapacity(result.count) result.enumerateObjects { asset, _, _ in let resources = PHAssetResource.assetResources(for: asset) let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier.prefix(8)).jpg" let isVideo = asset.mediaType == .video out.append(( asset.localIdentifier, filename, asset.creationDate, isVideo ? asset.duration : nil, isVideo )) } return out } private nonisolated static func buildItems( phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)], manifest: [ManifestEntry] ) -> [GalleryItem] { var byID = [String: ManifestEntry](minimumCapacity: manifest.count) var byName = [String: ManifestEntry](minimumCapacity: manifest.count) for entry in manifest { if !entry.localIdentifier.isEmpty { byID[entry.localIdentifier] = entry } let key = entry.filename.lowercased() if byName[key] == nil { byName[key] = entry } } var result: [GalleryItem] = [] result.reserveCapacity(phone.count + manifest.count / 4) var coveredFilenames = Set() for (localID, filename, date, duration, isVideo) in phone { let key = filename.lowercased() if let entry = byID[localID] ?? byName[key] { coveredFilenames.insert(entry.filename.lowercased()) result.append(GalleryItem( id: "synced_\(localID)", filename: filename, creationDate: date, duration: duration, fileSize: entry.fileSize > 0 ? entry.fileSize : nil, mediaType: isVideo ? .video : .photo, source: .synced(localIdentifier: localID, nasPath: entry.remotePath) )) } else { result.append(GalleryItem( id: "phone_\(localID)", filename: filename, creationDate: date, duration: duration, fileSize: nil, mediaType: isVideo ? .video : .photo, source: .phone(localIdentifier: localID) )) } } // NAS-only: in manifest but not matched to a phone asset for entry in manifest { guard !coveredFilenames.contains(entry.filename.lowercased()) else { continue } let ext = (entry.filename as NSString).pathExtension.lowercased() let mediaType: GalleryItem.MediaType = videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown result.append(GalleryItem( id: "nas_\(entry.remotePath)", filename: entry.filename, creationDate: entry.creationDate, duration: nil, fileSize: entry.fileSize > 0 ? entry.fileSize : nil, mediaType: mediaType, source: .nas(path: entry.remotePath) )) } result.sort { ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) } return result } // MARK: — Filtering + grouping private func filteredItems() -> [GalleryItem] { var base: [GalleryItem] switch tab { case .all: base = allItems case .phone: base = allItems.filter { if case .nas = $0.source { return false }; return true } case .nas: base = allItems.filter { if case .phone = $0.source { return false }; return true } } let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() if !q.isEmpty { base = base.filter { $0.filename.lowercased().contains(q) } } return base } private func buildSections(from items: [GalleryItem]) -> [GallerySection] { let cal = Calendar.current let currentYear = cal.component(.year, from: Date()) var sections: [GallerySection] = [] var indexMap: [String: Int] = [:] for item in items { let date = item.creationDate ?? .distantPast let sectionID: String let title: String if item.creationDate == nil { sectionID = "unknown" title = "Unknown Date" } else { let comps = cal.dateComponents([.year, .month, .day], from: date) sectionID = String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) let year = comps.year ?? currentYear if year == currentYear { title = date.formatted(.dateTime.month(.wide).day()) } else { title = date.formatted(.dateTime.month(.wide).day().year()) } } if let idx = indexMap[sectionID] { sections[idx].items.append(item) } else { indexMap[sectionID] = sections.count sections.append(GallerySection(id: sectionID, date: date, title: title, items: [item])) } } return sections } // MARK: — Actions func storeLocally(item: GalleryItem, connection: NASConnection?) async { guard case .nas(let path) = item.source, let conn = connection else { return } do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let data = try await transfer.downloadData(at: path) transfer.disconnect() let ext = (item.filename as NSString).pathExtension.lowercased() let isVideo = videoExts.contains(ext) try await PHPhotoLibrary.shared().performChanges { if isVideo { let tmpURL = FileManager.default.temporaryDirectory .appendingPathComponent(item.filename) try? data.write(to: tmpURL) PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tmpURL) } else if let img = UIImage(data: data) { PHAssetChangeRequest.creationRequestForAsset(from: img) } } } catch {} } }