228 lines
8.9 KiB
Swift
228 lines
8.9 KiB
Swift
|
|
import Foundation
|
||
|
|
import Photos
|
||
|
|
import UIKit
|
||
|
|
import Combine
|
||
|
|
|
||
|
|
private let imageExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp","tiff","tif"]
|
||
|
|
private let videoExts: Set<String> = ["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<Void, Never>?
|
||
|
|
private var cancellables = Set<AnyCancellable>()
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
let manifestEntries = statusService.lastManifest?.entries ?? []
|
||
|
|
|
||
|
|
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<String>()
|
||
|
|
|
||
|
|
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 {}
|
||
|
|
}
|
||
|
|
}
|